diff --git a/src/app/onboarding/[step]/page.tsx b/src/app/onboarding/[step]/page.tsx
new file mode 100644
index 000000000..596584632
--- /dev/null
+++ b/src/app/onboarding/[step]/page.tsx
@@ -0,0 +1,155 @@
+import { redirect, notFound } from "next/navigation";
+
+import { OnboardingShell } from "@/components/onboarding/OnboardingShell";
+import { getSession } from "@/lib/auth/session";
+import { getServerTranslator } from "@/lib/i18n/server-translator";
+import { resolveServerLocale } from "@/lib/i18n/server-locale";
+
+/**
+ * v1.4.25 W14b — onboarding wizard step page (foundation scaffold).
+ *
+ * Routes:
+ * /onboarding/0 welcome → carousel / value-prop intro
+ * /onboarding/1 goals → "what do you want to track?"
+ * /onboarding/2 source → Withings / Apple Health / manual cards
+ * /onboarding/3 baseline → first measurement or sync confirmation
+ * /onboarding/4 done → success screen + return to dashboard
+ *
+ * This page ships the scaffold only — the step body is a placeholder
+ * `
` that the W14b-Content agent replaces with real step UI.
+ *
+ * Step gating:
+ * - Unauthenticated → `/auth/login` (the proxy also enforces this,
+ * but the server-side check keeps the contract explicit).
+ * - `onboardingCompletedAt != null` → `/` (no replaying the wizard
+ * by URL once the user has finished).
+ * - Out-of-order requests (e.g. user lands on `/onboarding/3` while
+ * `onboardingStep == 1`) redirect to the user's current step. The
+ * "current step" is `User.onboardingStep ?? 0`.
+ *
+ * The user MAY navigate backwards (e.g. `/onboarding/0` while on step
+ * 2). Backwards navigation is non-destructive — the shell's "Back"
+ * button uses it. Only forward jumps are blocked.
+ */
+
+const VALID_STEPS = [0, 1, 2, 3, 4] as const;
+type Step = (typeof VALID_STEPS)[number];
+
+export const dynamicParams = false;
+
+export function generateStaticParams() {
+ return VALID_STEPS.map((step) => ({ step: String(step) }));
+}
+
+interface PageProps {
+ params: Promise<{ step: string }>;
+}
+
+export default async function OnboardingStepPage({ params }: PageProps) {
+ const { step: stepParam } = await params;
+ const parsedStep = Number.parseInt(stepParam, 10);
+
+ if (!Number.isFinite(parsedStep) || !VALID_STEPS.includes(parsedStep as Step)) {
+ notFound();
+ }
+ const requested = parsedStep as Step;
+
+ const session = await getSession();
+ if (!session) {
+ redirect("/auth/login");
+ }
+ const { user } = session;
+
+ if (user.onboardingCompletedAt) {
+ redirect("/");
+ }
+
+ const current = clampCurrentStep(user.onboardingStep);
+ if (requested > current) {
+ redirect(`/onboarding/${current}`);
+ }
+
+ const locale = await resolveServerLocale({ userLocale: user.locale });
+ const { t } = getServerTranslator(locale);
+
+ // Foundation scaffold body — placeholder per-step copy. The Content
+ // agent replaces each branch's body with the real carousel /
+ // goal-chips / source-cards / baseline-form / done-screen UI.
+ //
+ // Welcome copy lives under `onboarding.shell.welcome{Title,Body}`
+ // because the carousel is part of the chrome; every other step
+ // owns its own namespace (`onboarding.goals.*`, `.source.*`,
+ // `.baseline.*`, `.done.*`).
+ const bodyTitle =
+ requested === 0
+ ? t("onboarding.shell.welcomeTitle")
+ : t(`onboarding.${stepKey(requested)}.title`);
+ const bodyText =
+ requested === 0
+ ? t("onboarding.shell.welcomeBody")
+ : t(`onboarding.${stepKey(requested)}.body`);
+
+ // Wire shell navigation by step. Step 0 has no Back; the wizard
+ // never lets the user navigate forward past their current step, but
+ // they can advance from the current step's "Next" button — the
+ // POST /api/onboarding/step mutation flips `onboardingStep` and the
+ // Content agent will redirect to the new step on success. The
+ // Foundation scaffold uses plain hrefs so the shell renders end-to-
+ // end immediately; the Content agent will replace the next link
+ // with an action button bound to the API.
+ const backHref = requested > 0 ? `/onboarding/${requested - 1}` : undefined;
+ const skipHref = requested > 0 && requested < 4 ? "/" : undefined;
+ const nextHref =
+ requested < 4 ? `/onboarding/${requested + 1}` : "/";
+
+ return (
+
+
+
+ {bodyTitle}
+
+
+ {bodyText}
+
+
+
+
+ );
+}
+
+function clampCurrentStep(value: number | null | undefined): Step {
+ if (value == null || !Number.isFinite(value)) return 0;
+ const floor = Math.floor(value);
+ if (floor <= 0) return 0;
+ if (floor >= 4) return 4;
+ return floor as Step;
+}
+
+function stepKey(step: Step): "welcome" | "goals" | "source" | "baseline" | "done" {
+ switch (step) {
+ case 0:
+ return "welcome";
+ case 1:
+ return "goals";
+ case 2:
+ return "source";
+ case 3:
+ return "baseline";
+ case 4:
+ return "done";
+ }
+}
diff --git a/src/app/onboarding/layout.tsx b/src/app/onboarding/layout.tsx
new file mode 100644
index 000000000..7e2daca90
--- /dev/null
+++ b/src/app/onboarding/layout.tsx
@@ -0,0 +1,15 @@
+import type { ReactNode } from "react";
+
+/**
+ * v1.4.25 W14b — onboarding route-segment layout.
+ *
+ * Intentionally pass-through. The `OnboardingShell` chrome lives inside
+ * each `[step]/page.tsx` so the existing `/onboarding` root page
+ * (`page.tsx` — the legacy v1.4.20 single-file wizard) keeps rendering
+ * unchanged while the rebuilt multi-step flow ships beside it. The
+ * Content agent that follows W14b-Foundation will swap the root page's
+ * redirect target once every step has real content.
+ */
+export default function OnboardingLayout({ children }: { children: ReactNode }) {
+ return
{children}
;
+}