Bazel rules for Next.js. Runs next build as a
hermetic Bazel action with the workspace's deps as explicit inputs and
the .next/ tree as the declared output.
- rule:
next_build— see docs/defs.md. - rule/macro:
next_standalone— turn anoutput = "standalone"build into abazel run-able server and a deployable bundle forpkg_tar/oci_image. - provider:
NextBuildInfo— wraps the.nextoutput tree so future rules (deploy targets,oci_imagewrappers, doc-site extractors) can consume builds programmatically.
Add the registry to your .bazelrc:
common --registry=https://raw.githubusercontent.com/fastverk/bazel-registry/main/
common --registry=https://bcr.bazel.build/
In your MODULE.bazel:
bazel_dep(name = "rules_nextjs", version = "0.1.0")You'll also need aspect_rules_js (or equivalent) to expose next as a js_binary-compatible target — this rule consumes the CLI via next_bin, doesn't bring its own.
load("@npm//:my-app/next/package_json.bzl", next_bin_gen = "bin")
load("@rules_nextjs//next:defs.bzl", "next_build")
# Real js_binary wrapping node_modules/next/dist/bin/next. The rule needs
# an executable target — `:node_modules/next/dir` is a directory and
# cannot be exec'd directly. aspect_rules_js generates `bin.next_binary`
# for any npm package that declares a bin in its package.json.
next_bin_gen.next_binary(name = "next_cli")
next_build(
name = "build",
srcs = glob(["src/**/*", "public/**/*"]) + [
"next.config.ts",
"tsconfig.json",
],
deps = [
"//packages/some-lib:lib",
":node_modules/next",
":node_modules/react",
":node_modules/react-dom",
],
data = [
# Runtime assets dropped into public/ before the build.
"//db/migrations:bundle",
],
next_bin = ":next_cli",
)bazel build //:build produces bazel-bin/build.out/ containing the full
.next/ tree (standalone/, static/, trace files).
next build's output: 'standalone' emits the self-contained server
(.next/standalone) and the hashed client assets (.next/static) as
siblings — neither runs on its own. next_standalone re-stitches them
into one tree (matching the hand-written Dockerfile COPY layout) and
exposes it two ways:
load("@rules_nextjs//next:defs.bzl", "next_build", "next_standalone")
next_build(
name = "build",
# ... as above ...
output = "standalone", # the default; static/vercel get no runnable
next_bin = ":next_cli",
)
next_standalone(
name = "app",
build = ":build",
next_bin = ":next_cli", # borrowed for the hermetic Node
)-
bazel run //:app— serve the app on the hermetic Node (honorsPORT/HOSTNAME). -
//:app.bundle— aTreeArtifactready for an image:load("@rules_pkg//pkg:tar.bzl", "pkg_tar") load("@rules_oci//oci:defs.bzl", "oci_image") pkg_tar(name = "app_layer", srcs = ["//:app.bundle"], package_dir = "/app") oci_image( name = "image", base = "@distroless_nodejs", tars = [":app_layer"], workdir = "/app", # The bundle drops a fixed-name entry shim at its root (it discovers the # nested server.js for you), so the cmd never changes per app: cmd = ["__next_standalone_server.cjs"], )
The standalone server resolves /_next/static/* relative to the cwd, so
the runnable and the entry shim both cd to the bundle root, where
.next/static is re-stitched.
next_buildrepairs the standalonenode_modulesso dynamic runtime requires (e.g.next's require-hook →styled-jsx) resolve — without it the deref'd standalone crashes on boot. The trade-off is a heaviernode_modulesthan a pnpm-built standalone; see the CHANGELOG0.3.0note.
The rule forces three Next.js env vars:
NEXT_TELEMETRY_DISABLED=1NEXT_PRIVATE_STANDALONE=1NODE_ENV=production
The rest of the hermeticity scrub lives in each app's next.config.ts — rules_nextjs deliberately doesn't try to patch from the outside. Consumer-side checklist:
| Bring under control | How |
|---|---|
| Font CDN fetches | Vendor under public/fonts/ or use next/font/local; next/font/google reaches fonts.googleapis.com at build time |
| Image optimizer pre-fetches | images: { unoptimized: true } or explicit remotePatterns |
| Build-time network from instrumentation | Audit instrumentation*.ts for module-init side effects |
| Next version | Pin via root package.json catalog |
Validate the scrub by building with --network none after the migration lands.
- Bazel: 7.4+, bzlmod required.
- Next.js: 14+ tested. Earlier versions may work —
next build <app-dir>and the env-var contract have been stable. - Workspace shape: assumes
aspect_rules_js-style npm linking (:node_modules/next/dir).
Reference docs (docs/defs.md) are stardoc-generated. After editing rule docstrings:
bazel run //docs:updateCI gates this via bazel test //docs/....
MIT.