Summary
With the webpack builder (Next 16, output: 'standalone', pnpm monorepo, app at apps/web), App Hosting deploys serve every public/ asset as a Next 404 in production — images, fonts, manifest.json, all of it — while /_next/static/* serves fine. The same app built with turbopack (same adapter version) serves public/ correctly.
We shipped this to production unknowingly during a builder cutover (turbopack → webpack) and every public asset on the site 404'd until we diagnosed it.
Mechanism (adapter-nextjs 14.0.21, src/utils.ts)
The adapter has no bundler-independent public/ copy:
.next/static is copied explicitly and unconditionally (utils.ts ~148-150: copy(staticDirectory, opts.outputStaticDirectoryPath, ...)) — which is why /_next/static always works.
public/ is only handled by the generic copyResources() (utils.ts ~163-181), copying top-level app-dir entries into standaloneAppPath gated by existsInOutputBundle — i.e. it relies on Next's standalone output already containing (or correctly receiving) public/.
- Turbopack standalone output includes
public/; webpack standalone documentedly does not ("This minimal server does not copy the public or .next/static folders by default" — Next docs). In a monorepo layout (.next/standalone/apps/web/..., driven by outputFileTracingRoot), the fallback copy does not land where the standalone server.js resolves public/ from, so the runtime ends up with an empty/near-empty public/.
The diagnostic tell: the only public file that served in our production deploys was the single file NFT-tracing happened to pull into standalone (a route reads it at runtime) — proving the standalone serving path works and the directory simply wasn't populated.
Repro context
@apphosting/adapter-nextjs 14.0.21 (current latest; buildpack default via npm view), Next.js 16.1.6, next build --webpack, output: 'standalone', pnpm workspace + turbo.json at repo root (monorepo mode, GOOGLE_BUILDABLE=apps/web).
- Result:
.next/standalone/apps/web/public/ contains only NFT-traced files; all other public assets 404 from the deployed Cloud Run service.
Workaround we ship
postbuild npm hook that performs the documented Next standalone copy:
copy public/ → .next/standalone/<monorepo-path>/public and .next/static → .next/standalone/<monorepo-path>/.next/static. With public/ pre-placed, the adapter packages it and assets serve.
Suggested fix
Copy public/ explicitly and unconditionally into standaloneAppPath/public (mirroring the existing explicit .next/static copy), independent of bundler. The e2e suite currently tests neither public-asset serving nor a monorepo layout — both would have caught this.
Summary
With the webpack builder (Next 16,
output: 'standalone', pnpm monorepo, app atapps/web), App Hosting deploys serve everypublic/asset as a Next 404 in production — images, fonts,manifest.json, all of it — while/_next/static/*serves fine. The same app built with turbopack (same adapter version) servespublic/correctly.We shipped this to production unknowingly during a builder cutover (turbopack → webpack) and every public asset on the site 404'd until we diagnosed it.
Mechanism (adapter-nextjs 14.0.21,
src/utils.ts)The adapter has no bundler-independent
public/copy:.next/staticis copied explicitly and unconditionally (utils.ts ~148-150:copy(staticDirectory, opts.outputStaticDirectoryPath, ...)) — which is why/_next/staticalways works.public/is only handled by the genericcopyResources()(utils.ts ~163-181), copying top-level app-dir entries intostandaloneAppPathgated byexistsInOutputBundle— i.e. it relies on Next's standalone output already containing (or correctly receiving)public/.public/; webpack standalone documentedly does not ("This minimal server does not copy thepublicor.next/staticfolders by default" — Next docs). In a monorepo layout (.next/standalone/apps/web/..., driven byoutputFileTracingRoot), the fallback copy does not land where the standaloneserver.jsresolvespublic/from, so the runtime ends up with an empty/near-emptypublic/.The diagnostic tell: the only public file that served in our production deploys was the single file NFT-tracing happened to pull into standalone (a route reads it at runtime) — proving the standalone serving path works and the directory simply wasn't populated.
Repro context
@apphosting/adapter-nextjs14.0.21 (currentlatest; buildpack default vianpm view), Next.js 16.1.6,next build --webpack,output: 'standalone', pnpm workspace +turbo.jsonat repo root (monorepo mode,GOOGLE_BUILDABLE=apps/web)..next/standalone/apps/web/public/contains only NFT-traced files; all other public assets 404 from the deployed Cloud Run service.Workaround we ship
postbuildnpm hook that performs the documented Next standalone copy:copy
public/→.next/standalone/<monorepo-path>/publicand.next/static→.next/standalone/<monorepo-path>/.next/static. Withpublic/pre-placed, the adapter packages it and assets serve.Suggested fix
Copy
public/explicitly and unconditionally intostandaloneAppPath/public(mirroring the existing explicit.next/staticcopy), independent of bundler. The e2e suite currently tests neither public-asset serving nor a monorepo layout — both would have caught this.