Scope: This report applies specifically to the Next.js starter at starters/next. We have not evaluated starters/react-router and are not claiming the same issue exists there — the proxy.ts/middleware/headers() pattern described below is a Next.js–specific mechanism and would need a separate, independent investigation if it turns out that starter uses an analogous approach.
Summary
Projects generated from this starter use a root-level proxy.ts (Next.js's middleware-replacement convention) to stamp an x-url request header, which the catch-all route (app/[[...slug]]/page.tsx) then reads via headers() and parses with new URL(...) to recover Drupal's Visual Editor preview ?token=. This works under next dev / next start, but reliably fails on Netlify: the x-url header comes back empty at render time, so new URL("") throws TypeError [ERR_INVALID_URL], and any /node/preview/... request returns a 500 instead of rendering the preview.
The 500 is isolated to a single branch of calculatePath(). The function has two execution paths: a no-op path for any URL that isn't /node/preview/... (returns path unchanged, never touches URL), and the preview path, which is the only one that calls new URL(url) to read the token query-string parameter. Only the second path can throw — regular (non-preview) navigation on the same deployment was completely unaffected, because calculatePath short-circuits before ever constructing a URL for those requests. This confirms the failure is specific to the query-string/token-extraction logic, not a general breakage of routing or of calculatePath() as a whole.
We've hit this in more than one project generated from this starter, with identical code and identical failure signature, which points to the starter's default preview implementation rather than any project-specific customization.
Affected code in the starter
proxy.ts (project root):
import { type NextRequest, NextResponse } from "next/server";
export function proxy(request: NextRequest) {
const response = NextResponse.next({
request: { headers: new Headers(request.headers) },
});
response.headers.set("x-url", request.url);
return response;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
app/[[...slug]]/page.tsx:
const requestUrl = (await headers()).get("x-url") ?? "";
const path = calculatePath({ path: pathFromParams, url: requestUrl });
utils/routes.ts:
export const calculatePath = ({ path = "/", url }: CalculatePathArgs): string => {
if (path.startsWith("node/preview")) {
const { searchParams } = new URL(url); // throws if url === ""
if (searchParams.has("token")) {
return `${path}?token=${searchParams.get("token")}`;
}
}
return path;
};
Environments where it occurs
- Reproduces on: Netlify (production deployments of the generated app).
- Does not reproduce on: local
next dev and local next start.
- We have not tested other hosts (Vercel, self-hosted Node); we're raising this as a Netlify-specific report but flagging that the underlying mechanism (custom header set in middleware, expected to survive into a Server Component render) is inherently platform-dependent and worth hardening regardless of host.
Impact and scope
- Any project generated from this starter and deployed to Netlify that uses Drupal's decoupled preview (Visual Editor "Preview") will get a 500 on preview URLs instead of the expected draft content.
- Reproduced across multiple independent projects derived from this starter, with no project-specific middleware/proxy customizations — same file, same behavior, same crash. This strongly suggests the starter's default template is the common factor, not downstream project code.
Suspected root cause / technical analysis
NextResponse.next({ request: { headers } }) is Next.js's mechanism for mutating the request that continues on to the actual page render. That hand-off relies on Next's own internal request-forwarding contract between middleware/proxy execution and the render stage — it's an implementation detail of Next's server, not a documented, versioned protocol.
Netlify's Next.js Runtime doesn't run next start verbatim. It translates a Next.js build into Netlify's own primitives: middleware/proxy is deployed as a separate Netlify Edge Function, and the actual page render happens in a separate Netlify Function. Bridging those two requires Netlify's adapter to reimplement Next's middleware→origin header-forwarding contract across a network hop, rather than handing the mutated request directly to the same in-process render call the way Next's own server does. If that reimplementation doesn't fully preserve the specific header injected via NextResponse.next({ request: { headers } }), the header is silently dropped between the Edge Function and the render Function — the proxy/middleware itself runs successfully (no error surfaces there), it's the header propagation across that hop that fails.
This is consistent with the observed symptom pattern:
- Middleware/proxy executes without error.
headers().get("x-url") resolves to null only in the Netlify-deployed environment.
- The failure is a hard crash (
ERR_INVALID_URL) rather than a soft "preview not found," because the code assumed the header would always be a valid absolute URL and never guarded the new URL() call against an empty string.
- The crash only manifests inside the
path.startsWith("node/preview") branch of calculatePath() — the branch responsible for reading token off the query string via new URL(url).searchParams. Every other path through calculatePath() (i.e. every non-preview route on the site) returns immediately without constructing a URL, so it never observes the empty x-url value at all. This is why the bug presented specifically as "preview is broken on Netlify" rather than "the site is broken on Netlify" — the empty header was silently harmless everywhere except the one code path that actively parsed it to extract the token.
In short: the starter's preview implementation depends on a custom header surviving an inter-process/inter-function hop that's an internal, unversioned mechanism of Next.js's own middleware pipeline — and at least one major hosting platform's adapter (Netlify) doesn't preserve it end-to-end.
What we changed in our projects (working fix)
We removed the dependency on the x-url header entirely and instead read the preview token from Next's native searchParams page prop, which Next's render pipeline parses directly from the incoming request URL — no middleware, no custom header, no inter-function hop involved:
-
utils/routes.ts — calculatePath now takes token?: string instead of url: string, and no longer constructs a URL object at all:
export const calculatePath = ({ path = "/", token }: CalculatePathArgs): string => {
if (token && path.startsWith("node/preview")) {
return `${path}?token=${token}`;
}
return path;
};
This can never throw ERR_INVALID_URL, regardless of host.
-
proxy.ts deleted — once nothing reads the x-url header, the middleware has no remaining purpose.
-
Split the preview route out of the shared catch-all. Reading searchParams forces a route into fully dynamic (per-request) rendering, and since the original catch-all (app/[[...slug]]/page.tsx) is shared by every page on the site, reading searchParams there directly would force the entire site into dynamic rendering. Instead, we added a dedicated app/node/preview/[[...rest]]/page.tsx route (export const dynamic = "force-dynamic") that owns reading searchParams and rebuilding the Drupal preview path, keeping that cost isolated to preview traffic while the shared catch-all is left untouched. It reads the token straight from Next's searchParams prop rather than from a header:
export const dynamic = "force-dynamic";
function readToken(searchParams: {
[key: string]: string | string[] | undefined;
}): string | undefined {
const token = searchParams.token;
return typeof token === "string" ? token : undefined;
}
export default async function Page({ params, searchParams }: PageProps) {
const data = await getDrupalData({
slug: previewSlug((await params).rest),
token: readToken(await searchParams),
});
return <RouteContent {...data} />;
}
That token is passed into the shared getDrupalData helper (used by both the catch-all and the preview route), which forwards it straight through to calculatePath:
export const getDrupalData = cache(
async ({
slug,
token,
}: {
slug: string[] | undefined;
token?: string;
}): Promise<DrupalRouteData> => {
const pathFromParams = slug?.join("/") || "/home";
const path = calculatePath({ path: pathFromParams, token });
// ...fetch from Drupal using `path`
},
);
Net effect: previews work reliably on Netlify, and the rest of the site regains static caching that the original (buggy) implementation had inadvertently never actually benefited from, since the headers() call in the original catch-all was already forcing dynamic rendering site-wide as an unintended side effect of the x-url hack.
Recommendation for the starter
We'd suggest the starter adopt the searchParams-based approach as the default, rather than the proxy.ts + custom-header pattern, for these reasons:
- Platform-agnostic by construction.
searchParams is parsed directly from the request Next.js is already rendering — there's no secondary channel (middleware execution + custom header + inter-process hand-off) for a hosting platform's adapter to get wrong. This removes an entire class of platform-specific breakage, not just the Netlify case we've observed.
- Removes a redundant
proxy.ts. If the only purpose of the starter's proxy.ts is to pass the request URL to the render layer for this one preview-token use case, it can be deleted entirely once the token is read from searchParams — one less moving part in the template, and one less thing for downstream projects to carry forward without realizing what it's for.
- Move preview handling to its own dedicated route. Since reading
searchParams forces dynamic rendering, we'd recommend the starter's default catch-all route not read searchParams directly, and instead route preview traffic (/node/preview/...) to its own dedicated route from the start — keeping the dynamic-rendering cost scoped to preview only, regardless of whatever rendering strategy (static, ISR, fully dynamic, etc.) a given project chooses for the rest of its pages. We're happy to share the route-split portion of our implementation as a reference if that's useful for a PR.
- If the maintainers want to keep the current header-based approach for other reasons, we'd at minimum suggest guarding
calculatePath's new URL() call so a missing/empty URL degrades gracefully (returns the unmodified path) instead of throwing — this wouldn't fix the underlying data-loss on Netlify, but it would turn a hard 500 into a soft "preview token not applied," which is a much safer default for any hosting environment where the header assumption doesn't hold.
Happy to help
We're glad to open a PR with our fix adapted as a generic template change, share our specific commits/diffs, or provide more detail/repro steps — whatever's most useful for evaluating this. Let us know.
Scope: This report applies specifically to the Next.js starter at
starters/next. We have not evaluatedstarters/react-routerand are not claiming the same issue exists there — theproxy.ts/middleware/headers()pattern described below is a Next.js–specific mechanism and would need a separate, independent investigation if it turns out that starter uses an analogous approach.Summary
Projects generated from this starter use a root-level
proxy.ts(Next.js's middleware-replacement convention) to stamp anx-urlrequest header, which the catch-all route (app/[[...slug]]/page.tsx) then reads viaheaders()and parses withnew URL(...)to recover Drupal's Visual Editor preview?token=. This works undernext dev/next start, but reliably fails on Netlify: thex-urlheader comes back empty at render time, sonew URL("")throwsTypeError [ERR_INVALID_URL], and any/node/preview/...request returns a 500 instead of rendering the preview.The 500 is isolated to a single branch of
calculatePath(). The function has two execution paths: a no-op path for any URL that isn't/node/preview/...(returnspathunchanged, never touchesURL), and the preview path, which is the only one that callsnew URL(url)to read thetokenquery-string parameter. Only the second path can throw — regular (non-preview) navigation on the same deployment was completely unaffected, becausecalculatePathshort-circuits before ever constructing aURLfor those requests. This confirms the failure is specific to the query-string/token-extraction logic, not a general breakage of routing or ofcalculatePath()as a whole.We've hit this in more than one project generated from this starter, with identical code and identical failure signature, which points to the starter's default preview implementation rather than any project-specific customization.
Affected code in the starter
proxy.ts(project root):app/[[...slug]]/page.tsx:utils/routes.ts:Environments where it occurs
next devand localnext start.Impact and scope
Suspected root cause / technical analysis
NextResponse.next({ request: { headers } })is Next.js's mechanism for mutating the request that continues on to the actual page render. That hand-off relies on Next's own internal request-forwarding contract between middleware/proxy execution and the render stage — it's an implementation detail of Next's server, not a documented, versioned protocol.Netlify's Next.js Runtime doesn't run
next startverbatim. It translates a Next.js build into Netlify's own primitives: middleware/proxy is deployed as a separate Netlify Edge Function, and the actual page render happens in a separate Netlify Function. Bridging those two requires Netlify's adapter to reimplement Next's middleware→origin header-forwarding contract across a network hop, rather than handing the mutated request directly to the same in-process render call the way Next's own server does. If that reimplementation doesn't fully preserve the specific header injected viaNextResponse.next({ request: { headers } }), the header is silently dropped between the Edge Function and the render Function — the proxy/middleware itself runs successfully (no error surfaces there), it's the header propagation across that hop that fails.This is consistent with the observed symptom pattern:
headers().get("x-url")resolves tonullonly in the Netlify-deployed environment.ERR_INVALID_URL) rather than a soft "preview not found," because the code assumed the header would always be a valid absolute URL and never guarded thenew URL()call against an empty string.path.startsWith("node/preview")branch ofcalculatePath()— the branch responsible for readingtokenoff the query string vianew URL(url).searchParams. Every other path throughcalculatePath()(i.e. every non-preview route on the site) returns immediately without constructing aURL, so it never observes the emptyx-urlvalue at all. This is why the bug presented specifically as "preview is broken on Netlify" rather than "the site is broken on Netlify" — the empty header was silently harmless everywhere except the one code path that actively parsed it to extract the token.In short: the starter's preview implementation depends on a custom header surviving an inter-process/inter-function hop that's an internal, unversioned mechanism of Next.js's own middleware pipeline — and at least one major hosting platform's adapter (Netlify) doesn't preserve it end-to-end.
What we changed in our projects (working fix)
We removed the dependency on the
x-urlheader entirely and instead read the preview token from Next's nativesearchParamspage prop, which Next's render pipeline parses directly from the incoming request URL — no middleware, no custom header, no inter-function hop involved:utils/routes.ts—calculatePathnow takestoken?: stringinstead ofurl: string, and no longer constructs aURLobject at all:This can never throw
ERR_INVALID_URL, regardless of host.proxy.tsdeleted — once nothing reads thex-urlheader, the middleware has no remaining purpose.Split the preview route out of the shared catch-all. Reading
searchParamsforces a route into fully dynamic (per-request) rendering, and since the original catch-all (app/[[...slug]]/page.tsx) is shared by every page on the site, readingsearchParamsthere directly would force the entire site into dynamic rendering. Instead, we added a dedicatedapp/node/preview/[[...rest]]/page.tsxroute (export const dynamic = "force-dynamic") that owns readingsearchParamsand rebuilding the Drupal preview path, keeping that cost isolated to preview traffic while the shared catch-all is left untouched. It reads the token straight from Next'ssearchParamsprop rather than from a header:That
tokenis passed into the sharedgetDrupalDatahelper (used by both the catch-all and the preview route), which forwards it straight through tocalculatePath:Net effect: previews work reliably on Netlify, and the rest of the site regains static caching that the original (buggy) implementation had inadvertently never actually benefited from, since the
headers()call in the original catch-all was already forcing dynamic rendering site-wide as an unintended side effect of thex-urlhack.Recommendation for the starter
We'd suggest the starter adopt the
searchParams-based approach as the default, rather than theproxy.ts+ custom-header pattern, for these reasons:searchParamsis parsed directly from the request Next.js is already rendering — there's no secondary channel (middleware execution + custom header + inter-process hand-off) for a hosting platform's adapter to get wrong. This removes an entire class of platform-specific breakage, not just the Netlify case we've observed.proxy.ts. If the only purpose of the starter'sproxy.tsis to pass the request URL to the render layer for this one preview-token use case, it can be deleted entirely once the token is read fromsearchParams— one less moving part in the template, and one less thing for downstream projects to carry forward without realizing what it's for.searchParamsforces dynamic rendering, we'd recommend the starter's default catch-all route not readsearchParamsdirectly, and instead route preview traffic (/node/preview/...) to its own dedicated route from the start — keeping the dynamic-rendering cost scoped to preview only, regardless of whatever rendering strategy (static, ISR, fully dynamic, etc.) a given project chooses for the rest of its pages. We're happy to share the route-split portion of our implementation as a reference if that's useful for a PR.calculatePath'snew URL()call so a missing/empty URL degrades gracefully (returns the unmodified path) instead of throwing — this wouldn't fix the underlying data-loss on Netlify, but it would turn a hard 500 into a soft "preview token not applied," which is a much safer default for any hosting environment where the header assumption doesn't hold.Happy to help
We're glad to open a PR with our fix adapted as a generic template change, share our specific commits/diffs, or provide more detail/repro steps — whatever's most useful for evaluating this. Let us know.