diff --git a/gnu/packages/javascript.scm b/gnu/packages/javascript.scm
index ee7a48154c..76a51c22ac 100644
--- a/gnu/packages/javascript.scm
+++ b/gnu/packages/javascript.scm
@@ -32,6 +32,7 @@ (define-module (gnu packages javascript)
   #:use-module (gnu packages compression)
   #:use-module (gnu packages fontutils)
   #:use-module (gnu packages java)
+  #:use-module (gnu packages man)
   #:use-module (gnu packages node)
   #:use-module (gnu packages perl)
   #:use-module (gnu packages python)
@@ -382,7 +383,7 @@ (define-public js-mathjax-for-r-mathjaxr
 (define-public font-katex
   (package
     (name "font-katex")
-    (version "0.16.4")
+    (version "0.16.9")
     (source
      (origin
        (method git-fetch)
@@ -391,7 +392,7 @@ (define-public font-katex
               (commit (string-append "v" version))))
        (sha256
         (base32
-         "0z6y2188lhfv0gk0hp4rm37g6fs99qb3ab2q3n9g76ga9dwxhw3s"))
+         "1aq8n9s4r15m1fdi4h58qxal4brkafm4xsw6rpz40wqi9454kkgn"))
        (snippet
         ;; unbundle generated files
         #~(begin
@@ -446,6 +447,256 @@ (define-public font-katex
     (description "This package contains the fonts required for KaTeX.")
     (license license:expat)))
 
+(define-public katex
+  (package
+    (inherit font-katex)
+    (name "katex")
+    (outputs '("out" "dist"))
+    (build-system node-build-system)
+    (native-inputs
+     (list esbuild
+           flow-remove-types
+           help2man
+           lessc))
+    (inputs
+     (list font-katex
+           js-commander))
+    (arguments
+     (list
+      #:tests? #f ; many more dependencies
+      #:modules
+      `((guix build node-build-system)
+        (ice-9 match)
+        (srfi srfi-1)
+        (srfi srfi-26)
+        (guix build utils))
+      #:phases
+      #~(modify-phases %standard-phases
+          (add-before 'patch-dependencies 'move-sources
+            (lambda* (#:key inputs #:allow-other-keys)
+              ;; Our node-build-system doesn't properly respect the "files"
+              ;; entry in "package.json" to determine which files to install.
+              ;; This case is particularly egregious because the source
+              ;; repository also contains the source for the whole katex.org
+              ;; website.  For now, manually do what "files" ought to do.
+              (mkdir "../guix-source")
+              (copy-recursively "src" "../guix-source/src")
+              (copy-recursively "contrib" "../guix-source/contrib")
+              (for-each (cut install-file <> "../guix-source")
+                        '("README.md"
+                          "LICENSE"
+                          "package.json"
+                          "katex.js"
+                          "cli.js"))
+              (install-file
+               (search-input-file inputs "share/katex/fontMetricsData.js")
+               "../guix-source/src")
+              (chdir "../guix-source")))
+          (add-after 'move-sources 'patch-package-json
+            (lambda args
+              (with-atomic-json-file-replacement "package.json"
+                (match-lambda
+                  (('@ . alist)
+                   (cons '@
+                    (filter-map
+                     (match-lambda
+                       (((or "devDependencies" "scripts") . _)
+                        #f)
+                       ;; ESBuild can't generate Universal Module Definitions,
+                       ;; so keep our CJS separate from our browser builds:
+                       (("files" . lst)
+                        `("files" "guix-node-cjs/" ,@lst))
+                       (("main" . "dist/katex.js")
+                        `("main" . "guix-node-cjs/katex.js"))
+                       (("exports" '@ . alist)
+                        `("exports" @
+                          ,@(map (match-lambda
+                                   (("./*" . "./*")
+                                    `("./*" . "./*"))
+                                   ((lhs '@ . alist)
+                                    `(,lhs @
+                                      ,@(map (match-lambda
+                                               (("require" . ,str)
+                                                (cons
+                                                 "require"
+                                                 (string-append
+                                                  "./guix-node-cjs/"
+                                                  (substring str
+                                                             (string-length
+                                                              "./dist")))))
+                                               (other
+                                                other))
+                                             alist))))
+                                 alist)))
+                       (other
+                        other))
+                       alist)))))))
+          (add-after 'patch-dependencies 'patch-sources
+            (lambda* (#:key inputs #:allow-other-keys)
+              (substitute* "src/SourceLocation.js"
+                ;; for some reason, the + prefix isn't handled
+                ;; by flow-remove-types
+                (("[+](lexer|start|end)" _ name)
+                 name))
+              (substitute* "src/fonts.less"
+                ;; what webpack would do
+                (("@font-folder: \"\\.\\./fonts\";" orig)
+                 (string-append "@font-folder: \"fonts\"; // " orig)))
+              (define version
+                #$(package-version this-package))
+              (substitute* "src/katex.less"
+                (("@version: \"\";" orig)
+                 (string-append "@version: \"" version "\"; // " orig)))
+              (substitute* "katex.js"
+                (("version: __VERSION__," orig)
+                 (string-append "version: \"" version "\", // " orig)))))
+          (add-after 'patch-sources 'erase-types
+            (lambda args
+              (invoke "flow-remove-types"
+                      "--sourcemaps"
+                      "--out-dir" "../erased/src/"
+                      "src/")
+              (invoke  "flow-remove-types"
+                       "--sourcemaps"
+                       "--out-dir" "../erased/"
+                       "katex.js")
+              (invoke "flow-remove-types"
+                      "--sourcemaps"
+                      "--out-dir" "../erased/contrib/"
+                      "contrib/")))
+          (add-after 'erase-types 'build-js
+            (lambda args
+              (with-directory-excursion "../erased"
+                ;; ^ avoid "../erased" in generated code
+                (define (esbuild . args)
+                  (apply invoke `("esbuild"
+                                  "--bundle"
+                                  "--log-limit=0"
+                                  "--platform=neutral"
+                                  ,@args)))
+                (esbuild "--outfile=../guix-source/dist/katex.mjs"
+                         "--format=esm"
+                         "katex.js")
+                ;; Workaround  for different handling of ES6 default export
+                ;; when generating CJS:
+                (esbuild "--outfile=katex-cjs.js"
+                         "--format=cjs"
+                         "katex.js")
+                (with-output-to-file "katex-wrapper.js"
+                  (lambda ()
+                    (display
+                     "module.exports = require('./katex-cjs.js').default;\n")))
+                (esbuild "--outfile=../guix-source/guix-node-cjs/katex.js"
+                         "--format=cjs"
+                         "katex-wrapper.js")
+                (esbuild "--outfile=../guix-source/dist/katex.js"
+                         "--format=iife"
+                         "--global-name=katex"
+                         "katex-wrapper.js")
+                (esbuild "--outfile=../guix-source/dist/katex.min.js"
+                         "--minify"
+                         "--format=iife"
+                         "--global-name=katex"
+                         "katex-wrapper.js")
+                ;; Build extensions:
+                (for-each
+                 (match-lambda
+                   ((name export)
+                    ;; The copy-tex extension doesn't actually import katex,
+                    ;; but it's harmless to handle it the same way.
+                    (with-directory-excursion (string-append "contrib/" name)
+                      (esbuild (string-append "--outfile=../../../guix-source"
+                                              "/guix-node-cjs/contrib/"
+                                              name ".js")
+                               "--format=cjs"
+                               "--external:katex"
+                               (string-append name ".js"))
+                      (substitute* (string-append name ".js")
+                        (("import katex from \"katex\";")
+                         "import katex from \"../katex.mjs\";"))
+                      (esbuild (string-append "--outfile=" name ".mjs")
+                               "--format=esm"
+                               "--external:../katex.mjs"
+                               (string-append name ".js"))
+                      (install-file (string-append name ".mjs")
+                                    "../../../guix-source/dist/contrib")
+                      (substitute* (string-append name ".js")
+                        (("import katex")
+                         "// import katex"))
+                      (for-each
+                       (lambda (minify?)
+                         (apply
+                          esbuild
+                          `(,(string-append "--outfile=../../.."
+                                            "/guix-source/dist/contrib/"
+                                            name
+                                            (if minify? ".min" "")
+                                            ".js")
+                            "--format=iife"
+                            ,@(if minify?
+                                  '("--minify")
+                                  '())
+                            ,@(if export
+                                  `("--global-name=guixTmp"
+                                    ,(string-append "--banner:js=const "
+                                                    export
+                                                    " = (() => {")
+                                    "--footer:js=return guixTmp.default;\n})();")
+                                  '())
+                            ,(string-append name ".js"))))
+                       '(#t #f)))))
+                 '(("auto-render" "renderMathInElement")
+                   ("copy-tex" #f)
+                   ("mathtex-script-type" #f)
+                   ("mhchem" #f)
+                   ("render-a11y-string" "renderA11yString"))))))
+          (add-after 'build-js 'build-css
+            (lambda args
+              (invoke "lessc" "src/katex.less" "dist/katex.css")))
+          (add-after 'install 'generate-man-page
+            (lambda args
+              (invoke "help2man"
+                      "-N"
+                      "-n" "render TeX math to HTML and MathML"
+                      "--output=katex.1"
+                      (string-append #$output "/bin/katex"))
+              (install-file "katex.1"
+                            (string-append #$output "/share/man/man1"))))
+          (add-after 'generate-man-page 'install-dist
+            (lambda* (#:key inputs #:allow-other-keys)
+              ;; The CSS, fonts, etc. needed for KaTeX, plus the optional
+              ;; bundled version of the JavaScript for dynamic use in the
+              ;; browser, are in the 'dist' directory of the Node module.
+              ;; Putting them in a separate output lets them be used without
+              ;; retaining a reference to Node and the cli utility.
+              ;; In Debian, 'dist' is a symlink to /usr/share/javascript/katex:
+              ;; do likewise to help tools that may need to find it.
+              (define up-dist-dir
+                (string-append #$output:dist "/share/javascript"))
+              (define dist-dir
+                (string-append up-dist-dir "/katex"))
+              (mkdir-p up-dist-dir)
+              (with-directory-excursion
+                  (string-append #$output "/lib/node_modules/katex")
+                (rename-file "dist" dist-dir)
+                (symlink dist-dir "dist"))
+              (with-directory-excursion dist-dir
+                ;; Link the fonts to where the CSS expects them:
+                (symlink (search-input-directory inputs
+                                                 "share/fonts/truetype/katex")
+                         "fonts")
+                ;; We can't actually minify the CSS, but fake it for anything
+                ;; that may expect it. With Brotli compression, the difference
+                ;; is only about 300 bytes anyway.
+                (symlink "katex.css" "katex.min.css")))))))
+    (synopsis "Fast math typesetting for the web")
+    (description "KaTeX renders TeX math notation to HTML and/or MathML.  The
+rendered output does not depend on JavaScript, so rendering can be done
+entirely ahead-of-time using the @command{katex} command-line tool.  When
+desired, KaTeX can also be used as a JavaScript library in the browser to
+render math dynamically, and it is designed to be fast, even on pages with
+hundreds of mathematical expressions.")))
+
 (define-public js-commander
   (package
     (name "js-commander")
