From 500b719102bf43bb868c6ef13da860501385d8fc Mon Sep 17 00:00:00 2001
From: breken-ai <312387581+breken-ai@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:36:35 -0700
Subject: [PATCH 1/2] fix(prerender): strip baseURL from crawled links
Crawled links include baseURL while prerender routes are relative to it, so each crawled page rendered a second time and bypassed prerender.ignore and route rules.
---
src/prerender/prerender.ts | 6 ++-
.../prerender-baseurl-fixture/nitro.config.ts | 7 ++++
test/vite/prerender-baseurl-fixture/server.ts | 12 ++++++
.../prerender-baseurl-fixture/vite.config.ts | 6 +++
test/vite/prerender-baseurl.test.ts | 41 +++++++++++++++++++
5 files changed, 70 insertions(+), 2 deletions(-)
create mode 100644 test/vite/prerender-baseurl-fixture/nitro.config.ts
create mode 100644 test/vite/prerender-baseurl-fixture/server.ts
create mode 100644 test/vite/prerender-baseurl-fixture/vite.config.ts
create mode 100644 test/vite/prerender-baseurl.test.ts
diff --git a/src/prerender/prerender.ts b/src/prerender/prerender.ts
index 43e6c351af..7daa7071d8 100644
--- a/src/prerender/prerender.ts
+++ b/src/prerender/prerender.ts
@@ -311,8 +311,10 @@ export async function prerender(nitro: Nitro) {
nitro.options.prerender.crawlLinks ?? false
);
for (const _link of extractedLinks) {
- if (canPrerender(_link)) {
- routes.add(_link);
+ // Crawled links include `baseURL`, while routes are relative to it
+ const link = withoutBase(_link, nitro.options.baseURL);
+ if (canPrerender(link)) {
+ routes.add(link);
}
}
}
diff --git a/test/vite/prerender-baseurl-fixture/nitro.config.ts b/test/vite/prerender-baseurl-fixture/nitro.config.ts
new file mode 100644
index 0000000000..64ea97ea70
--- /dev/null
+++ b/test/vite/prerender-baseurl-fixture/nitro.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "nitro";
+
+export default defineConfig({
+ preset: "static",
+ baseURL: "/base/",
+ prerender: { routes: ["/"], crawlLinks: true, ignore: ["/admin"] },
+});
diff --git a/test/vite/prerender-baseurl-fixture/server.ts b/test/vite/prerender-baseurl-fixture/server.ts
new file mode 100644
index 0000000000..c3a32687d0
--- /dev/null
+++ b/test/vite/prerender-baseurl-fixture/server.ts
@@ -0,0 +1,12 @@
+export default {
+ fetch(req: Request) {
+ const { pathname } = new URL(req.url);
+ // Links rendered by an app with `baseURL` include the base
+ const links = ["/base/", "/base/about", "/base/admin"]
+ .map((href) => `${href}`)
+ .join("");
+ return new Response(`
${pathname}
${links}`, {
+ headers: { "content-type": "text/html" },
+ });
+ },
+};
diff --git a/test/vite/prerender-baseurl-fixture/vite.config.ts b/test/vite/prerender-baseurl-fixture/vite.config.ts
new file mode 100644
index 0000000000..dc884054e6
--- /dev/null
+++ b/test/vite/prerender-baseurl-fixture/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from "vite";
+import { nitro } from "nitro/vite";
+
+export default defineConfig({
+ plugins: [nitro()],
+});
diff --git a/test/vite/prerender-baseurl.test.ts b/test/vite/prerender-baseurl.test.ts
new file mode 100644
index 0000000000..b8015f602e
--- /dev/null
+++ b/test/vite/prerender-baseurl.test.ts
@@ -0,0 +1,41 @@
+import { fileURLToPath } from "node:url";
+import { join } from "node:path";
+import { rm, mkdir } from "node:fs/promises";
+import { beforeAll, describe, expect, it } from "vitest";
+import { createNitro, build, prepare } from "nitro/builder";
+
+const fixtureDir = fileURLToPath(new URL("./prerender-baseurl-fixture", import.meta.url));
+const outDir = join(fixtureDir, ".tmp");
+
+// https://github.com/nitrojs/nitro/issues/4441
+describe("prerender with baseURL", () => {
+ const routes: string[] = [];
+
+ beforeAll(async () => {
+ await rm(outDir, { recursive: true, force: true });
+ await mkdir(outDir, { recursive: true });
+ const nitro = await createNitro({
+ rootDir: fixtureDir,
+ output: { dir: outDir },
+ builder: "vite",
+ });
+ nitro.hooks.hook("prerender:route", (route) => {
+ routes.push(route.route);
+ });
+ try {
+ await prepare(nitro);
+ await build(nitro);
+ } finally {
+ await nitro.close();
+ }
+ }, 30_000);
+
+ it("renders crawled links once, without the baseURL prefix", () => {
+ expect(routes.sort()).toEqual(["/", "/about"]);
+ });
+
+ it("applies `prerender.ignore` to crawled links", () => {
+ expect(routes).not.toContain("/admin");
+ expect(routes).not.toContain("/base/admin");
+ });
+});
From 1d42d634f20204d0ccddc57aea661ead4101f46b Mon Sep 17 00:00:00 2001
From: breken-ai <312387581+breken-ai@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:50:28 -0700
Subject: [PATCH 2/2] fix(prerender): fetch and write crawled links by their
original path
Use the baseURL-relative route only as the dedupe and ignore key, so a route that itself starts with the base segment (/base/base/about) is still fetched and written as before.
---
src/prerender/prerender.ts | 27 ++++++++++++-------
test/vite/prerender-baseurl-fixture/server.ts | 2 +-
test/vite/prerender-baseurl.test.ts | 12 +++++++--
3 files changed, 28 insertions(+), 13 deletions(-)
diff --git a/src/prerender/prerender.ts b/src/prerender/prerender.ts
index 7daa7071d8..4eb1289605 100644
--- a/src/prerender/prerender.ts
+++ b/src/prerender/prerender.ts
@@ -108,6 +108,8 @@ export async function prerender(nitro: Nitro) {
// Start prerendering
const generatedRoutes = new Set();
+ // Crawled links, keyed by route (relative to `baseURL`), with their original path
+ const crawledLinkPaths = new Map();
const failedRoutes = new Set();
const skippedRoutes = new Set();
const displayedLengthWarns = new Set();
@@ -192,8 +194,12 @@ export async function prerender(nitro: Nitro) {
const generateRoute = async (route: string) => {
const start = Date.now();
+ // Crawled links are fetched, resolved and written by their original path
+ const crawledPath = crawledLinkPaths.get(route);
+
// Ensure route is decoded to start with
route = decodeURI(route);
+ const routePath = crawledPath ? decodeURI(crawledPath) : route;
// Check if we should render route
if (!canPrerender(route)) {
@@ -206,7 +212,7 @@ export async function prerender(nitro: Nitro) {
const _route: PrerenderRoute = { route };
// Fetch the route
- const encodedRoute = encodeURI(route);
+ const encodedRoute = encodeURI(routePath);
const req = toRequest(withBase(encodedRoute, nitro.options.baseURL), {
headers: [["x-nitro-prerender", encodedRoute]],
@@ -257,14 +263,14 @@ export async function prerender(nitro: Nitro) {
// Guess route type and populate fileName
const contentType = res.headers.get("content-type") || "";
const isImplicitHTML =
- !route.endsWith(".html") &&
+ !routePath.endsWith(".html") &&
contentType.includes("html") &&
!JsonSigRx.test(dataBuff!.subarray(0, 32).toString("utf8"));
- const routeWithIndex = route.endsWith("/") ? route + "index" : route;
+ const routeWithIndex = routePath.endsWith("/") ? routePath + "index" : routePath;
const htmlPath =
- route.endsWith("/") || nitro.options.prerender.autoSubfolderIndex
- ? joinURL(route, "index.html")
- : route + ".html";
+ routePath.endsWith("/") || nitro.options.prerender.autoSubfolderIndex
+ ? joinURL(routePath, "index.html")
+ : routePath + ".html";
_route.fileName = withoutBase(
isImplicitHTML ? htmlPath : routeWithIndex,
nitro.options.baseURL
@@ -303,17 +309,18 @@ export async function prerender(nitro: Nitro) {
}
// Crawl route links
- if (!_route.error && (isImplicitHTML || route.endsWith(".html"))) {
+ if (!_route.error && (isImplicitHTML || routePath.endsWith(".html"))) {
const extractedLinks = await extractLinks(
dataBuff!.toString("utf8"),
- route,
+ routePath,
res,
nitro.options.prerender.crawlLinks ?? false
);
for (const _link of extractedLinks) {
- // Crawled links include `baseURL`, while routes are relative to it
+ // Crawled links include `baseURL`, while routes (and `ignore` patterns) are relative to it
const link = withoutBase(_link, nitro.options.baseURL);
- if (canPrerender(link)) {
+ if (canPrerender(link) && !routes.has(link)) {
+ crawledLinkPaths.set(link, _link);
routes.add(link);
}
}
diff --git a/test/vite/prerender-baseurl-fixture/server.ts b/test/vite/prerender-baseurl-fixture/server.ts
index c3a32687d0..a6e6911f45 100644
--- a/test/vite/prerender-baseurl-fixture/server.ts
+++ b/test/vite/prerender-baseurl-fixture/server.ts
@@ -2,7 +2,7 @@ export default {
fetch(req: Request) {
const { pathname } = new URL(req.url);
// Links rendered by an app with `baseURL` include the base
- const links = ["/base/", "/base/about", "/base/admin"]
+ const links = ["/base/", "/base/about", "/base/admin", "/base/base/about"]
.map((href) => `${href}`)
.join("");
return new Response(`${pathname}
${links}`, {
diff --git a/test/vite/prerender-baseurl.test.ts b/test/vite/prerender-baseurl.test.ts
index b8015f602e..3527bb67b6 100644
--- a/test/vite/prerender-baseurl.test.ts
+++ b/test/vite/prerender-baseurl.test.ts
@@ -1,6 +1,6 @@
import { fileURLToPath } from "node:url";
import { join } from "node:path";
-import { rm, mkdir } from "node:fs/promises";
+import { readFile, rm, mkdir } from "node:fs/promises";
import { beforeAll, describe, expect, it } from "vitest";
import { createNitro, build, prepare } from "nitro/builder";
@@ -31,7 +31,15 @@ describe("prerender with baseURL", () => {
}, 30_000);
it("renders crawled links once, without the baseURL prefix", () => {
- expect(routes.sort()).toEqual(["/", "/about"]);
+ expect(routes.sort()).toEqual(["/", "/about", "/base/about"]);
+ });
+
+ it("fetches and writes crawled links by their original path", async () => {
+ const read = (file: string) => readFile(join(outDir, "public", file), "utf8");
+ expect(await read("index.html")).toContain("/base
");
+ expect(await read("about/index.html")).toContain("/base/about
");
+ // `/base/base/about` is the `/base/about` route, not a duplicate of `/about`
+ expect(await read("base/about/index.html")).toContain("/base/base/about
");
});
it("applies `prerender.ignore` to crawled links", () => {