How to Add an Onboarding Flow for your Application with Clerk

Category
Engineering
Published

Leverage Clerk’s customizable session tokens, publicMetadata and Next’s Middleware to create a robust onboarding experience within a few lines of code.

As part of your onboarding flow, you may want to collect extra information from your user and use it to drive your application state. Let’s walk through a quick example using Next.js and TypeScript to show you how simple implementing an onboarding flow can be.

In this guide, you will learn how to:

  1. Add custom claims to your session token
  2. Configure your middleware to read session data
  3. Update with the user’s onboarding state

To see a working example, check out our sample demonstration app here.

Note

The examples below have been pared down to the bare minimum to enable you to easily customize them to your needs, you can build them with the Clerk + Next Quickstart using @clerk/nextjs 6.0.2 and Next 15.0.2.

Let’s get started!

Add Custom Claims to Your Session Token

Session tokens are JWTs that are generated by Clerk on behalf of your instance, and contain claims that allow you to store data about a user’s session. With Clerk, when a session token exists for a user, it indicates that the user is authenticated, and the associated claims can be retrieved at any time. [Learn More]

First, navigate to Sessions in your Clerk Dashboard and click the ‘Edit’ button. In the modal that opens, there will be a window where you can augment your session token with custom claims.

In there, add the following and hit save:

{
  "metadata": "{{user.public_metadata}}"
}

If you haven’t already, we can make the public metadata type information accessible to our application by adding the following to src/types/globals.d.ts:

src/types/globals.d.ts
export {}

declare global {
  interface CustomJwtSessionClaims {
    metadata: {
      onboardingComplete?: boolean
      applicationName?: string
      applicationType?: string
    }
    firstName?: string
  }
}

We have just added custom data to our session token in the Clerk Dashboard and made those claims accessible to our app. Next, we’ll use clerkMiddleware to redirect the user based on onboardingComplete status.

Configure your Next.js middleware to read session data

Clerk's clerkMiddleware() allows you to configure access to your routes with fine grained control. You can also retrieve claims directly from the session and redirect your user accordingly. [Learn More]

Add the code sample below to your src/middleware.ts file:

src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

const isPublicRoute = createRouteMatcher(['/', '/onboarding'])

export default clerkMiddleware(async (auth, req) => {
  const { userId, sessionClaims, redirectToSignIn } = await auth()

  // If the user isn't signed in and the route is private, redirect to sign-in
  if (!userId && !isPublicRoute(req)) {
    return redirectToSignIn({ returnBackUrl: req.url })
  }

  // Catch users who do not have `onboardingComplete: true` in their publicMetadata
  // Redirect them to the /onboading route to complete onboarding
  if (
    userId &&
    !sessionClaims?.metadata?.onboardingComplete &&
    req.nextUrl.pathname !== '/onboarding'
  ) {
    const onboardingUrl = new URL('/onboarding', req.url)
    return NextResponse.redirect(onboardingUrl)
  }

  // If the user is logged in and the route is protected, let them view.
  if (userId && !isPublicRoute(req)) {
    return NextResponse.next()
  }
})

export const config = {
  matcher: [
    // Skip Next.js internals and all static files, unless found in search params
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)',
  ],
}

Next, create a layout.tsx file in src/app/onboarding and add the following code to the file. This logic could go in the Middleware, but by adding to the layout.tsx to the route the logic remains in one place. This file can also be expanded to handle multiple steps, if multiple steps are required for an onboarding flow.

src/app/onboarding/layout.tsx
import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // Check if a user has completed onboarding
  // If yes, redirect them to /dashboard
  if ((await auth()).sessionClaims?.metadata?.onboardingComplete === true) {
    redirect('/dashboard')
  }

  return <>{children}</>
}

Now that we have the logic for where to direct the user, we’ll need a way to track their onboarding status and note it on their session, let’s dig into that now!

Update publicMetadata based on onboarding state

Updating a user's publicMetadata as they progress through the flow will allow us to recognize when they have successfully completed their onboarding and, per the logic above, are now able to access the application. [Learn More]

To do this you need:

  • A method in your backend to securely update the user publicMetadata
  • A process in your frontend with logic to collect and submit all the information for onboarding. In this guide you’ll use an example form.

Add userUpdate method to your backend

First, add a method in your backend, that will be called on form submission and update the user’s publicMetadata accordingly. The example below uses the clerkClient wrapper to interact with the Backend API.

Under src/app/onboarding/_actions.ts add the following code snippet:

src/app/onboarding/_actions.ts
'use server'

import { auth, clerkClient } from '@clerk/nextjs/server'

export const completeOnboarding = async (formData: FormData) => {
  const client = await clerkClient()
  const { userId } = await auth()

  if (!userId) {
    return { message: 'No Logged In User' }
  }

  try {
    await client.users.updateUser(userId, {
      publicMetadata: {
        onboardingComplete: true,
        applicationName: formData.get('applicationName'),
        applicationType: formData.get('applicationType'),
      },
    })
    return { message: 'User metadata Updated' }
  } catch (e) {
    console.log('error', e)
    return { message: 'Error Updating User Metadata' }
  }
}

Now that we have a method to securely update our user’s publicMetadata we can call this server action from a client side form.

Add a form to your frontend

With the backend updateUser method in place, we’ll add a basic page that contains a form to complete the onboarding process.

This example form that will capture an application name (applicationName) and application type of either B2C or B2B (applicationType). This is a very loose example — you can use this step to capture information from the user, sync user data to your database, have the user sign up to a course or subscription, or more.

To implement this logic, insert the following into your src/app/onboarding/page.tsx:

src/app/onboarding/page.tsx
'use client'

import * as React from 'react'
import { useUser } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
import { completeOnboarding } from './_actions'

export default function OnboardingComponent() {
  const { user } = useUser()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    await completeOnboarding(formData)
    await user?.reload()
    router.push('/dashboard')
  }
  return (
    <div className="px-8 py-12 sm:py-16 md:px-20">
      <div className="mx-auto max-w-sm overflow-hidden rounded-lg bg-white shadow-lg">
        <div className="p-8">
          <h3 className="text-xl font-semibold text-gray-900">Welcome!</h3>
        </div>
        <form action={handleSubmit}>
          <div className="space-y-4 px-8 pb-8">
            <div>
              <label className="block text-sm font-semibold text-gray-700">
                {' '}
                Application Name{' '}
              </label>
              <p className="text-xs text-gray-500">Enter the name of your application.</p>
              <input
                type="text"
                name="applicationName"
                className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
              />
            </div>

            <div>
              <label className="block text-sm font-semibold text-gray-700">Application Type</label>
              <p className="text-xs text-gray-500">Describe the type of your application.</p>
              <input
                type="text"
                name="applicationType"
                className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500"
              />
            </div>
          </div>
          <div className="bg-gray-50 px-8 py-4">
            <button
              type="submit"
              className="w-full rounded bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
            >
              Submit
            </button>
          </div>
        </form>
      </div>
    </div>
  )
}

Wrap Up

Your onboarding flow is now complete! 🎉 New users who haven’t yet onboarded will now land on your /onboarding page and, once they have completed onboarding, will be sent through to the dashboard. By using Clerk, which already handles user authentication, we were able to simplify the process of creating a custom user onboarding flow as well.

Ready to get started?

Sign up today
Author
Roy Anger