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 β†’ Done

Each 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                  ← βœ… Complete

Step 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/done

The 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-step

Skip 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.tsx

Edit 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.tsx

The 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?

Edit on GitHub

Last updated on

On this page