* fix(pages): send charset=utf-8 on HTML Content-Type
Next.js serves every HTML response (SSR and prerendered) with
`Content-Type: text/html; charset=utf-8`, but vinext's Pages Router
paths sent a bare `text/html`. Without the header charset — and without
an early <meta charset> in the page — Chromium falls back to
windows-1252, so non-ASCII content renders as mojibake (e.g. nbsp as
'Â ') and the resulting DOM diverges from the hydrated tree, triggering
the full-screen hydration error overlay in dev. Reproduced against
nextjs-notion-starter-kit; baseline `next dev`/`next start` both send
the charset.
Append `; charset=utf-8` on every Pages Router HTML producer (the App
Router paths already send it):
- pages-page-response.ts — page render response headers + gSSP header
merge
- dev-server.ts — streaming SSR, ISR HIT/STALE, and static-HTML dev
responses
- pages-page-data.ts — ISR cache HIT/STALE responses in prod
- pages-request-pipeline.ts — the `defaultContentType` a buffering
adapter applies when a render response carries no Content-Type
- static-file-cache.ts — `.html` static files (prerendered pages),
matching Next.js static serving
Compression negotiation is unaffected: COMPRESSIBLE_TYPES matching
splits the media type on ";" before lookup.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(client): polyfill `global` in browser bundles
Next.js exposes the Node-style `global` alias to client code: webpack
via its `node.global` runtime shim, Turbopack by compile-time rewriting
the free `global` identifier to its globalThis shortcut and folding
`typeof global` to "object" (turbopack-ecmascript references). vinext
provided nothing, so any client dependency that reads `global` (e.g.
use-dark-mode via nextjs-notion-starter-kit) threw
`ReferenceError: global is not defined` after hydration.
Add a `vinext:client-global-define` plugin that scopes
`define: { global: "globalThis" }` to the client environment:
- builds statically rewrite free `global` references (Turbopack-style)
- dev injects `"global": globalThis` into the client runtime defines
(/@vite/env), assigning `globalThis.global` before user code runs
(webpack-style)
- the same define is layered into the client dep optimizer
(rolldownOptions.transform.define / esbuildOptions.define) because
pre-bundled deps bypass the plugin transform pipeline
`typeof global` evaluates to "object" in the browser either way,
matching Next.js. Server environments are untouched — `global` remains
the real Node global — and a user-configured `compiler.define.global`
takes precedence, mirroring Turbopack's or_insert free-var semantics.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(images): accept /_next/image/ with trailingSlash: true
With `trailingSlash: true`, the App Router dev handler 308-redirects
`/_next/image?url=...` to `/_next/image/?url=...` (the trailing-slash
normalizer runs before the image-endpoint check), but the image endpoint
only matched the exact `/_next/image` pathname — so the redirected
request 404'd and every dev-mode next/image request broke. Reproduced
on tailwind-nextjs-starter-blog, which ships trailingSlash: true.
Baseline Next 16.2.10 behaves the same way up to the redirect (dev with
trailingSlash: true also 308s `/_next/image` to `/_next/image/`) but
then SERVES the slashed form: its route matching strips a trailing
slash before matching internal paths (getItem in
packages/next/src/server/lib/router-utils/filesystem.ts), so image
requests never fail.
Match that: isImageOptimizationPath() now strips a single trailing
slash before comparing, which covers every caller — App Router
dev/prod (app-rsc-handler), Pages Router dev middleware, the Node prod
server, and the Cloudflare worker entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test: align existing assertions with charset and trailing-slash fixes
Three assertions added on main after these fixes were authored still
asserted the old behavior: bare text/html Content-Type in
static-file-cache and the pages pipeline defaultContentType, and
isImageOptimizationPath rejecting the trailing-slash form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(config): match overlapping tsconfig paths by longest prefix
TypeScript (and Next.js) match compilerOptions.paths patterns by longest
matched prefix regardless of declaration order, but vinext materialized
them into Vite resolve.alias entries in declaration order, where the
alias plugin picks the first match. With overlapping patterns like
`"@/*": ["./src/*"]` + `"@/public/*": ["./public/*"]` (the
ixartz/Next-js-Boilerplate shape), `@/public/...` imports resolved into
src/public/ and every page 500'd.
Sort materialized aliases longest-prefix-first in both the plugin's
resolve.alias materialization and the next.config.ts loader's
runnerImport aliases.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(config): keep tsconfig path aliases out of stylesheet resolution
TypeScript compilerOptions.paths never apply to CSS in Next.js —
@import specifiers in stylesheets use standard bundler resolution,
including package.json exports maps. vinext's materialized
resolve.alias entries also ran inside Vite's internal CSS resolver
(which only consults the alias plugin plus Vite's own resolver), so a
monorepo alias like `"@scope/ui/*": ["../../packages/ui/src/*"]`
rewrote `@import "@scope/ui/globals.css"` away from its
exports-mapped target and every route failed with a postcss ENOENT in
dev, and builds failed while analyzing client references
(create-better-t-stack scaffolds).
Emit the merged alias map as alias entries and attach a customResolver
to tsconfig-derived ones that bails out for stylesheet importers
(stylesheet file paths and the synthetic <basedir>/* importer that
postcss-import/less use), letting Vite resolve the original specifier
normally. JS/TS importers keep full alias behavior — including
`import "@/styles/globals.css"` from a layout and alias-based
import.meta.glob / dynamic-import patterns, which require blind prefix
replacement. Vite 8 reports alias customResolver as deprecated during
config resolution; the replacement it suggests (a resolveId plugin)
never runs inside the CSS resolver container, so the warning is
filtered while this remains the only importer-aware hook available.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Vite 8's OXC transform (and Vite 7's esbuild transform) honour
`"verbatimModuleSyntax": true` from the app's tsconfig, so
`import { type Metadata } from "next"` leaves a side-effect
`import "next"` behind when every specifier is type-only. Next.js
(SWC) — and esbuild/tsc without verbatimModuleSyntax — elide the whole
statement. create-t3-app and other scaffolds emit both the tsconfig
option and the inline-type import form stock, so on vinext the real
Next.js server runtime was pulled into the RSC graph (dev 500
"require is not defined", build failures via styled-jsx/client-only)
and server-only modules were pulled into the browser bundle when a
"use client" file imported only types from them (t3's tRPC router
shipped to clients and crashed hydration).
Force `typescript.onlyRemoveTypeImports: false` in the oxc transform
options (and `verbatimModuleSyntax: false` in the Vite 7 esbuild
tsconfigRaw) so type-import elision matches Next.js regardless of the
app's tsconfig.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
* fix(dev): treat .js as JSX in the optimizeDeps scanner
The dep optimizer (scanner + pre-bundler) runs its own Rolldown/esbuild
pipeline that does not go through the `vinext:jsx-in-js` transform plugin,
so JSX in plain `.js`/`.mjs` source files made the dependency scan fail with
"Unexpected JSX expression" and aborted pre-bundling.
Configure the dep optimizer to treat `.js`/`.mjs` as JSX
(`optimizeDeps.rolldownOptions.moduleTypes` on Vite 8,
`optimizeDeps.esbuildOptions.loader` on Vite 7), mirroring how the main
transform treats `.js`/`.mjs` (its `/\.m?js$/` filter). Applied to the
top-level optimizeDeps and the per-environment (rsc/ssr/client) blocks via
getDepOptimizeNodeEnvOptions.
The motivating real-world symptom is that, once the scan aborts,
pre-bundling is skipped and UMD/CJS deps can fail to interop under SSR
("window is not defined"). That downstream cascade runs through a different
optimizer path and is not what this change is verified to fix — the added
tests assert only that the dependency scan no longer aborts on
JSX-in-`.js`/`.mjs`.
* test(dev): cover pages jsx optimizer scan
* test(dev): strengthen jsx optimizer scan coverage
* fix(dev): apply jsx optimizer config to pages build clients
---------
Co-authored-by: James <james@eli.cx>
* feat(pages): enforce reactStrictMode by wrapping client root in <StrictMode>
`reactStrictMode: true` was recognized but not enforced — the app root was
never wrapped in <React.StrictMode>, so dev-time strict checks (double-invoked
effects/render, deprecation warnings) were silently lost.
Resolve `reactStrictMode` from next.config (preserved as `boolean | null` so
each router applies its own default) and, for the Pages Router, wrap the
client tree in <React.StrictMode> when the value is `true`. The default
matches Next.js: `null`/unset is OFF for the Pages Router
(`reactStrictMode === null ? false` in define-env.ts).
The wrap lives in `wrapWithRouterContext` (next/router) — the single seam every
render path funnels through: the initial hydration entry (production AND the
dev server's inline hydration script) and every client-side navigation
`root.render()` in shims/router.ts. This mirrors Next.js, whose `doRender`
closure wraps in <React.StrictMode> for both the initial hydrate and subsequent
`reactRoot.render()` calls (client/index.tsx). Wrapping only the production
client entry would have been inert — StrictMode does nothing in production, and
the dev server hydrates via a separate template — so the flag is also threaded
into createSSRHandler and the dev hydration script. The wrap is gated on a
client-only `window.__VINEXT_REACT_STRICT_MODE__` flag so the server-rendered
tree is never wrapped (Next.js wraps client-side only); StrictMode renders no
DOM, so SSR markup and hydration are unaffected. The CommitBoundary stays
outside StrictMode so its commit effect is not double-invoked (Next.js keeps
`<Root>` outside <StrictMode> too).
`vinext check` reports reactStrictMode as "partial": enforced for the Pages
Router, but the App Router is not yet wrapped (its root is mounted by the RSC
client runtime, not vinext-owned code, and Next.js defaults App Router strict
mode on).
* test(pages): cover strict mode navigation renders
---------
Co-authored-by: James <james@eli.cx>
* perf(build): parallelize prerender across a pool of render processes
Build-time prerender rendered every static route by fetching it from a
single in-process production server driven by a promise pool. React
SSR/RSC rendering is CPU-bound JS, so the pool only overlaps I/O — every
render serialized on one core, and raising --prerender-concurrency did
nothing. Next.js forks a worker pool and saturates every core.
Fork a pool of production-server child processes (one per core, capped)
and round-robin the per-route render fetch across them, keeping route
scanning, getStaticPaths/static-params resolution, file writing and the
manifest on the main process. Pool size scales by cores AND routes, so
small apps, low-memory machines, and --prerender-concurrency 1 keep the
single in-process server (no fork, no regression); running from source
(no built .js worker entry) also falls back to single-process.
child_process, not worker_threads: worker threads contend for CPU on this
workload (measured ~2x slower per route and non-scaling), which is also
why Next.js uses processes.
react.dev (809 routes, cold cache, same machine): 29.9s -> 15.1s, now
faster than its own Next.js build (~19.3s). An 801-route static fixture:
~22s -> ~2-6s. Prerender output is byte-identical to the single-process
path on deterministic renders; workers install the same NoOp cache
handler the in-process path uses. A worker that exits unexpectedly fails
the build loudly instead of shipping partial output.
* fix(build): harden prerender worker pool
---------
Co-authored-by: James <james@eli.cx>
* fix(config): resolve and bundle extensionless .cjs config imports
`vinext init` renames CJS config files (tailwind.config.js,
postcss.config.js) to .cjs when it adds "type": "module", and app code
imports them extensionlessly (import cfg from "../tailwind.config").
Two problems blocked this on the app module graph:
1. vinext overrides resolve.extensions for every Vite environment with a
list that, like Vite's default, omits .cjs/.cts, so the extensionless
import failed with [UNRESOLVED_IMPORT]. Append .cjs/.cts (lowest
priority) to buildViteResolveExtensions' default list.
2. Once resolved, vite-plugin-commonjs rewrote the .cjs module.exports to
ESM export {}, but rolldown infers moduleType: cjs from the extension
and re-parsed the output as CommonJS, failing with "Cannot use export
statement outside a module". Return false from the commonjs() filter for
project-local .cjs/.cts so vite-plugin-commonjs skips them and rolldown's
own CJS interop bundles them. Everything else returns undefined, which
preserves the plugin's defaults, including its existing skip of
node_modules .cjs files.
Fixescloudflare/vinext#13.
* fix(config): harden local cjs commonjs filter
* docs(config): fix cjs regression references
* test(config): cover extensionless cts config imports
* docs(config): clarify cjs resolve extension defaults
---------
Co-authored-by: James <james@eli.cx>