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 4.29.5 and Next 14.0.4.

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:

export {}

declare global {
  interface CustomJwtSessionClaims {
    metadata: {
      onboardingComplete?: boolean
    }
  }
}

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 authMiddleware() allows you ton 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:

import { authMiddleware } from '@clerk/nextjs'
import { redirectToSignIn } from '@clerk/nextjs/server'
import { NextRequest, NextResponse } from 'next/server'

// This example protects all routes including api/trpc routes
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
  publicRoutes: ['/'],
  afterAuth: async (auth, req: NextRequest) => {
    const { userId, sessionClaims } = auth

    // For user visiting /onboarding, don't try and redirect
    if (userId && req.nextUrl.pathname === '/onboarding') {
      return NextResponse.next()
    }

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

    // Catch users who doesn't have `onboardingComplete: true` in PublicMetata
    // Redirect them to the /onboading out to complete onboarding
    if (userId && !sessionClaims?.metadata?.onboardingComplete) {
      const onboardingUrl = new URL('/onboarding', req.url)
      return NextResponse.redirect(onboardingUrl)
    }

    // User is logged in and the route is protected - let them view.
    if (userId && !auth.isPublicRoute) return NextResponse.next()

    // If the route is public, anyone can view it.
    if (auth.isPublicRoute) return NextResponse.next()
  },
})

export const config = {
  matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(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.

import { auth } from '@clerk/nextjs/server'
import { redirect } from 'next/navigation'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  // Check if a user has completed onboarding
  // If yes, redirect them to /dashboard
  if (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:

'use server'

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

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

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

  try {
    const res = await clerkClient.users.updateUser(userId, {
      publicMetadata: {
        onboardingComplete: true,
        applicationName: formData.get('applicationName'),
        applicationType: formData.get('applicationType'),
      },
    })
    return { message: res.publicMetadata }
  } catch (err) {
    return { error: 'There was an error updating the 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:

'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 [error, setError] = React.useState('')
  const { user } = useUser()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const res = await completeOnboarding(formData)
    if (res?.message) {
      await user?.reload()
      router.push('/dashboard')
    }
    if (res?.error) {
      setError(res?.error)
    }
  }
  return (
    <div>
      <h1>Welcome</h1>
      <form action={handleSubmit}>
        <div>
          <label>Application Name</label>
          <p>Enter the name of your application.</p>
          <input type="text" name="applicationName" required />
        </div>

        <div>
          <label>Application Type</label>
          <p>Describe the type of your application.</p>
          <input type="text" name="applicationType" required />
        </div>
        {error && <p className="text-red-600">Error: {error}</p>}
        <button type="submit">Submit</button>
      </form>
    </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.

Author
Roy Anger