diff --git a/CHANGELOG.md b/CHANGELOG.md
index 014f7fc..65b84bb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
# Changelog
+## Unreleased
+
+- An app-supplied `404.html` at the site root is served with status 404 for
+ misses, under SPA fallback for paths with an extension and under `--no-spa`
+ for every miss. Without it, misses keep the plain-text 404.
+- Docs: `--no-spa` is now recommended for multi-page and content sites, such as
+ podcast audio and Markdown transcripts, where unknown URLs should 404.
+
## 0.2.1
- Smoother migration story, with more automatic fallbacks to the old component
diff --git a/INTEGRATION.md b/INTEGRATION.md
index 14d7038..44cf8be 100644
--- a/INTEGRATION.md
+++ b/INTEGRATION.md
@@ -325,8 +325,9 @@ equivalents: `publicPath` and `assetPrefix`.
Requests for an extension-less path that doesn't match an uploaded file fall
back to `index.html`, so client-side routes survive a reload. Paths with an
extension (e.g. `/missing.js`) always 404 when not found. To turn the fallback
-off for a multi-page app (unknown paths become real 404s), deploy with
-`--no-spa`:
+off for a multi-page or content site (unknown paths become real 404s, which
+is the right choice for a site of podcast audio and Markdown transcripts), deploy
+with `--no-spa`:
```bash
npx @convex-dev/static-hosting deploy --no-spa
@@ -337,6 +338,10 @@ The flag is stored on the deployment record (the `deploymentInfo` table), so the
serving behavior travels with the code you ship rather than living in a separate
env var. Re-deploy without `--no-spa` or with `--spa` to turn it back on.
+Upload a `404.html` at the site root to show your own not-found page. A miss is
+answered with that page and status 404. Under SPA fallback, extension-less paths
+still serve `index.html`; under `--no-spa`, every miss gets the page.
+
## Upgrading from 0.1.x
0.2.0 is a **storage-breaking change**. Every existing app must remove its
diff --git a/README.md b/README.md
index 4ce1f0d..3876dc2 100644
--- a/README.md
+++ b/README.md
@@ -351,10 +351,20 @@ should be a real 404, deploy with `--no-spa`:
npx @convex-dev/static-hosting deploy --no-spa
```
+Use `--no-spa` for any site that is not a single-page app: a multi-page site, or
+a content site such as podcast audio and Markdown transcripts. Without it, an
+unknown page URL serves your homepage with status 200. With it, unknown URLs
+return 404.
+
The setting is stored with the deployment, so it travels with the code you ship
rather than living in a separate env var. Requests for paths with an extension
(e.g. `/missing.js`) always 404 when not found, regardless of this setting.
+To show your own not-found page, upload a `404.html` at the site root. A miss is
+then answered with that page and status 404. Under SPA fallback, extension-less
+paths still serve `index.html`, so the page appears for misses such as
+`/missing.js`. Under `--no-spa`, it appears for every miss.
+
## Upgrading from 0.1.x
0.2.0 moves uploads and file storage into the component. You must remove the
diff --git a/src/client/index.test.ts b/src/client/index.test.ts
index abd5c62..c52f611 100644
--- a/src/client/index.test.ts
+++ b/src/client/index.test.ts
@@ -302,3 +302,141 @@ describe("exposeDeploymentQuery", () => {
});
});
});
+describe("app 404 page through registerStaticRoutes", () => {
+ test("a miss is answered with the storage-backed 404 page and status 404", async () => {
+ const handler = staticHandler();
+ const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) =>
+ args.path === "/404.html"
+ ? {
+ storageUrl: "https://storage.example/404",
+ contentType: "text/html; charset=utf-8",
+ etag: '"nf"',
+ }
+ : null,
+ );
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(new Response("
Gone
")),
+ );
+
+ const response = await invokeHandler(
+ handler,
+ runQuery,
+ new Request("https://app.convex.site/missing.js"),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("Content-Type")).toBe(
+ "text/html; charset=utf-8",
+ );
+ expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(await response.text()).toBe("Gone
");
+ });
+
+ test("a miss without an app 404 page keeps the plain-text 404", async () => {
+ const handler = staticHandler();
+ const runQuery = vi.fn().mockResolvedValue(null);
+
+ const response = await invokeHandler(
+ handler,
+ runQuery,
+ new Request("https://app.convex.site/missing.js"),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("Content-Type")).toBe("text/plain");
+ expect(await response.text()).toBe("Not Found");
+ });
+
+ test.each([true, false])(
+ "an inherited app-storage 404 page falls back when missing: %s",
+ async (missing) => {
+ const handler = staticHandler();
+ const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) =>
+ args.path === "/404.html" ? { appStorageId: "legacy-id" } : null,
+ );
+ const get = vi
+ .fn()
+ .mockResolvedValue(missing ? null : new Blob(["Gone
"]));
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const response = await (
+ handler as unknown as {
+ _handler: (
+ ctx: { runQuery: typeof runQuery; storage: { get: typeof get } },
+ request: Request,
+ ) => Promise;
+ }
+ )._handler(
+ { runQuery, storage: { get } },
+ new Request("https://app.convex.site/missing.js"),
+ );
+
+ expect(get).toHaveBeenCalledWith("legacy-id");
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(response.status).toBe(404);
+ expect(response.headers.get("Content-Type")).toBe(
+ missing ? "text/plain" : "text/html; charset=utf-8",
+ );
+ if (!missing) {
+ expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ }
+ expect(await response.text()).toBe(
+ missing ? "Not Found" : "Gone
",
+ );
+ },
+ );
+});
+
+describe("app 404 page upstream failure", () => {
+ test("a rejected fetch for the 404 page falls back to plain text", async () => {
+ const handler = staticHandler();
+ const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) =>
+ args.path === "/404.html"
+ ? { storageUrl: "https://storage.example/404" }
+ : null,
+ );
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockRejectedValue(new TypeError("fetch failed")),
+ );
+
+ const response = await invokeHandler(
+ handler,
+ runQuery,
+ new Request("https://app.convex.site/missing.js"),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("Content-Type")).toBe("text/plain");
+ expect(await response.text()).toBe("Not Found");
+ });
+
+ test("a failed upstream fetch for the 404 page falls back to plain text", async () => {
+ const handler = staticHandler();
+ const runQuery = vi.fn(async (_fn: unknown, args: { path: string }) =>
+ args.path === "/404.html"
+ ? {
+ storageUrl: "https://storage.example/404",
+ contentType: "text/html; charset=utf-8",
+ etag: '"nf"',
+ }
+ : null,
+ );
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue(new Response("boom", { status: 500 })),
+ );
+
+ const response = await invokeHandler(
+ handler,
+ runQuery,
+ new Request("https://app.convex.site/missing.js"),
+ );
+
+ expect(response.status).toBe(404);
+ expect(response.headers.get("Content-Type")).toBe("text/plain");
+ expect(await response.text()).toBe("Not Found");
+ });
+});
diff --git a/src/client/index.ts b/src/client/index.ts
index 15a9534..28715a3 100644
--- a/src/client/index.ts
+++ b/src/client/index.ts
@@ -12,6 +12,8 @@ import {
getSetupHtml,
resolveContentType,
isHtmlContentType,
+ NOT_FOUND_PAGE_HEADERS,
+ NOT_FOUND_PAGE_PATH,
} from "../component/serving.js";
const deploymentInfoValidator = v.object({
@@ -100,6 +102,30 @@ export function registerStaticRoutes(
},
});
}
+ const notFoundPage = await ctx.runQuery(
+ component.lib.resolveAssetForHttp,
+ { path: NOT_FOUND_PAGE_PATH },
+ );
+ if (notFoundPage?.appStorageId) {
+ const blob = await ctx.storage.get(
+ notFoundPage.appStorageId as GenericId<"_storage">,
+ );
+ if (blob) {
+ return new Response(blob, {
+ status: 404,
+ headers: NOT_FOUND_PAGE_HEADERS,
+ });
+ }
+ }
+ if (notFoundPage?.storageUrl) {
+ const upstream = await fetch(notFoundPage.storageUrl).catch(() => null);
+ if (upstream?.ok && upstream.body) {
+ return new Response(upstream.body, {
+ status: 404,
+ headers: NOT_FOUND_PAGE_HEADERS,
+ });
+ }
+ }
return new Response("Not Found", {
status: 404,
headers: { "Content-Type": "text/plain" },
diff --git a/src/component/http.test.ts b/src/component/http.test.ts
index 09c1f0f..8590338 100644
--- a/src/component/http.test.ts
+++ b/src/component/http.test.ts
@@ -289,3 +289,108 @@ describe("serving helpers", () => {
});
});
});
+describe("app 404 page", () => {
+ test("a miss with an app 404 page is answered with that page and status 404", async () => {
+ const t = initConvexTest();
+ await storeAsset(
+ t,
+ "/index.html",
+ "",
+ "text/html; charset=utf-8",
+ );
+ await storeAsset(
+ t,
+ "/404.html",
+ "Gone
",
+ "text/html; charset=utf-8",
+ );
+
+ const res = await t.fetch("/missing.js", {});
+ expect(res.status).toBe(404);
+ expect(res.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
+ expect(await res.text()).toBe("Gone
");
+ });
+
+ test("SPA fallback still serves index.html for an extension-less miss", async () => {
+ const t = initConvexTest();
+ await storeAsset(
+ t,
+ "/index.html",
+ "",
+ "text/html; charset=utf-8",
+ );
+ await storeAsset(
+ t,
+ "/404.html",
+ "Gone
",
+ "text/html; charset=utf-8",
+ );
+
+ const res = await t.fetch("/dashboard", {});
+ expect(res.status).toBe(200);
+ expect(await res.text()).toBe("");
+ });
+
+ test("with SPA fallback off, an extension-less miss gets the app 404 page", async () => {
+ const t = initConvexTest();
+ await storeAsset(
+ t,
+ "/index.html",
+ "",
+ "text/html; charset=utf-8",
+ );
+ await storeAsset(
+ t,
+ "/404.html",
+ "Gone
",
+ "text/html; charset=utf-8",
+ );
+ await t.mutation(internal.lib.commitDeployment, {
+ currentDeploymentId: "deploy-1",
+ spaFallback: false,
+ });
+
+ const res = await t.fetch("/dashboard", {});
+ expect(res.status).toBe(404);
+ expect(await res.text()).toBe("Gone
");
+ });
+
+ test("without an app 404 page, a miss keeps the plain-text 404", async () => {
+ const t = initConvexTest();
+ await storeAsset(
+ t,
+ "/index.html",
+ "",
+ "text/html; charset=utf-8",
+ );
+
+ const res = await t.fetch("/missing.js", {});
+ expect(res.status).toBe(404);
+ expect(res.headers.get("Content-Type")).toBe("text/plain");
+ expect(await res.text()).toBe("Not Found");
+ });
+});
+
+describe("app 404 page fallbacks", () => {
+ test("a 404 page stored as a blob without a storage ID falls back to plain text", async () => {
+ const t = initConvexTest();
+ await storeAsset(
+ t,
+ "/index.html",
+ "",
+ "text/html; charset=utf-8",
+ );
+ await t.mutation(internal.lib.recordAsset, {
+ path: "/404.html",
+ blobId: "blob-404",
+ contentType: "text/html; charset=utf-8",
+ deploymentId: "deploy-1",
+ });
+
+ const res = await t.fetch("/missing.js", {});
+ expect(res.status).toBe(404);
+ expect(res.headers.get("Content-Type")).toBe("text/plain");
+ expect(await res.text()).toBe("Not Found");
+ });
+});
diff --git a/src/component/http.ts b/src/component/http.ts
index 85105c8..dad346b 100644
--- a/src/component/http.ts
+++ b/src/component/http.ts
@@ -8,6 +8,8 @@ import {
getSetupHtml,
resolveContentType,
isHtmlContentType,
+ NOT_FOUND_PAGE_HEADERS,
+ NOT_FOUND_PAGE_PATH,
} from "./serving.js";
// CONVEX_SITE_URL reflects the component's mount point (including any httpPrefix).
@@ -47,8 +49,9 @@ const serveStaticFile = httpAction(async (ctx, request) => {
// One query resolves the asset: an exact match, or, when SPA fallback is
// enabled for the current deployment, the index.html asset for an
- // extension-less miss. The fallback lookup happens inside the query so the
- // HTTP action never makes a second round-trip.
+ // extension-less miss. The fallback lookup happens inside the query, so a
+ // hit costs a single round-trip. Only a miss makes a second query, for the
+ // app's 404.html.
const asset = await ctx.runQuery(internal.lib.resolveAsset, { path });
if (!asset) {
@@ -62,6 +65,18 @@ const serveStaticFile = httpAction(async (ctx, request) => {
},
});
}
+ const notFoundPage = await ctx.runQuery(internal.lib.resolveAsset, {
+ path: NOT_FOUND_PAGE_PATH,
+ });
+ const notFoundBlob = notFoundPage?.storageId
+ ? await ctx.storage.get(notFoundPage.storageId)
+ : null;
+ if (notFoundBlob) {
+ return new Response(notFoundBlob, {
+ status: 404,
+ headers: NOT_FOUND_PAGE_HEADERS,
+ });
+ }
return new Response("Not Found", {
status: 404,
headers: { "Content-Type": "text/plain" },
diff --git a/src/component/serving.ts b/src/component/serving.ts
index 6db2f3f..a09bdf2 100644
--- a/src/component/serving.ts
+++ b/src/component/serving.ts
@@ -80,6 +80,18 @@ export function getSetupHtml(): string {