diff --git a/frontend/app/(protected)/documents/[id]/not-found.tsx b/frontend/app/(protected)/documents/[id]/not-found.tsx
index fc62127a..0dc9b9b2 100644
--- a/frontend/app/(protected)/documents/[id]/not-found.tsx
+++ b/frontend/app/(protected)/documents/[id]/not-found.tsx
@@ -1,42 +1,16 @@
-"use client";
-
-import Link from "next/link";
+import { NotFoundContent } from "@/components/NotFoundContent";
/**
- * Scoped not-found for the document detail route. Distinct from a
- * verification failure: a missing hash means no record was ever
- * anchored, not that the verifier adjudicated and rejected it.
- *
- * This file is intentionally outside the [locale] boundary so the
- * English-only copy is rendered consistently regardless of locale
- * (the document ID itself is locale-invariant).
+ * Scoped not-found for the document detail route. A missing hash means no
+ * record was ever anchored, not that the verifier adjudicated and rejected it.
+ * This boundary has no locale parameter, so it uses the default English copy.
*/
export default function DocumentNotFound() {
return (
-
-
No record anchored
-
- This document hash has no record anchored on the Stellar ledger.
-
-
- This is different from a verification failure. A failed verification
- means a record exists but did not match what you submitted; an
- unanchored hash means no party has ever published this hash, so
- there is nothing to verify against.
-
-
- This is a 404 (record not found), not a 422 or 500. The verifier
- did not adjudicate.
-
-
- Back to home
-
-
+
);
}
diff --git a/frontend/app/[locale]/not-found.tsx b/frontend/app/[locale]/not-found.tsx
index 5baddc44..c5058b5a 100644
--- a/frontend/app/[locale]/not-found.tsx
+++ b/frontend/app/[locale]/not-found.tsx
@@ -1,5 +1,5 @@
import { getTranslations, setRequestLocale } from "next-intl/server";
-import { Link } from "@/i18n/navigation";
+import { NotFoundContent } from "@/components/NotFoundContent";
export default async function NotFound({
params,
@@ -13,18 +13,11 @@ export default async function NotFound({
const t = await getTranslations("notFound");
return (
-
-
404
-
{t("description")}
-
- {t("home")}
-
-
- {t("docNotFoundDescription")}
-
-
+
);
}
diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx
index 3d04afce..3e0466f6 100644
--- a/frontend/app/not-found.tsx
+++ b/frontend/app/not-found.tsx
@@ -1,29 +1,18 @@
-import Link from "next/link";
+import { NotFoundContent } from "@/components/NotFoundContent";
/**
* Root fallback for any request that bypasses the locale boundary.
* Lives outside app/[locale]/ because Next.js renders this when
* notFound() is called from a route with no nearer not-found.tsx.
*
- * NOTE: this file MUST NOT emit or . The root
- * app/layout.tsx already provides the document frame, and a
- * second / here triggers a hydration error. Return
- * only the inner content tree so Next.js mounts it inside the
- * existing root layout.
+ * The root fallback uses the default English copy because no locale is
+ * available at this boundary.
*/
export default function GlobalNotFound() {
return (
-
-
404
-
- The page you are looking for could not be found.
-
-
- Go back home
-
-
+
);
}
diff --git a/frontend/components/NotFoundContent.tsx b/frontend/components/NotFoundContent.tsx
new file mode 100644
index 00000000..537fe8ce
--- /dev/null
+++ b/frontend/components/NotFoundContent.tsx
@@ -0,0 +1,31 @@
+import Link from "next/link";
+
+interface NotFoundContentProps {
+ description: string;
+ homeLabel: string;
+ homeHref?: string;
+ detailDescription?: string;
+}
+
+export function NotFoundContent({
+ description,
+ homeLabel,
+ homeHref = "/",
+ detailDescription,
+}: NotFoundContentProps) {
+ return (
+
+
404
+
{description}
+
+ {homeLabel}
+
+ {detailDescription ? (
+
{detailDescription}
+ ) : null}
+
+ );
+}
diff --git a/frontend/test-utils/not-found.test.tsx b/frontend/test-utils/not-found.test.tsx
new file mode 100644
index 00000000..a077382a
--- /dev/null
+++ b/frontend/test-utils/not-found.test.tsx
@@ -0,0 +1,47 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import GlobalNotFound from "@/app/not-found";
+import LocaleNotFound from "@/app/[locale]/not-found";
+import DocumentNotFound from "@/app/(protected)/documents/[id]/not-found";
+
+jest.mock("next-intl/server", () => ({
+ getTranslations: async () => (key: string) =>
+ ({
+ description: "The page you are looking for could not be found.",
+ home: "Go back home",
+ docNotFoundDescription: "The requested document was not found.",
+ })[key],
+ setRequestLocale: jest.fn(),
+}));
+
+jest.mock("@/i18n/navigation", () => ({
+ Link: ({ children, ...props }: React.AnchorHTMLAttributes) => (
+ {children}
+ ),
+}));
+
+describe("not-found boundaries", () => {
+ it("renders the root boundary with default-locale copy", () => {
+ render();
+
+ expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
+ expect(screen.getByText("The page you are looking for could not be found.")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Go back home" })).toHaveAttribute("href", "/");
+ });
+
+ it("renders the locale boundary with translated copy", async () => {
+ render(await LocaleNotFound({ params: Promise.resolve({ locale: "fr" }) }));
+
+ expect(screen.getByText("The page you are looking for could not be found.")).toBeInTheDocument();
+ expect(screen.getByText("The requested document was not found.")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Go back home" })).toHaveAttribute("href", "/fr");
+ });
+
+ it("renders the document boundary with the same branded structure", () => {
+ render();
+
+ expect(screen.getByRole("heading", { name: "404" })).toBeInTheDocument();
+ expect(screen.getByText("No record anchored")).toBeInTheDocument();
+ expect(screen.getByRole("link", { name: "Back to home" })).toHaveAttribute("href", "/");
+ });
+});