Project: Custom User Onboarding Flow
When new users sign up, you often need to gather additional profile details (such as company name, phone numbers, or usage categories) before granting access to the workspace dashboard. Let us implement a user onboarding flow.
1. Onboarding State Architecture
graph TD
A[User Logs In] --> B{Onboarding Complete?}
B -- Yes --> C[Redirect to /dashboard]
B -- No --> D[Redirect to /onboarding]
D --> E[Submit Profile Form]
E --> F[Save to clerk.publicMetadata]
F --> C2. Implementing the Middleware Interceptor
To ensure users cannot skip the onboarding page by typing URLs manually, add redirect logic to your middleware.ts:
// Example of middleware logic checking onboarding state
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
const isProtectedRoute = createRouteMatcher(["/dashboard(.*)"]);
export default clerkMiddleware(async (auth, request) => {
const authObject = await auth();
const userId = authObject.userId;
if (userId && isProtectedRoute(request)) {
const sessionClaims = authObject.sessionClaims;
// Check if onboarding status is set to true
const isOnboarded = sessionClaims?.metadata?.onboarded === true;
if (!isOnboarded) {
// Redirect user to the onboarding details form
const onboardingUrl = new URL("/onboarding", request.url);
return NextResponse.redirect(onboardingUrl);
}
}
});3. Creating the Onboarding Form Component
Here is the onboarding form component that saves metadata and updates the session:
// app/onboarding/page.tsx
"use client";
import React, { useState } from "react";
import { useUser } from "@clerk/nextjs";
import { useRouter } from "next/navigation";
export default function OnboardingPage() {
const { user, isLoaded } = useUser();
const router = useRouter();
const [companyName, setCompanyName] = useState("");
const [loading, setLoading] = useState(false);
if (!isLoaded || !user) return <p>Loading onboarding form...</p>;
async function handleOnboardingSubmit(e: React.FormEvent) {
e.preventDefault();
if (!companyName.trim()) return;
try {
setLoading(true);
// Call your backend API route to save metadata using the Clerk Server SDK
const response = await fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ companyName }),
});
if (!response.ok) throw new Error("Failed to save profile");
// Reload user session to update claims
await user.reload();
router.push("/dashboard");
} catch (err) {
alert("Failed to submit onboarding form details");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-md mx-auto p-8 border rounded-3xl bg-white shadow-sm mt-20">
<h2 className="text-xl font-bold text-gray-950">Tell us about yourself</h2>
<p className="text-gray-400 text-xs mt-1">Complete your registration details to continue.</p>
<form onSubmit={handleOnboardingSubmit} className="mt-8 space-y-6">
<div>
<label className="block text-xs font-semibold text-gray-700 uppercase">Company Name</label>
<input
type="text"
required
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Acme Corp"
className="w-full border p-2.5 rounded-lg mt-2 text-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2.5 rounded-lg text-sm transition"
>
{loading ? "Saving..." : "Submit and Access Dashboard"}
</button>
</form>
</div>
);
}Published on Last updated: