diff --git a/job-tracker-ui/app/[[...slug]]/ClientShell.tsx b/job-tracker-ui/app/[[...slug]]/ClientShell.tsx
new file mode 100644
index 0000000..eb5e085
--- /dev/null
+++ b/job-tracker-ui/app/[[...slug]]/ClientShell.tsx
@@ -0,0 +1,13 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+// The whole app is a client-side React Router SPA whose providers read window/localStorage during
+// their initial render -- ssr:false keeps Next's static prerender from ever executing any of it on
+// the server. Kept in its own client module so the route's page.tsx can stay a server component
+// (generateStaticParams is a server-only export and cannot live in a "use client" file).
+const ClientApp = dynamic(() => import("../../src/ClientApp"), { ssr: false });
+
+export default function ClientShell() {
+ return ;
+}
diff --git a/job-tracker-ui/app/[[...slug]]/page.tsx b/job-tracker-ui/app/[[...slug]]/page.tsx
new file mode 100644
index 0000000..0352501
--- /dev/null
+++ b/job-tracker-ui/app/[[...slug]]/page.tsx
@@ -0,0 +1,15 @@
+import ClientShell from "./ClientShell";
+
+// Optional catch-all so EVERY path (/, /cv/{slug}, /career/builder/…) resolves to this same shell on
+// a hard load. Without it, `output: export` only generates `/`, and Next's client renders its
+// not-found for any deep URL before React Router can route it — breaking refresh and shared links.
+// generateStaticParams emits just index.html (the empty slug); nginx (try_files $uri /index.html)
+// serves that shell for every unknown path, and this catch-all matches it client-side so React
+// Router takes over. See docs/architecture/frontend.md (SPA routing).
+export function generateStaticParams() {
+ return [{ slug: [] }];
+}
+
+export default function Page() {
+ return ;
+}
diff --git a/job-tracker-ui/app/page.tsx b/job-tracker-ui/app/page.tsx
deleted file mode 100644
index b08cf30..0000000
--- a/job-tracker-ui/app/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-"use client";
-
-import dynamic from "next/dynamic";
-
-// The whole app is a client-side React Router SPA whose providers read window/localStorage
-// during their initial render -- ssr:false keeps Next's static prerender from ever executing
-// any of it on the server.
-const ClientApp = dynamic(() => import("../src/ClientApp"), { ssr: false });
-
-export default function Page() {
- return ;
-}
diff --git a/job-tracker-ui/src/views/PublicCvPage.tsx b/job-tracker-ui/src/views/PublicCvPage.tsx
index 26284a4..da80baa 100644
--- a/job-tracker-ui/src/views/PublicCvPage.tsx
+++ b/job-tracker-ui/src/views/PublicCvPage.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
-import { api, getApiErrorMessage } from "../api";
+import { api } from "../api";
// Anonymous read-only public CV at /cv/:slug. Renders the server-produced HTML in a sandboxed
// iframe. noindex is enforced server-side (X-Robots-Tag) and reinforced with a meta tag here.
@@ -18,8 +18,14 @@ export default function PublicCvPage() {
let alive = true;
api.get<{ html: string; name: string }>(`/public-cv/${slug}`)
- .then((r) => { if (alive) setHtml(r.data.html); })
- .catch((err) => { if (alive) setError(getApiErrorMessage(err, "This CV is not available.")); });
+ .then((r) => { if (alive) { setHtml(r.data.html); if (r.data.name) document.title = `${r.data.name} — CV`; } })
+ .catch((err) => {
+ if (!alive) return;
+ // 404 = unknown slug or the owner made it private again. Anything else is a transient error.
+ setError(err?.response?.status === 404
+ ? "This CV isn’t available. The link may be wrong, or the owner has made it private."
+ : "This CV can’t be loaded right now. Please try again in a moment.");
+ });
return () => {
alive = false;