Skip to content

fix(app-router): emit streamed redirect and not-found meta tags#1261

Open
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/streaming-error-meta
Open

fix(app-router): emit streamed redirect and not-found meta tags#1261
NathanDrake2406 wants to merge 2 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/streaming-error-meta

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Field Details
Goal Match Next.js streamed App Router handling for redirect() and notFound() after the SSR shell has committed.
Core change Capture SSR render errors in handleSsr and flush browser-visible meta tags through the existing server-inserted HTML path.
Main boundary Streaming HTML cannot change the HTTP status once bytes are committed, so navigation signals must be communicated in streamed HTML.
Primary files server/app-ssr-error-meta.ts, server/app-ssr-entry.ts, server/app-page-stream.ts, tests/rsc-streaming.test.ts
Expected impact Late streamed redirects get a refresh meta tag; late streamed notFound/http access fallback errors get a noindex meta tag.

Why

Once the App Router HTML shell streams, a later redirect() or notFound() cannot turn the response into a 307 or 404. Next.js treats the streamed HTML as the browser-visible recovery boundary: it records SSR render errors and injects <meta http-equiv="refresh"> or <meta name="robots" content="noindex"> so browsers and crawlers react before hydration catches up.

Area Principle / invariant What this PR changes
SSR streaming Post-shell navigation signals must still be visible in HTML. handleSsr captures errors from React's HTML onError and flushes meta tags on each insertion pass.
Routing compatibility basePath handling for streamed redirects should match early redirect responses. Threads basePath into the SSR handler and applies it to app-internal refresh URLs.
HTML safety Dynamic redirect URLs must not break attribute context. Uses the shared HTML attribute escaper, extended to escape < and >.

What changed

Scenario Before After
redirect() after the shell has streamed 200 HTML response with the digest only in RSC data until hydration. 200 HTML response includes a refresh meta tag for the redirect target.
permanentRedirect() after the shell has streamed Same as above. Refresh meta tag uses 0 delay for 308.
notFound() / HTTP access fallback after the shell has streamed 200 HTML response lacked the noindex fallback meta. Stream includes <meta name="robots" content="noindex">.
Maintainer review path
  1. packages/vinext/src/server/app-ssr-error-meta.ts: focused helper for mapping captured digest errors to meta tags and flushing each captured error once.
  2. packages/vinext/src/server/app-ssr-entry.ts: React HTML onError capture and insertion into the existing streamed server-inserted HTML path.
  3. packages/vinext/src/server/app-page-stream.ts, app-page-render.ts, app-page-dispatch.ts: basePath plumbing into the SSR handler, including ISR regeneration.
  4. tests/app-ssr-error-meta.test.ts and tests/rsc-streaming.test.ts: direct semantics plus the post-shell streaming timing regression.
Validation
  • vp test run tests/app-ssr-error-meta.test.ts tests/app-page-stream.test.ts tests/rsc-streaming.test.ts tests/app-page-render.test.ts tests/app-page-dispatch.test.ts tests/app-ssr-stream.test.ts
  • vp check
  • Pre-commit hook also reran formatting, lint, type checks, and knip.
Risk / compatibility
  • Runtime impact is limited to App Router HTML SSR streaming.
  • The helper only emits tags for recognized Next.js control-flow digests. Ordinary SSR errors keep the existing digest behavior.
  • basePath prefixing avoids double-prefixing already-prefixed app-internal URLs to stay consistent with vinext's existing redirect response path.
  • No public API changes.
Non-goals
  • This does not change early redirect or notFound response status handling.
  • This does not alter RSC-only navigation responses or server action redirect handling.
  • This does not attempt to re-render streamed fallback pages after bytes have committed.

References

Reference Why it matters
Next.js makeGetServerInsertedHTML Shows the streamed error meta tag injection path for HTTP access fallback and redirect errors.
Next.js HTML error handler Records every SSR render error into allCapturedErrors.
Next.js app render wiring Creates allCapturedErrors and passes it to the HTML error handler.
Next.js streaming SEO E2E tests Asserts noindex and refresh meta tags for streamed notFound and redirects.
Next.js redirect docs Documents meta tag redirect behavior in streaming contexts.
Next.js streaming status code docs Documents why streaming returns 200 and communicates noindex in HTML for late 404s.

Streaming App Router SSR currently returns a 200 once the shell commits, but redirect() or notFound() discovered inside a later Suspense boundary never emits the browser-visible meta tag that Next.js relies on. That leaves the control-flow digest only in the RSC payload until client hydration handles it.

The SSR onError path now captures digest-bearing render errors and a request-local flusher injects noindex or refresh meta tags through the existing server-inserted HTML stream hook. The app page pipeline also forwards basePath into SSR so streamed redirect meta tags match early redirect responses.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1261

commit: 666bf7f

