rules_nextjs
Bazel rules for Next.js. Hermetic 'next build' with .next/ as a declared output directory.
| Latest | 0.3.0 |
|---|---|
| Versions | 4 |
| Category | Bazel rules |
| Compat level | 1 |
| Maintainers | Matt Marshall |
| Registry | https://registry.tbzl.dev/modules/rules_nextjs/ |
| Source | github.com/tomato-bazel/rules_nextjs |
bazel_dep(name = "rules_nextjs", version = "0.3.0")
View source & releases on GitHub ↗
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.
Install
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.
Quick start
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).
Standalone: runnable server + deployable bundle
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.
Hermeticity
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.
Compatibility
- 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).
Contributing
Reference docs (docs/defs.md) are stardoc-generated. After editing rule docstrings:
bazel run //docs:update
CI gates this via bazel test //docs/....
License
MIT.
Usage#
Real usage, taken from the module’s examples/.
examples/minimal-app/BUILD.bazel
load("@npm//:defs.bzl", "npm_link_all_packages")
load("@npm//:next/package_json.bzl", next_bin = "bin")
load("@rules_nextjs//next:defs.bzl", "next_build", "next_standalone")
package(default_visibility = ["//visibility:public"])
npm_link_all_packages(name = "node_modules")
next_bin.next_binary(name = "next_cli")
next_build(
name = "build",
srcs = glob([
"src/**/*",
]) + [
"next.config.mjs",
"package.json",
"tsconfig.json",
],
deps = [
":node_modules/@types/node",
":node_modules/@types/react",
":node_modules/next",
":node_modules/react",
":node_modules/react-dom",
":node_modules/typescript",
],
next_bin = ":next_cli",
output = "standalone",
)
# `bazel build //:standalone.bundle` → deployable run tree (pkg_tar/oci input).
# `bazel run //:standalone` → serve it locally on $PORT (default 3000).
next_standalone(
name = "standalone",
build = ":build",
next_bin = ":next_cli",
)Rules & providers#
Generated with Stardoc from the module's .bzl sources.
from docs/defs.md
User-facing rules for rules_nextjs.
Currently exports next_build, which runs next build as a Bazel
action with the workspace’s deps as inputs and .next/ as the
declared output. Forces hermeticity-relevant Next.js env vars
(NEXT_TELEMETRY_DISABLED=1, NEXT_PRIVATE_STANDALONE=1,
NODE_ENV=production) so the build itself doesn’t drift.
The font/image-optimizer/instrumentation hermeticity scrub lives in
each consuming app’s next.config.ts — the rule doesn’t try to
patch it from the outside. See README.md for the consumer-side
checklist.
Targets returning NextBuildInfo expose the .next tree
programmatically so future rules (deploy targets, doc-site
extractors, oci_image wrappers) can consume builds without
re-running next build.
next_build
load("@rules_nextjs//next:defs.bzl", "next_build")
next_build(name, deps, srcs, data, app_dir, next_bin)
Run next build hermetically and emit the .next tree as a Bazel-output directory.
ATTRIBUTES
| Name | Description | Type | Mandatory | Default |
|---|---|---|---|---|
| name | A unique name for this target. | Name | required | |
| deps | ts_project / npm link targets the app imports. Brought into runfiles for the build action. | List of labels | optional | [] |
| srcs | Application source files + public/ assets + next.config.ts + instrumentation.*. | List of labels | required | |
| data | Additional inputs that should land in the working tree before next build runs (e.g. migrations.zip -> public/). | List of labels | optional | [] |
| app_dir | Package-relative app root. Defaults to the package containing the rule. | String | optional | "" |
| next_bin | js_binary-compatible target for the Next CLI (typically :node_modules/next/dir). | Label | required |
NextBuildInfo
load("@rules_nextjs//next:defs.bzl", "NextBuildInfo")
NextBuildInfo(tree)
A next build output tree.
FIELDS
| Name | Description |
|---|---|
| tree | Directory: the .next output (standalone + static). |
Conformance#
No gate findings. 15 contested atoms. See how gating works or the full report.
Contested atoms
Third-party modules where this module resolves a different version than others do. Not a violation of anything this module did — it is the actionable form of a registry-level convergence finding, and the sentence a maintainer can act on.
| Atom | Resolved here | Elsewhere |
|---|---|---|
apple_support | 1.24.2 | 2.2.0 ×1 |
aspect_bazel_lib | 2.22.5 | 2.8.1 ×1 |
bazel_lib | 3.0.0 | 3.2.2 ×17 |
bazel_skylib | 1.8.2 | 1.9.0 ×2 |
gawk | 5.3.2.bcr.1 | 5.3.2.bcr.3 ×17 |
jq.bzl | 0.1.0 | 0.4.0 ×17 |
nlohmann_json | 3.6.1 | 3.12.0.bcr.1 ×1 |
package_metadata | 0.0.2 | 0.0.5 ×3 |
protobuf | 33.4 | 34.0.bcr.1 ×2 |
rules_jvm_external | 6.7 | 6.8 ×4 |
rules_python | 1.7.0 | 2.0.1 ×1 |
rules_swift | 3.1.2 | 3.6.1 ×1 |
tar.bzl | 0.5.1 | 0.10.4 ×170.6.0 ×1 |
upb | 0.0.0-20220923-a547704 | 0.0.0-20230516-61a97ef ×1 |
yq.bzl | 0.1.1 | 0.3.4 ×17 |
Dependencies#
Depends on
Versions#
4 published versions, newest first. Each resolves to an immutable, integrity-checked archive.
| Version | Integrity (sha256) | Source archive |
|---|---|---|
0.3.0 latest | rd7BcyuPLWrq3o3d… | tag archive ↗ |
0.2.0 | j7f0a/KAICSfSSBb… | tag archive ↗ |
0.1.1 | uTiQDrfTq/axk35m… | tag archive ↗ |
0.1.0 | PQU+kTMPVCBk6/lD… | tag archive ↗ |
Changelog#
All notable changes to rules_nextjs. The format is loosely Keep a Changelog — version headers mirror the published bazel-registry entries.
0.3.0 — next_standalone (runnable + deployable bundle)
next_build: add anoutputattribute (standalone|static|vercel|default, defaultstandalone). OnlystandaloneforcesNEXT_PRIVATE_STANDALONE=1; the mode is surfaced onNextBuildInfo(along withapp_dir) so downstream rules can gate on it.- New
next_standalonemacro: from anoutput = "standalone"build it emits<name>.bundle— the standalone server re-stitched with.next/staticinto one deployableTreeArtifact(feed topkg_tar/oci_image) — and<name>, abazel run-able launcher that serves the bundle on the hermetic Node. Static / vercel builds fail fast (no self-contained server). NewNextStandaloneInfoprovider + a fixed-name entry shim (NEXT_STANDALONE_ENTRY) so anoci_imagecmdcan target the server regardless of where Next nestedserver.js. next_build: repair the standalonenode_modulesfor runtime resolution.next build’scp -RLpromote step deref’s the standalone into real directories, which disconnects each package from its sibling deps under.aspect_rules_js/<key>/node_modules/— so e.g.next’s require-hook can’t findstyled-jsxand the server crashes on boot. The build now adds a flat top-levelnode_modules/<pkg>entry (relative, internal symlink) for every content-store package, so Node’s realpath walk always reaches the single top-levelnode_modulesand resolves every transitive dep.next_standalone’s bundle preserves these symlinks (cp -R) so the image isn’t doubled. Verified:bazel run //:standaloneon the example app serves HTTP 200.- Trade-off: against the aspect_rules_js content store,
next build’s trace + the deref produce a large standalonenode_modules. Correct, but heavier than a pnpm-built standalone — narrowing the trace is a separate optimization.
0.2.0 — next_dev + bundler selection
- Add a
next_devrule: abazel run-launched Next.js dev server in the workspace tree (companion to the hermeticnext_build). next_build: add abundlerattribute (webpack|turbopack) to select the Next.js bundler.next_build: two-action design with tsconfig / next.config rewrites; the next.config wrapper handles peer-dep visibility, workspacetranspilePackages, and the.tsextensionAlias.- New dependency:
aspect_bazel_lib(2.22.5).
0.1.1 — hermeticity fixes for next_build
- Fix
BAZEL_BINDIRpropagation, pass workspace deps as explicit action inputs, and switch toFilesToRunProvidersonext buildruns cleanly inside the Bazel sandbox.
0.1.0 — initial release
- First cut of Bazel rules for Next.js: a
next_buildrule that runsnext buildas a hermetic Bazel action with workspace deps as inputs and.next/as the declared output, plus aNextBuildInfoprovider so downstream rules (deploy targets,oci_imagewrappers, doc extractors) can consume builds.