fix: get `nextjs_standalone_build` working with cross-platform builds (#2879)

I was stumped for a while about how to get Next.js working well in
Bazel. Because of its pre-rendering, `next build` inherently relies on
executing target-platform sources during the build. That left us with
two bad options. The first is what we do now, which is to just execute
target-platform sources during the build. But this blows up if the
target platform is significantly different from the exec platform, since
the build process involves some native binaries. The other option was to
try to set things up so that we operate on target-platform sources but
run only exec-platform sources. But Next.js will execute target-platform
sources as part of pre-rendering, and this results in React getting
resolved in more than one `node_modules` tree and crashing.

I found a third option that seems to work and is fairly simple, though.
We just build for the exec platform and then copy the result verbatim to
the target platform bin directory. The discussion here suggests roughly
the same idea: https://github.com/vercel/next.js/discussions/93034

I thought this might result in the wrong dependencies landing in the
`node_modules` directory in the output, but we actually delete that
directory anyway for unrelated reasons:

https://github.com/aspect-build/rules_js/blob/48e1704c6f403c943f94d5885a2c7254ced4f5e9/contrib/nextjs/next.bazel.mjs#L34

Any NPM package dependencies are expected to be provided in a subsequent
call to `nextjs_standalone_server`, and at that point they will be
treated correctly as target-platform dependencies.

Fixes #2814.

---

### Changes are visible to end-users: yes

- Searched for relevant documentation and updated as needed: yes
- Breaking change (forces users to change their own code or config):
yes, at least potentially
- Suggested release notes appear below: yes

The `nextjs_standalone_build` now works correctly when the build is
targeting a different OS or CPU.

### Test plan

- Covered by existing test cases
- New test cases added

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
diff --git a/contrib/nextjs/BUILD.bazel b/contrib/nextjs/BUILD.bazel
index aea9897..dcab9db 100644
--- a/contrib/nextjs/BUILD.bazel
+++ b/contrib/nextjs/BUILD.bazel
@@ -13,6 +13,7 @@
     srcs = ["defs.bzl"],
     deps = [
         "//js:defs",
+        "@bazel_lib//lib:copy_directory",
         "@bazel_lib//lib:copy_file",
         "@bazel_lib//lib:copy_to_directory",
         "@bazel_lib//lib:directory_path",
diff --git a/contrib/nextjs/defs.bzl b/contrib/nextjs/defs.bzl
index 8c915e5..a67c521 100644
--- a/contrib/nextjs/defs.bzl
+++ b/contrib/nextjs/defs.bzl
@@ -42,6 +42,7 @@
   [standalone directory structure guidelines](https://nextjs.org/docs/app/api-reference/config/next-config-js/output#automatically-copying-traced-files)
 """
 
+load("@bazel_lib//lib:copy_directory.bzl", "copy_directory_bin_action")
 load("@bazel_lib//lib:copy_file.bzl", "copy_file")
 load("@bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory")
 load("@bazel_lib//lib:directory_path.bzl", "directory_path")
@@ -296,6 +297,10 @@
         **kwargs: Other attributes passed to all targets such as `tags`, env
     """
 
+    tags = kwargs.pop("tags", [])
+    testonly = kwargs.pop("testonly", False)
+    visibility = kwargs.pop("visibility", [])
+
     # Extract the basename from config, which may be a label like ":next.config.js"
     # or "//pkg:next.config.js". The copy_file `out` must be a plain filename.
     config_basename = config.split(":")[-1].split("/")[-1]
@@ -305,7 +310,8 @@
         src = config,
         out = "__original.%s" % config_basename,
         visibility = ["//visibility:private"],
-        tags = ["manual"],
+        tags = tags + ["manual"],
+        testonly = testonly,
     )
 
     # Wrap the config file to add necessary bazel logic
@@ -316,12 +322,19 @@
         src = _next_standalone_config,
         out = _next_build_config,
         visibility = ["//visibility:private"],
-        tags = ["manual"],
+        tags = tags + ["manual"],
+        testonly = testonly,
     )
 
-    # `next build` of the standalone application
+    # `next build` of the standalone application.
+    # Next.js is tricky to handle in Bazel, because during its pre-rendering it
+    # executes some of the sources it is operating on. We run the risk of
+    # either trying to run code for an incompatible platform, or mixing
+    # node_modules directories from two different platforms. We work around
+    # these issues by building for the exec platform and then copying that
+    # result verbatim to the target platform bin directory.
     js_run_binary(
-        name = name,
+        name = "_%s.next_build" % name,
         tool = next_js_binary,
         env = env,
         args = ["build"],
@@ -331,9 +344,20 @@
         mnemonic = "NextJs",
         progress_message = "Compile Next.js standalone app %{label}",
         use_execroot_entry_point = use_execroot_entry_point,
+        tags = tags + ["manual"],
+        testonly = testonly,
+        visibility = ["//visibility:private"],
         **kwargs
     )
 
+    _copy_exec_to_bin(
+        name = name,
+        src = "_%s.next_build" % name,
+        tags = tags,
+        testonly = testonly,
+        visibility = visibility,
+    )
+
 def nextjs_standalone_server(name, app, pkg = None, data = [], **kwargs):
     """Configures the output of a standalone Next.js application to be a standalone server binary.
 
@@ -409,3 +433,33 @@
         visibility = ["//visibility:private"],
         tags = ["manual"],
     )
+
+def _copy_exec_to_bin_impl(ctx):
+    dst = ctx.actions.declare_directory(ctx.label.name)
+    copy_directory_bin = ctx.toolchains["@bazel_lib//lib:copy_directory_toolchain_type"].copy_directory_info.bin
+    copy_directory_bin_action(
+        ctx,
+        src = ctx.file.src,
+        dst = dst,
+        copy_directory_bin = copy_directory_bin,
+    )
+    return [
+        DefaultInfo(
+            files = depset([dst]),
+            runfiles = ctx.runfiles([dst]),
+        ),
+    ]
+
+_copy_exec_to_bin = rule(
+    implementation = _copy_exec_to_bin_impl,
+    attrs = {
+        "src": attr.label(
+            mandatory = True,
+            allow_single_file = True,
+            cfg = "exec",
+            doc = "A tree-artifact target to copy from exec-platform to target-platform bin.",
+        ),
+    },
+    toolchains = ["@bazel_lib//lib:copy_directory_toolchain_type"],
+    doc = "Copies a tree artifact built in the exec configuration into the target-platform bin directory.",
+)
diff --git a/examples/nextjs/BUILD.bazel b/examples/nextjs/BUILD.bazel
index 89a644f..24c29f9 100644
--- a/examples/nextjs/BUILD.bazel
+++ b/examples/nextjs/BUILD.bazel
@@ -1,5 +1,5 @@
 load("@aspect_rules_js//contrib/nextjs:defs.bzl", "nextjs_standalone_build", "nextjs_standalone_server")
-load("@aspect_rules_js//js:defs.bzl", "js_library")
+load("@aspect_rules_js//js:defs.bzl", "js_image_layer", "js_library")
 load("@bazel_lib//lib:write_source_files.bzl", "write_source_files")
 load("@bazel_skylib//rules:build_test.bzl", "build_test")
 load("@npm//:defs.bzl", "npm_link_all_packages")
@@ -68,6 +68,42 @@
     app = ":standalone",
 )
 
+platform(
+    name = "linux",
+    constraint_values = [
+        "@platforms//os:linux",
+        "@platforms//cpu:x86_64",
+    ],
+)
+
+platform(
+    name = "macos",
+    constraint_values = [
+        "@platforms//os:macos",
+        "@platforms//cpu:x86_64",
+    ],
+)
+
+js_image_layer(
+    name = "server_linux",
+    binary = ":server",
+    platform = ":linux",
+)
+
+js_image_layer(
+    name = "server_macos",
+    binary = ":server",
+    platform = ":macos",
+)
+
+build_test(
+    name = "cross_platform_build_test",
+    targets = [
+        ":server_linux",
+        ":server_macos",
+    ],
+)
+
 # Verify next (which has optional platform/os specific dependencies) can be built
 build_test(
     name = "next_build_test",