Streamed redirect meta tags checked basePath against the full redirect location string. Already-prefixed root URLs with a query or hash, such as /docs?from=checkout, failed the pathname-shaped basePath check and were prefixed a second time.

Add a shared pathname-only basePath prefix helper and use it from both early redirect responses and streamed redirect metadata. The streamed path now splits query/hash before checking the pathname.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 12:10
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-scoped implementation. The architecture mirrors Next.js closely — createSsrErrorMetaRenderer with incremental capture/flush maps neatly to Next.js's flushedErrorMetaTagsUntilIndex pattern in make-get-server-inserted-html.tsx. The basePath plumbing through the SSR pipeline is correct. Tests are thorough: unit tests for the helper, integration test for streaming timing, and regression coverage for query/hash basePath edge cases.

A few minor observations below, none blocking.

if (httpError) {
let html = '<meta name="robots" content="noindex" />';
if ((options.nodeEnv ?? process.env.NODE_ENV) === "development") {
html += '<meta name="next-error" content="not-found" />';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the next-error dev meta tag is emitted for all HTTP error fallback statuses (403, 401, etc.), not just 404. The content="not-found" value is a bit misleading for a 403 forbidden error. Next.js does the same thing (isHTTPAccessFallbackError covers all of these and emits content="not-found" for all of them), so this is correct for parity — but worth noting the Next.js oddity if someone wonders about it later.

if (!redirect) return "";

const delay = redirect.status === PERMANENT_REDIRECT_STATUS ? 0 : 1;
const location = prefixRedirectLocation(redirect.url, options.basePath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor observation: Next.js's addPathPrefix in the streaming meta tag path works on the raw URL string from getURLFromRedirectError(), which is already decoded. The prefixRedirectLocation here receives the same decoded URL from parseNextRedirectDigest. The manual pathname extraction (lines 26-36) is necessary here since new URL() would choke on relative paths. This is correct — just noting the intentional divergence from applyAppPageRedirectBasePath which can afford to use new URL(location, requestUrl) because it has a request URL for resolution.

return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good addition — < and > escaping makes escapeHtmlAttr properly comprehensive for any HTML attribute context. The existing callers (font URLs, nonces, module preload HREFs) won't be affected since those values don't contain angle brackets, but it's correct defense-in-depth for the new redirect URL use case.

const insertedHTML = renderInsertedHtml(renderServerInsertedHTML());
if (didInjectHeadHTML) return insertedHTML;
const errorMetaHTML = errorMetaRenderer.flush();
if (didInjectHeadHTML) return insertedHTML + errorMetaHTML;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordering here — insertedHTML + errorMetaHTML — means error meta tags appear after server-inserted HTML from useServerInsertedHTML callbacks. On the first flush (head injection), this places them after the nav scripts, form state, module preloads, and font HTML. On subsequent flushes, they appear after any new server-inserted HTML. This matches Next.js where error meta tags are appended after other server-inserted elements in the JSX array. Looks correct.

capturedRscDataRef: revalidatedCapturedRscRef,
}
: undefined,
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ISR regeneration path now always passes a basePath key in the options object (even when it's undefined). The old code passed undefined as the 4th argument when there was no sideStream. The new shape { basePath: options.basePath, ...conditionalSpread } is cleaner — it avoids the conditional undefined 4th parameter and ensures basePath flows through ISR regeneration consistently.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary of what I found:

Approved — this is a clean, well-structured PR that correctly implements streamed redirect and not-found meta tags for the App Router, matching Next.js behavior.

Key observations from the review:

  1. Architecture parity: The createSsrErrorMetaRenderer with incremental capture/flush directly mirrors Next.js's flushedErrorMetaTagsUntilIndex pattern in make-get-server-inserted-html.tsx. The delay values (0 for 308 permanent, 1 for 307 temporary) and the __next-page-redirect id both match Next.js.

  2. escapeHtmlAttr hardening: Adding < and > escaping is a correct defense-in-depth improvement. Next.js gets this for free from React's JSX attribute escaping since they render meta tags through renderToReadableStream, but vinext builds raw HTML strings so explicit escaping is necessary.

  3. basePath plumbing: Correctly threaded through the entire SSR pipeline — dispatch, render, stream, and SSR entry. The prefixRedirectLocation helper correctly handles the relative-URL case where new URL() isn't available (no request URL for resolution), using manual pathname extraction instead.

  4. addBasePathToPathname extraction: Good refactor consolidating the previously inline hasBasePath check + manual prefix into a shared utility, reused by both applyAppPageRedirectBasePath (early redirect response) and prefixRedirectLocation (streamed meta tag).

  5. Test coverage: Unit tests for the helper, integration test verifying streaming timing (meta tag appears between shell and resolved boundary), and regression tests for query/hash edge cases with basePath.

github run

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants