diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 374107ee..b6065ce7 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -21,7 +21,7 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        pip install setuptools wheel twine
+        pip install build twine
 
     - name: Check and get Version Number
       id: get_version
@@ -39,7 +39,7 @@ jobs:
         TWINE_USERNAME: __token__
         TWINE_PASSWORD: ${{ secrets.PYPI_JM }}
       run: |
-        python setup.py sdist bdist_wheel
+        python -m build
         twine upload dist/*
 
     - name: Create Draft Release
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 07607375..b62f09d6 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -60,9 +60,9 @@
 # built documents.
 #
 # The short X.Y version.
-version = re.match(r"v?(\d+\.\d+)", snewpy._version.__version__).group(1)
+version = re.match(r"v?(\d+\.\d+)", snewpy.__version__).group(1)
 # The full version, including alpha/beta/rc tags.
-release = snewpy._version.__version__
+release = snewpy.__version__
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 00000000..18663d33
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,51 @@
+[build-system]
+requires = ["setuptools>=61"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "snewpy"
+dynamic = ["version"]
+description = "A Python package for working with supernova neutrinos"
+authors = [{ name = "SNEWS Collaboration", email = "snews2.0@lists.bnl.gov" }]
+license = { text = "BSD" }
+readme = {file = "README.md", content-type = "text/markdown"}
+
+requires-python = ">=3.9"
+
+dependencies = [
+    "numpy",
+    "scipy",
+    "astropy >= 4.3",
+    "pandas",
+    "tqdm",
+    "matplotlib",
+    "h5py",
+    "requests",
+    "pyyaml",
+    "snowglobes_data == 1.3.2"
+]
+
+[project.optional-dependencies]
+dev = ["hypothesis", "pytest"]
+docs = ["numpydoc"]
+
+[project.urls]
+"Homepage" = "https://github.com/SNEWS2/snewpy"
+"Bug Tracker" = "https://github.com/SNEWS2/snewpy/issues"
+
+
+[tool.setuptools.dynamic]
+version = {attr = "snewpy.__version__"}
+
+[tool.setuptools.packages.find]
+where = ["python"]
+include = [
+    "snewpy",
+    "snewpy.*",
+]
+exclude = [
+    "snewpy.scripts",
+]
+
+[tool.setuptools.package-data]
+"snewpy.models" = ["*.yml"]
diff --git a/python/snewpy/__init__.py b/python/snewpy/__init__.py
index 3c1f0106..62889f5f 100644
--- a/python/snewpy/__init__.py
+++ b/python/snewpy/__init__.py
@@ -7,7 +7,6 @@
 Front-end for supernova models which provide neutrino luminosity and spectra.
 """
 
-from ._version import __version__
 from sys import exit
 import os
 
@@ -17,6 +16,10 @@
     # when imported by setup.py before dependencies are installed
     get_cache_dir = lambda: '.'
 
+
+__version__ = '1.6b1'
+
+
 src_path = os.path.realpath(__path__[0])
 base_path = os.sep.join(src_path.split(os.sep)[:-2])
 model_path = os.path.join(get_cache_dir(), 'snewpy', 'models')
diff --git a/python/snewpy/_git.py b/python/snewpy/_git.py
deleted file mode 100644
index b855030d..00000000
--- a/python/snewpy/_git.py
+++ /dev/null
@@ -1,179 +0,0 @@
-# Git interaction code adapted from https://github.com/desihub/desiutil.
-# The desiutil project is distributed under a 3-clause BSD style license:
-#
-# Copyright (c) 2014-2017, DESI Collaboration <desi-data@desi.lbl.gov>
-# All rights reserved.
-# 
-# Redistribution and use in source and binary forms, with or without
-# modification, are permitted provided that the following conditions are met:
-# 
-# * Redistributions of source code must retain the above copyright notice, this
-#   list of conditions and the following disclaimer.
-# * Redistributions in binary form must reproduce the above copyright notice,
-#   this list of conditions and the following disclaimer in the documentation
-#   and/or other materials provided with the distribution.
-# * Neither the name of the DESI Collaboration nor the names of its
-#   contributors may be used to endorse or promote products derived from this
-#   software without specific prior written permission.
-# 
-# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
-# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
-# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
-# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
-# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
-# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
-# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
-# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
-# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
-# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
-# POSSIBILITY OF SUCH DAMAGE.
-#
-"""Some code for interacting with git.
-"""
-
-import re
-from os.path import abspath, isdir, isfile, join
-from setuptools import Command
-
-
-def get_version():
-    """Get the value of ``__version__`` without having to import the module.
-
-    Returns
-    -------
-    :class:`str`
-        The value of ``__version__``.
-    """
-    ver = 'unknown'
-    try:
-        version_dir = find_version_directory()
-    except IOError:
-        return ver
-    version_file = join(version_dir, '_version.py')
-    if not isfile(version_file):
-        update_version()
-    with open(version_file, "r") as f:
-        for line in f.readlines():
-            mo = re.match("__version__ = '(.*)'", line)
-            if mo:
-                ver = mo.group(1)
-    return ver
-
-
-def get_git_version(git='git'):
-    """Use ``git describe`` to generate a version string.
-
-    Parameters
-    ----------
-    git : :class:`str`, optional
-        Path to the git executable, if not in :envvar:`PATH`.
-
-    Returns
-    -------
-    :class:`str`
-        A :pep:`386`-compatible version string.
-
-    Notes
-    -----
-    The version string should be compatible with :pep:`386` and
-    :pep:`440`.
-    """
-    from subprocess import Popen, PIPE
-    myversion = '0.0.1.dev0'
-    try:
-        p = Popen([git, "describe", "--tags", "--dirty", "--always"],
-                  universal_newlines=True, stdout=PIPE, stderr=PIPE)
-    except OSError:
-        return myversion
-    out, err = p.communicate()
-    if p.returncode != 0:
-        return myversion
-    ver = out.rstrip().split('-')[0]+'.dev'
-    try:
-        p = Popen([git, "rev-list", "--count", "HEAD"],
-                  universal_newlines=True, stdout=PIPE, stderr=PIPE)
-    except OSError:
-        return myversion
-    out, err = p.communicate()
-    if p.returncode != 0:
-        return myversion
-    ver += out.rstrip()
-    return ver
-
-
-def update_version(tag=None):
-    """Update the _version.py file.
-
-    Parameters
-    ----------
-    tag : :class:`str`, optional
-        Set the version to this string, unconditionally.
-
-    Raises
-    ------
-    IOError
-        If the repository type could not be determined.
-    """
-    version_dir = find_version_directory()
-    if tag is not None:
-        ver = tag
-    else:
-        if isdir(".git"):
-            ver = get_git_version()
-        else:
-            raise IOError("Repository type is not git.")
-    version_file = join(version_dir, '_version.py')
-    with open(version_file, "w") as f:
-        f.write("__version__ = '{}'\n".format(ver))
-    return
-
-
-def find_version_directory():
-    """Return the name of a directory containing version information.
-
-    Looks for files in the following places:
-
-    * python/snewpy/_version.py
-    * snewpy/_version.py
-
-    Returns
-    -------
-    :class:`str`
-        Name of a directory that can or does contain version information.
-
-    Raises
-    ------
-    IOError
-        If no valid directory can be found.
-    """
-    packagename='snewpy'
-    setup_dir = abspath('.')
-    if isdir(join(setup_dir, 'python', packagename)):
-        version_dir = join(setup_dir, 'python', packagename)
-    elif isdir(join(setup_dir, packagename)):
-        version_dir = join(setup_dir, packagename)
-    else:
-        raise IOError("Could not find a directory containing version information!")
-    return version_dir
-
-
-class SetVersion(Command):
-    """Allow users to easily update the package version with
-    ``python setup.py version``.
-    """
-    description = "update _version.py from git repo"
-    user_options = [('tag=', 't',
-                     'Set the version to a name in preparation for tagging.'),
-                    ]
-    boolean_options = []
-
-    def initialize_options(self):
-        self.tag = None
-
-    def finalize_options(self):
-        pass
-
-    def run(self):
-        update_version(tag=self.tag)
-        ver = get_version()
-        self.announce("Version is now {}.".format(ver), level=2)
diff --git a/python/snewpy/_version.py b/python/snewpy/_version.py
deleted file mode 100644
index a5f08e49..00000000
--- a/python/snewpy/_version.py
+++ /dev/null
@@ -1 +0,0 @@
-__version__ = '1.6b1'
diff --git a/python/snewpy/test/__init__.py b/python/snewpy/test/__init__.py
index b32ea693..e69de29b 100644
--- a/python/snewpy/test/__init__.py
+++ b/python/snewpy/test/__init__.py
@@ -1 +0,0 @@
-from .snewpy_test_suite import runtests
diff --git a/python/snewpy/test/snewpy_test_suite.py b/python/snewpy/test/snewpy_test_suite.py
deleted file mode 100644
index 70363511..00000000
--- a/python/snewpy/test/snewpy_test_suite.py
+++ /dev/null
@@ -1,24 +0,0 @@
-import unittest
-
-def snewpy_test_suite():
-    """Returns unittest.TestSuite of desiutil tests.
-
-    This is factored out separately from runtests() so that it can be used by
-    ``python setup.py test``.
-    """
-    from os.path import dirname
-    pydir = dirname(dirname(__file__))
-    tests = unittest.defaultTestLoader.discover(pydir,
-                                                top_level_dir=dirname(pydir))
-    return tests
-
-def runtests():
-    """Run all tests in snewpy.test.test_*.
-    """
-    # Load all TestCase classes from snewpy/test/test_*.py
-    tests = snewpy_test_suite()
-    # Run them
-    unittest.TextTestRunner(verbosity=2).run(tests)
-
-if __name__ == "__main__":
-    runtests()
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 0c737aaa..00000000
--- a/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-numpy
-scipy
-astropy >=4.3
-pandas
-tqdm
-matplotlib
-h5py
-requests
-pyyaml
-snowglobes_data == 1.3.2
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 30bdf9de..00000000
--- a/setup.py
+++ /dev/null
@@ -1,64 +0,0 @@
-#!/usr/bin/env python
-#
-# Licensed under a 3-clause BSD style license - see LICENSE.rst
-
-import os
-from setuptools import setup, find_packages
-
-# Git-based version info. Remove?
-from python.snewpy._git import get_version, SetVersion
-
-#
-# Begin setup
-#
-setup_keywords = dict()
-#
-setup_keywords['name'] = 'snewpy'
-setup_keywords['description'] = 'A Python package for working with supernova neutrinos'
-setup_keywords['author'] = 'SNEWS Collaboration'
-setup_keywords['author_email'] = 'snews2.0@lists.bnl.gov'
-setup_keywords['license'] = 'BSD'
-setup_keywords['url'] = 'https://github.com/SNEWS2/snewpy'
-setup_keywords['version'] = get_version()
-#
-# Use README.md as a long_description.
-#
-setup_keywords['long_description'] = ''
-if os.path.exists('README.md'):
-    with open('README.md') as readme:
-        setup_keywords['long_description'] = readme.read()
-    setup_keywords['long_description_content_type'] = 'text/markdown'
-#
-# Set other keywords for the setup function.
-#
-# Use entry_points to let `pip` create executable scripts for each target platform.
-# See https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html
-# setup_keywords['entry_points'] = {'console_scripts': ['to_snowglobes = snewpy.to_snowglobes:generate_time_series', ], },
-setup_keywords['provides'] = [setup_keywords['name']]
-setup_keywords['python_requires'] = '>=3.9'
-setup_keywords['zip_safe'] = False
-setup_keywords['packages'] = find_packages('python')
-setup_keywords['package_dir'] = {'': 'python'}
-setup_keywords['package_data'] = {'':['templates/*.glb', 'models/*.yml']}
-setup_keywords['cmdclass'] = {'version': SetVersion}
-setup_keywords['test_suite']='snewpy.test.snewpy_test_suite.snewpy_test_suite'
-
-requires = []
-with open('requirements.txt', 'r') as f:
-    for line in f:
-        if line.strip():
-            requires.append(line.strip())
-setup_keywords['install_requires'] = requires
-setup_keywords['extras_require'] = {  # Optional
-    'dev': ['pytest','hypothesis'],
-    'docs':['numpydoc']
-}
-#
-# Internal data directories.
-#
-#setup_keywords['data_files'] = [('snewpy/data/config', glob('data/config/*')),
-#                                ('snewpy/data/spectra', glob('data/spectra/*'))]
-#
-# Run setup command.
-#
-setup(**setup_keywords)