Onboarding Flow
Add, remove, and reorder onboarding steps. Customize welcome copy, skip logic per plan, and configure illustrations.
Partial implementation: The Welcome and Create organization steps are complete and production-ready. The Invite team and Configure steps currently render UI but do not persist state or trigger downstream actions β they redirect to the dashboard on submit. These steps are safe to ship as-is if those features are not yet needed in your product.
The onboarding wizard lives in apps/web/src/app/(onboarding)/. It is a multi-step flow driven by server actions and URL search params, so it works without client-side state management.
Default flow
Welcome β Create organization β Invite team β Configure β DoneEach step maps to a directory under (onboarding):
apps/web/src/app/(onboarding)/
layout.tsx β wizard chrome (progress bar, skip link)
welcome/
page.tsx β β
Complete
create-org/
page.tsx β β
Complete
actions.ts β server action: createOrganization()
invite-team/
page.tsx β β οΈ Stub
configure/
page.tsx β β οΈ Stub
done/
page.tsx β β
CompleteStep tracking
The current step is stored in the URL as a search param and validated server-side. Users cannot jump ahead to a step they have not unlocked.
/onboarding/welcome
/onboarding/create-org
/onboarding/invite-team?skip=false
/onboarding/configure
/onboarding/doneThe layout component reads the current pathname to render the progress indicator. Each step's server action redirects to the next step's URL on success.
// Example server action pattern
// apps/web/src/app/(onboarding)/create-org/actions.ts
"use server";
import { redirect } from "next/navigation";
import { requireAuth } from "@/lib/auth";
export async function createOrganization(formData: FormData) {
const session = await requireAuth();
const name = formData.get("name") as string;
// ... create org logic
redirect("/onboarding/invite-team");
}Adding a new step
mkdir -p apps/web/src/app/\(onboarding\)/my-step// apps/web/src/app/(onboarding)/my-step/page.tsx
import { requireAuth } from "@/lib/auth";
import { MyStepForm } from "./_components/my-step-form";
export default async function MyStepPage() {
await requireAuth();
return (
<div className="mx-auto max-w-lg space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold text-[var(--neutral-12)]">
Step title
</h1>
<p className="text-[var(--neutral-11)]">
Helpful description of what this step does.
</p>
</div>
<MyStepForm />
</div>
);
}// apps/web/src/app/(onboarding)/steps.ts
export const ONBOARDING_STEPS = [
{ key: "welcome", href: "/onboarding/welcome", label: "Welcome" },
{ key: "create-org", href: "/onboarding/create-org", label: "Organization" },
{ key: "my-step", href: "/onboarding/my-step", label: "My Step" }, // β add here
{ key: "invite-team", href: "/onboarding/invite-team", label: "Team" },
{ key: "configure", href: "/onboarding/configure", label: "Configure" },
{ key: "done", href: "/onboarding/done", label: "Done" },
] as const;The layout uses this manifest to render the progress bar and validate step transitions.
Find the server action that currently redirects to the step that comes after your new step, and change its redirect() target:
// Before
redirect("/onboarding/invite-team");
// After (your new step is inserted before invite-team)
redirect("/onboarding/my-step");// apps/web/src/app/(onboarding)/my-step/actions.ts
"use server";
import { redirect } from "next/navigation";
export async function completeMyStep(formData: FormData) {
// ... persist data
redirect("/onboarding/invite-team");
}Removing a step
Delete its entry from ONBOARDING_STEPS in apps/web/src/app/(onboarding)/steps.ts.
Find the step that previously redirected to the removed step, and update its redirect() to point to the step after the removed one.
rm -rf apps/web/src/app/\(onboarding\)/my-stepSkip logic per plan
Enterprise users are often pre-configured and should skip steps like billing setup. Implement skip logic in the server action of the step that precedes the skippable step:
// apps/web/src/app/(onboarding)/create-org/actions.ts
"use server";
import { redirect } from "next/navigation";
import { getTenantContext } from "@/lib/auth";
export async function createOrganization(formData: FormData) {
const { plan } = await getTenantContext();
// ... create org logic
// Enterprise orgs are provisioned with billing pre-configured β skip that step
if (plan === "enterprise") {
redirect("/onboarding/invite-team");
} else {
redirect("/onboarding/configure");
}
}You can also expose a skip link in the step's page UI for optional steps:
import Link from "next/link";
// In your step's page component
<Link
href="/onboarding/invite-team"
className="text-sm text-[var(--neutral-11)] hover:text-[var(--neutral-12)] underline"
>
Skip for now
</Link>Customizing welcome copy
The welcome step's content is in a single file:
apps/web/src/app/(onboarding)/welcome/page.tsxEdit the headline, subheading, and feature bullets directly. The page uses standard Tailwind + token classes β no special config needed.
// apps/web/src/app/(onboarding)/welcome/page.tsx
<h1
className="text-4xl font-bold"
style={{
background: "var(--brand-gradient)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
}}
>
Welcome to Acme {/* β change this */}
</h1>
<p className="mt-3 text-lg text-[var(--neutral-11)]">
Your all-in-one platform forβ¦ {/* β and this */}
</p>Customizing illustrations
The welcome step currently uses an inline SVG in apps/web/src/app/(onboarding)/welcome/page.tsx. To swap it out, replace the <svg> element with your own asset or an <Image> component from next/image. A formal illustration slot with a centralized asset registry is planned but not yet available.
In the meantime, replace the inline SVG in apps/web/src/app/(onboarding)/welcome/page.tsx with your own:
// Replace the existing <IllustrationPlaceholder /> with your asset
import Image from "next/image";
<Image
src="/illustrations/welcome.svg" // place in apps/web/public/illustrations/
alt="Welcome to Nebutra"
width={480}
height={320}
priority
/>Layout: progress bar and navigation chrome
The wizard chrome (progress bar, back link, skip link) is in the layout:
apps/web/src/app/(onboarding)/layout.tsxThe progress bar derives its state from the ONBOARDING_STEPS manifest and the current pathname. Modifying the manifest is sufficient to update the progress bar automatically.
How is this guide?
Last updated on