Skip to content
4 changes: 2 additions & 2 deletions packages/vinext/src/build/static-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* - Dynamic routes without generateStaticParams → build error
*/
import type { ViteDevServer } from "vite";
import type { Route } from "../routing/pages-router.js";
import { patternToNextFormat, type Route } from "../routing/pages-router.js";
import type { AppRoute } from "../routing/app-router.js";
import type { ResolvedNextConfig } from "../config/next-config.js";
import { safeJsonStringify } from "../server/html.js";
Expand Down Expand Up @@ -277,7 +277,7 @@ async function renderStaticPage(options: RenderStaticPageOptions): Promise<strin
// Set SSR context for router shim
if (typeof routerShim.setSSRContext === "function") {
routerShim.setSSRContext({
pathname: urlPath,
pathname: patternToNextFormat(route.pattern),
query: params,
asPath: urlPath,
});
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/entries/pages-server-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,7 @@ async function _renderPage(request, url, manifest) {
try {
if (typeof setSSRContext === "function") {
setSSRContext({
pathname: routeUrl.split("?")[0],
pathname: patternToNextFormat(route.pattern),
query: { ...params, ...parseQuery(routeUrl) },
asPath: routeUrl,
locale: locale,
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/dev-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ export function createSSRHandler(
const routerShim = await server.ssrLoadModule("next/router");
if (typeof routerShim.setSSRContext === "function") {
routerShim.setSSRContext({
pathname: localeStrippedUrl.split("?")[0],
pathname: patternToNextFormat(route.pattern),
query: { ...params, ...parseQuery(url) },
asPath: url,
locale: locale ?? i18nConfig?.defaultLocale,
Expand Down
9 changes: 7 additions & 2 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ function getPathnameAndQuery(): {
}
return { pathname: "/", query: {}, asPath: "/" };
}
const pathname = stripBasePath(window.location.pathname, __basePath);
const resolvedPath = stripBasePath(window.location.pathname, __basePath);
// In Next.js, router.pathname is the route pattern (e.g., "/posts/[id]"),
// not the resolved path ("/posts/42"). __NEXT_DATA__.page holds the route
// pattern and is updated by navigateClient() on every client-side navigation.
const pathname = window.__NEXT_DATA__?.page ?? resolvedPath;

Choose a reason for hiding this comment

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

P2 Badge Keep router pathname source consistent across SSR and hydration

Switching client pathname to window.__NEXT_DATA__.page here makes dynamic-route pages hydrate with a different value than the server markup, because SSR still seeds router state from the resolved URL path (e.g. setSSRContext({ pathname: routeUrl.split("?")[0], ... }) in the pages server entries). When a page renders router.pathname in HTML (like /posts/[id] pages), SSR prints /posts/123 but hydration computes /posts/[id], which triggers hydration mismatch warnings and DOM replacement on first load.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 509e340. All three SSR entry points (dev-server, pages-server-entry, static-export) now use patternToNextFormat(route.pattern) for the SSR context pathname, matching what __NEXT_DATA__.page already provides. SSR and hydration are now consistent.

Copy link
Contributor

Choose a reason for hiding this comment

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

The fallback to resolvedPath when __NEXT_DATA__ isn't available is sensible — it handles edge cases where the page loaded without the SSR data script (e.g., a raw HTML shell). Good defensive coding.

const routeQuery: Record<string, string | string[]> = {};
// Include dynamic route params from __NEXT_DATA__ (e.g., { id: "42" } from /posts/[id]).
// Only include keys that are part of the route pattern (not stale query params).
Expand All @@ -302,7 +306,8 @@ function getPathnameAndQuery(): {
addQueryParam(searchQuery, key, value);
}
const query = { ...searchQuery, ...routeQuery };
const asPath = pathname + window.location.search + window.location.hash;
// asPath uses the resolved browser path, not the route pattern
const asPath = resolvedPath + window.location.search + window.location.hash;
return { pathname, query, asPath };
}

Expand Down
2 changes: 1 addition & 1 deletion tests/__snapshots__/entry-templates.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -18128,7 +18128,7 @@ async function _renderPage(request, url, manifest) {
try {
if (typeof setSSRContext === "function") {
setSSRContext({
pathname: routeUrl.split("?")[0],
pathname: patternToNextFormat(route.pattern),
query: { ...params, ...parseQuery(routeUrl) },
asPath: routeUrl,
locale: locale,
Expand Down
4 changes: 2 additions & 2 deletions tests/e2e/pages-router/dynamic-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ test.describe("Dynamic routes with getServerSideProps", () => {
test("renders post page with dynamic id from GSSP", async ({ page }) => {
await page.goto(`${BASE}/posts/123`);
await expect(page.locator('[data-testid="post-title"]')).toHaveText("Post: 123");
// vinext returns the resolved pathname (not the route pattern)
await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/123");
// router.pathname returns the route pattern, not the resolved path
await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]");
await expect(page.locator('[data-testid="query"]')).toHaveText("Query ID: 123");
});

Expand Down
3 changes: 1 addition & 2 deletions tests/e2e/pages-router/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,7 @@ test.describe("Client-side navigation", () => {
await expect(page.locator('[data-testid="as-path"]')).toHaveText(
"As Path: /posts/42?from=hook",
);
// TODO(#462): after implementing two-value url/as navigation, assert that
// router.pathname stays as the route pattern (/posts/[id]) not the resolved path.
await expect(page.locator('[data-testid="pathname"]')).toHaveText("Pathname: /posts/[id]");
expect(page.url()).toBe(`${BASE}/posts/42?from=hook`);
});

Expand Down
2 changes: 1 addition & 1 deletion tests/pages-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe("Pages Router integration", () => {
expect(html).toMatch(/Post:\s*(<!--\s*-->)?\s*42/);
expect(html).toContain("post-title");
// Router should have correct pathname and query during SSR
expect(html).toMatch(/Pathname:\s*(<!--\s*-->)?\s*\/posts\/42/);
expect(html).toMatch(/Pathname:\s*(<!--\s*-->)?\s*\/posts\/\[id\]/);
expect(html).toMatch(/Query ID:\s*(<!--\s*-->)?\s*42/);
});

Expand Down
Loading