Skip to content

Fix: prendering with basename and ssr:false renders 404s for dynamic routes #13791

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-brooms-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"react-router": patch
---

Fix prerendering when a `basename` is set with `ssr:false`
1 change: 1 addition & 0 deletions contributors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -432,3 +432,4 @@
- zeromask1337
- zheng-chuang
- zxTomw
- skrhlm
32 changes: 19 additions & 13 deletions integration/helpers/create-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,16 +158,15 @@ export async function createFixture(init: FixtureInit, mode?: ServerMode) {
prerender: init.prerender,
requestDocument(href: string) {
let file = new URL(href, "test://test").pathname + "/index.html";
let mainPath = path.join(projectDir, "build", "client", file);
let fallbackPath = path.join(
projectDir,
"build",
"client",
"__spa-fallback.html",
);
let clientDir = path.join(projectDir, "build", "client");
let mainPath = path.join(clientDir, file);
let fallbackPath = path.join(clientDir, "__spa-fallback.html");
let fallbackPath2 = path.join(clientDir, "index.html");
let html = existsSync(mainPath)
? readFileSync(mainPath)
: readFileSync(fallbackPath);
: existsSync(fallbackPath)
? readFileSync(fallbackPath)
: readFileSync(fallbackPath2);
return new Response(html, {
headers: {
"Content-Type": "text/html",
Expand Down Expand Up @@ -344,11 +343,18 @@ export async function createAppFixture(fixture: Fixture, mode?: ServerMode) {
);
app.get("*", (req, res, next) => {
let dir = path.join(fixture.projectDir, "build", "client");
let file = req.path.endsWith(".data")
? req.path
: req.path + "/index.html";
if (file.endsWith(".html") && !existsSync(path.join(dir, file))) {
file = "__spa-fallback.html";
let file;
if (req.path.endsWith(".data")) {
file = req.path;
} else {
let mainPath = req.path + "/index.html";
let fallbackPath = "__spa-fallback.html";
let fallbackPath2 = "index.html";
file = existsSync(mainPath)
? mainPath
: existsSync(fallbackPath)
? fallbackPath
: fallbackPath2;
}
let filePath = path.join(dir, file);
if (existsSync(filePath)) {
Expand Down
285 changes: 285 additions & 0 deletions integration/vite-prerender-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2673,5 +2673,290 @@ test.describe("Prerendering", () => {
await page.waitForSelector("#target");
expect(requests).toEqual(["/redirect.data"]);
});

test("Navigates across SPA/prerender pages when starting from a SPA page (w/basename)", async ({
page,
}) => {
fixture = await createFixture({
prerender: true,
files: {
"react-router.config.ts": reactRouterConfig({
ssr: false, // turn off fog of war since we're serving with a static server
prerender: ["/page"],
basename: "/base",
}),
"vite.config.ts": files["vite.config.ts"],
"app/root.tsx": js`
import * as React from "react";
import { Outlet, Scripts } from "react-router";

export function Layout({ children }) {
return (
<html lang="en">
<head />
<body>
{children}
<Scripts />
</body>
</html>
);
}

export default function Root({ loaderData }) {
return <Outlet />
}
`,
"app/routes/_index.tsx": js`
import { Link } from 'react-router';
export default function Index() {
return <Link to="/page">Go to page</Link>
}
`,
"app/routes/page.tsx": js`
import { Link, Form } from 'react-router';
export async function loader() {
return "PAGE DATA"
}
let count = 0;
export function clientAction() {
return "PAGE ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page>{loaderData}</p>
{actionData ? <p data-page-action>{actionData}</p> : null}
<Link to="/page2">Go to page2</Link>
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
"app/routes/page2.tsx": js`
import { Form } from 'react-router';
export function clientLoader() {
return "PAGE2 DATA"
}
let count = 0;
export function clientAction() {
return "PAGE2 ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page2>{loaderData}</p>
{actionData ? <p data-page2-action>{actionData}</p> : null}
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
},
});
appFixture = await createAppFixture(fixture);

let requests = captureRequests(page);
let app = new PlaywrightFixture(appFixture, page);

await app.goto("/base", true);
await page.waitForSelector('a[href="/base/page"]');

await app.clickLink("/base/page");
await page.waitForSelector("[data-page]");
expect(await (await page.$("[data-page]"))?.innerText()).toBe(
"PAGE DATA",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 1",
);
// No revalidation after submission to self
expect(requests).toEqual([]);

await app.clickLink("/base/page2");
await page.waitForSelector("[data-page2]");
expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
"PAGE2 DATA",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 1",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 2",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 2",
);
expect(requests).toEqual([]);
});

test("Navigates across SPA/prerender pages when starting from a prerendered page (w/basename)", async ({
page,
}) => {
fixture = await createFixture({
prerender: true,
files: {
"react-router.config.ts": reactRouterConfig({
ssr: false, // turn off fog of war since we're serving with a static server
prerender: ["/", "/page"],
basename: "/base",
}),
"vite.config.ts": files["vite.config.ts"],
"app/root.tsx": js`
import * as React from "react";
import { Outlet, Scripts } from "react-router";

export function Layout({ children }) {
return (
<html lang="en">
<head />
<body>
{children}
<Scripts />
</body>
</html>
);
}

export default function Root({ loaderData }) {
return <Outlet />;
}
`,
"app/routes/_index.tsx": js`
import { Link } from 'react-router';
export default function Index() {
return <Link to="/page">Go to page</Link>
}
`,
"app/routes/page.tsx": js`
import { Link, Form } from 'react-router';
export async function loader() {
return "PAGE DATA"
}
let count = 0;
export function clientAction() {
return "PAGE ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page>{loaderData}</p>
{actionData ? <p data-page-action>{actionData}</p> : null}
<Link to="/page2">Go to page2</Link>
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
"app/routes/page2.tsx": js`
import { Form } from 'react-router';
export function clientLoader() {
return "PAGE2 DATA"
}
let count = 0;
export function clientAction() {
return "PAGE2 ACTION " + (++count)
}
export default function Page({ loaderData, actionData }) {
return (
<>
<p data-page2>{loaderData}</p>
{actionData ? <p data-page2-action>{actionData}</p> : null}
<Form method="post" action="/page">
<button type="submit">Submit</button>
</Form>
<Form method="post" action="/page2">
<button type="submit">Submit /page2</button>
</Form>
</>
);
}
`,
},
});
appFixture = await createAppFixture(fixture);

let requests = captureRequests(page);
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/base", true);
await page.waitForSelector('a[href="/base/page"]');

await app.clickLink("/base/page");
await page.waitForSelector("[data-page]");
expect(await (await page.$("[data-page]"))?.innerText()).toBe(
"PAGE DATA",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 1",
);
// No revalidation after submission to self
expect(requests).toEqual([]);

await app.clickLink("/base/page2");
await page.waitForSelector("[data-page2]");
expect(await (await page.$("[data-page2]"))?.innerText()).toBe(
"PAGE2 DATA",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 1",
);
expect(requests).toEqual([]);

await app.clickSubmitButton("/base/page");
await page.waitForSelector("[data-page-action]");
expect(await (await page.$("[data-page-action]"))?.innerText()).toBe(
"PAGE ACTION 2",
);
expect(requests).toEqual(["/base/page.data"]);
clearRequests(requests);

await app.clickSubmitButton("/base/page2");
await page.waitForSelector("[data-page2-action]");
expect(await (await page.$("[data-page2-action]"))?.innerText()).toBe(
"PAGE2 ACTION 2",
);
expect(requests).toEqual([]);
});
});
});
24 changes: 24 additions & 0 deletions packages/react-router/lib/server-runtime/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,30 @@ export const createRequestHandler: CreateRequestHandlerFunction = (
// Decode the URL path before checking against the prerender config
let decodedPath = decodeURI(normalizedPath);

if (normalizedBasename !== "/") {
let strippedPath = stripBasename(decodedPath, normalizedBasename);
if (strippedPath == null) {
errorHandler(
new ErrorResponseImpl(
404,
"Not Found",
`Refusing to prerender the \`${decodedPath}\` path because it does ` +
`not start with the basename \`${normalizedBasename}\``,
),
{
context: loadContext,
params,
request,
},
);
return new Response("Not Found", {
status: 404,
statusText: "Not Found",
});
}
decodedPath = strippedPath;
}

// When SSR is disabled this, file can only ever run during dev because we
// delete the server build at the end of the build
if (_build.prerender.length === 0) {
Expand Down
Loading