# Creating a Delightful Onboarding Experience with Multi-Step Forms

> In this post, we'll show you how to create a delightful onboarding experience using the Multi-Step Form Component for Makerkit.

*Published: 2024-07-10*
*Canonical: https://makerkit.dev/blog/changelog/delightful-onboarding-multi-step-forms*

---

The release of Multi-Step forms for Makerkit opens up a world of possibilities for creating delightful onboarding experiences. In this post, we'll show you how to create a delightful onboarding experience using the Multi-Step Form Component for Makerkit.

While Makerkit Turbo does not include an Onboarding (customers in v1 were not interested in this feature), some customers are still needing this feature. In this post, I'll show you how easy it is with Makerkit.

The final result will be a multi-step form that guides users through the onboarding process, collecting information along the way:

{% Video src="/assets/videos/onboarding-flow.mp4" /%}

## Differentiating between Onboarded and Non-Onboarded Users

First - we must be able to differentiate between onboarded and non-onboarded users. This can be done by checking if the user has completed the onboarding process. If they have, we can redirect them to the dashboard. If they haven't, we can show them the onboarding form.

To store this data, we can use the Supabase Auth `app_metadata` field. This field is a JSON object that can store any data you want (with limitations). In this case, we'll store a boolean value that indicates whether the user has completed the onboarding process.

### Checking if the User is Onboarded in the Middleware

To check if the user is onboarded, we add a check in the middleware. If the user is not onboarded, we redirect them to the onboarding page.

In your `apps/web/proxy.ts` file (or `apps/web/middleware.ts` for versions prior to Next.js 16), you can add the following code after the user has been fetched:

 ```tsx {% title="apps/web/proxy.ts" %} {19-23}
{
  pattern: new URLPattern({ pathname: '/home/*?' }),
  handler: async (req: NextRequest, res: NextResponse) => {
    const {
      data: { user },
    } = await getUser(req, res);

    const origin = req.nextUrl.origin;
    const next = req.nextUrl.pathname;

    // If user is not logged in, redirect to sign in page.
    if (!user) {
      const signIn = pathsConfig.auth.signIn;
      const redirectPath = `${signIn}?next=${next}`;

      return NextResponse.redirect(new URL(redirectPath, origin).href);
    }

    const isOnboarded = user?.app_metadata.onboarded;

    if (!isOnboarded) {
      return NextResponse.redirect(new URL('/onboarding', origin).href);
    }

    const supabase = createMiddlewareClient(req, res);

    const requiresMultiFactorAuthentication =
      await checkRequiresMultiFactorAuthentication(supabase);

    // If user requires multi-factor authentication, redirect to MFA page.
    if (requiresMultiFactorAuthentication) {
      return NextResponse.redirect(
        new URL(pathsConfig.auth.verifyMfa, origin).href,
      );
    }
  },
}
```

### Creating the Onboarding Page

With the middleware in place, we can now create the onboarding page. The onboarding page is where the user will complete the onboarding process. This page will contain the multi-step form that the user will fill out.

Let's create a page for the onboarding at `apps/web/app/onboarding/page.tsx`:

 ```tsx {% title="apps/web/app/onboarding/page.tsx" %}
import { AppLogo } from '~/components/app-logo';
import { OnboardingForm } from '~/onboarding/_components/onboarding-form';

export default function OnboardingPage() {
  return (
    <div className={'flex flex-1 flex-col h-screen items-center justify-center space-y-16'}>
      <AppLogo />

      <OnboardingForm />
    </div>
  );
}
```

NB: we don't have the `OnboardingForm` component yet, but we'll create it in the next section. Please leave it empty for now and then import it back.

#### Installing the package react-confetti

To make the onboarding experience more delightful, we can add some confetti animation when the user completes the onboarding process. We'll use the `react-confetti` package for this.

To install the package, run:

```bash
pnpm --filter web add react-confetti
```

### Creating the Multi-Step Form

Now that we have the onboarding page, we can create the multi-step form. The multi-step form will guide the user through the onboarding process, collecting the necessary information along the way.

To create the multi-step form, we'll use the Multi-Step Form Component for Makerkit. This component provides a simple API to create multi-step forms with ease.

In the onboarding form, we ask the user the following information:

1. Their full name
2. The name of the Team they want to create

We split the form into two steps, with each step collecting one piece of information - which is a good practice for onboarding forms and helps to reduce friction.

Now let's dive deep into the multi-step form implementation. We'll break down each part of the `OnboardingForm` component and explain its purpose and functionality.

### 1. Defining the Form Schema

First, we define the schema for our form using Zod:

```typescript
const FormSchema = createStepSchema({
  profile: z.object({
    name: z.string().min(1).max(255),
  }),
  team: z.object({
    name: z.string().min(1).max(255),
  }),
});
```

This schema defines two steps: 'profile' and 'team', each with a 'name' field. The `createStepSchema` function is a helper that ensures our schema is compatible with the Multi-Step Form component.

### 2. The OnboardingForm Component

The `OnboardingForm` component is the main container for our multi-step form:

```tsx
export function OnboardingForm() {
  const form = useForm({
    resolver: zodResolver(FormSchema),
    defaultValues: {
      profile: { name: '' },
      team: { name: '' },
    },
    mode: 'onBlur',
  });

  const onSubmit = useCallback((data: z.infer<typeof FormSchema>) => {
    console.log(data);
  }, []);

  return (
    <div>
      <MultiStepForm
        className={/* ... */}
        schema={FormSchema}
        form={form}
        onSubmit={onSubmit}
      >
        {/* Form content */}
      </MultiStepForm>
    </div>
  );
}
```

Here, we:
- Use `useForm` from react-hook-form to manage our form state
- Set up form validation using the Zod schema we defined
- Define an `onSubmit` callback (currently just logging the data)
- Render the `MultiStepForm` component, passing in our schema, form, and onSubmit handler

### 3. Form Header with Stepper

Inside the `MultiStepForm`, we first render a header with a stepper:

```tsx
<MultiStepFormHeader>
  <MultiStepFormContextProvider>
    {({ currentStepIndex }) => (
      <Stepper
        variant={'numbers'}
        steps={['Profile', 'Team', 'Complete']}
        currentStep={currentStepIndex}
      />
    )}
  </MultiStepFormContextProvider>
</MultiStepFormHeader>
```

This creates a visual indicator of progress through the form steps. The `MultiStepFormContextProvider` gives us access to the current step index, which we use to highlight the active step.

### 4. Form Steps

Next, we define each step of our form:

```tsx
<MultiStepFormStep name={'profile'}>
  <ProfileStep />
</MultiStepFormStep>

<MultiStepFormStep name={'team'}>
  <TeamStep />
</MultiStepFormStep>

<MultiStepFormStep name={'complete'}>
  <CompleteStep />
</MultiStepFormStep>
```

Each `MultiStepFormStep` corresponds to a step in our form schema. The `name` prop should match the keys in our schema.

### 5. The Profile Step

The `ProfileStep` component renders the first step of our form:

```tsx
function ProfileStep() {
  const { nextStep, form } = useMultiStepFormContext();

  return (
    <Form {...form}>
      <div className={'flex flex-col space-y-6'}>
        {/* Welcome text */}
        <FormField
          render={({ field }) => (
            <FormItem>
              <FormLabel>Your Name</FormLabel>
              <FormControl>
                <Input {...field} placeholder={'Name'} />
              </FormControl>
              <FormDescription>Enter your full name here</FormDescription>
            </FormItem>
          )}
          name={'profile.name'}
        />
        <div className={'flex justify-end'}>
          <Button onClick={nextStep}>Continue</Button>
        </div>
      </div>
    </Form>
  );
}
```

This step:
- Uses the `useMultiStepFormContext` hook to access form utilities
- Renders a form field for the user's name
- Provides a 'Continue' button to move to the next step

### 6. The Team Step

The `TeamStep` component is similar to the `ProfileStep`, but adds team creation functionality:

```tsx
function TeamStep() {
  const { nextStep, prevStep, form, mutation } = useMultiStepFormContext();

  return (
    <Form {...form}>
      {/* Similar structure to ProfileStep */}
      <div className={'flex justify-end space-x-2'}>
        <Button variant={'ghost'} onClick={prevStep}>
          Go Back
        </Button>
        <Button
          onClick={async (e) => {
            await mutation.mutateAsync();
            nextStep(e);
          }}
        >
          {mutation.isPending ? 'Processing...' : 'Create Team'}
        </Button>
      </div>
    </Form>
  );
}
```

This step adds:
- A 'Go Back' button to return to the previous step
- A 'Create Team' button that triggers a mutation (e.g., to create the team in your backend) before moving to the next step

The `mutation` object uses `useMutation` from React Query and provides utilities for handling asynchronous operations, which is very useful when you need additional control over form submission.

### 7. The Complete Step

The `CompleteStep` component renders the final step of our form:

```tsx
function CompleteStep() {
  const { form } = useMultiStepFormContext();

  return (
    <div className={'flex flex-col space-y-6'}>
      {/* Completion message */}
      <Button asChild>
        <Link href={'/home'}>Finish</Link>
      </Button>
    </div>
  );
}
```

This step:
- Displays a completion message, including the user's name from the form data
- Provides a 'Finish' button that links to the home page

### Handling Form Submission

Finally, we handle form submission in the `onSubmit` callback. In this function, we will execute a Server Action to update the user profile, create the team, and mark the user as onboarded.

 ```tsx {% title="apps/web/app/onboarding/_lib/server/server-actions.ts" %}
'use server';

import { z } from 'zod';

import { enhanceAction } from '@kit/next/actions';
import { getSupabaseServerClient } from '@kit/supabase/server-client';

export const submitOnboardingFormAction = enhanceAction(
  async (data, user) => {
    const client = getSupabaseServerClient();

    const createTeamResponse = await client.rpc('create_team_account', {
      account_name: data.team.name,
    });

    if (createTeamResponse.error) {
      throw new Error(
        `Failed to create team: ${createTeamResponse.error.message}`,
      );
    }

    const profileResponse = await client
      .from('accounts')
      .update({
        name: data.profile.name,
      })
      .eq('id', user.id);

    if (profileResponse.error) {
      throw new Error(
        `Failed to update profile: ${profileResponse.error.message}`,
      );
    }

    const adminClient = getSupabaseServerActionClient({ admin: true });

    await adminClient.auth.admin.updateUserById(user.id, {
      app_metadata: {
        onboarded: true,
      },
    });

    return {
      success: true,
    };
  },
  {
    auth: true,
    schema: z.object({
      profile: z.object({
        name: z.string().min(1).max(255),
      }),
      team: z.object({
        name: z.string().min(1).max(255),
      }),
    }),
  },
);
```

This action updates the user's profile, creates the team, and marks the user as onboarded. It uses the `enhanceAction` helper to handle authentication and validation.

This is just an example - your onboarding process may involve different steps or actions. You can customize the `submitOnboardingFormAction` to suit your specific requirements.

### Integrating the Server Action

Let's now integrate the `submitOnboardingFormAction` with our form submission:

 ```tsx {% title="apps/web/app/onboarding/onboarding-form.tsx" %}
import { submitOnboardingFormAction } from './_lib/server/server-actions';
```

 ```tsx {% title="apps/web/app/onboarding/onboarding-form.tsx" %}
const onSubmit = useCallback(async (data: z.infer<typeof FormSchema>) => {
  try {
    await submitOnboardingFormAction(data);
  } catch (error) {
    console.error('Failed to submit form:', error);
  }
}, []);
```

### Putting It All Together

With all the pieces in place, our `OnboardingForm` component looks like this:

 ```tsx {% title="apps/web/app/onboarding/onboarding-form.tsx" %}
'use client';

import { useCallback } from 'react';

import { createPortal } from 'react-dom';

import dynamic from 'next/dynamic';
import Link from 'next/link';

import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';

import { Button } from '@kit/ui/button';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
} from '@kit/ui/form';
import { Input } from '@kit/ui/input';
import {
  MultiStepForm,
  MultiStepFormContextProvider,
  MultiStepFormHeader,
  MultiStepFormStep,
  createStepSchema,
  useMultiStepFormContext,
} from '@kit/ui/multi-step-form';
import { Stepper } from '@kit/ui/stepper';

import { submitOnboardingFormAction } from '~/onboarding/_lib/server/server-actions';

const Confetti = dynamic(
  () => {
    return import('react-confetti');
  },
  {
    ssr: false,
  },
);

const FormSchema = createStepSchema({
  profile: z.object({
    name: z.string().min(1).max(255),
  }),
  team: z.object({
    name: z.string().min(1).max(255),
  }),
});

export function OnboardingForm() {
  const form = useForm({
    resolver: zodResolver(FormSchema),
    defaultValues: {
      profile: {
        name: '',
      },
      team: {
        name: '',
      },
    },
    mode: 'onBlur',
  });

  const onSubmit = useCallback(async (data: z.infer<typeof FormSchema>) => {
    try {
      await submitOnboardingFormAction(data);
    } catch (error) {
      console.error('Failed to submit form:', error);
    }
  }, []);

  return (
    <div>
      <MultiStepForm
        className={
          'w-full max-w-md space-y-8 rounded-lg border p-8 shadow-sm duration-500 animate-in fade-in-90 zoom-in-95 slide-in-from-bottom-12'
        }
        schema={FormSchema}
        form={form}
        onSubmit={onSubmit}
      >
        <MultiStepFormHeader>
          <MultiStepFormContextProvider>
            {({ currentStepIndex }) => (
              <Stepper
                variant={'numbers'}
                steps={['Profile', 'Team', 'Complete']}
                currentStep={currentStepIndex}
              />
            )}
          </MultiStepFormContextProvider>
        </MultiStepFormHeader>

        <MultiStepFormStep name={'profile'}>
          <ProfileStep />
        </MultiStepFormStep>

        <MultiStepFormStep name={'team'}>
          <TeamStep />
        </MultiStepFormStep>

        <MultiStepFormStep name={'complete'}>
          <CompleteStep />
        </MultiStepFormStep>
      </MultiStepForm>
    </div>
  );
}

function ProfileStep() {
  const { nextStep, form } = useMultiStepFormContext();

  return (
    <Form {...form}>
      <div className={'flex flex-col space-y-6'}>
        <div className={'flex flex-col space-y-2'}>
          <h1 className={'text-xl font-semibold'}>Welcome to Makerkit</h1>

          <p className={'text-sm text-muted-foreground'}>
            Welcome to the onboarding process! Let&apos;s get started by
            entering your name.
          </p>
        </div>

        <FormField
          render={({ field }) => {
            return (
              <FormItem>
                <FormLabel>Your Name</FormLabel>

                <FormControl>
                  <Input {...field} placeholder={'Name'} />
                </FormControl>

                <FormDescription>Enter your full name here</FormDescription>
              </FormItem>
            );
          }}
          name={'profile.name'}
        />

        <div className={'flex justify-end'}>
          <Button onClick={nextStep}>Continue</Button>
        </div>
      </div>
    </Form>
  );
}

function TeamStep() {
  const { nextStep, prevStep, form, mutation } = useMultiStepFormContext();

  return (
    <Form {...form}>
      <div className={'flex flex-col space-y-6'}>
        <div className={'flex flex-col space-y-2'}>
          <h1 className={'text-xl font-semibold'}>Create Your Team</h1>

          <p className={'text-sm text-muted-foreground'}>
            Let&apos;s create your team. Enter your team name below.
          </p>
        </div>

        <FormField
          render={({ field }) => {
            return (
              <FormItem>
                <FormLabel>Your Team Name</FormLabel>

                <FormControl>
                  <Input {...field} placeholder={'Name'} />
                </FormControl>

                <FormDescription>
                  This is the name of your team.
                </FormDescription>
              </FormItem>
            );
          }}
          name={'team.name'}
        />

        <div className={'flex justify-end space-x-2'}>
          <Button
            disabled={mutation.isPending}
            variant={'ghost'}
            onClick={prevStep}
          >
            Go Back
          </Button>

          <Button
            onClick={async (e) => {
              await mutation.mutateAsync();

              nextStep(e);
            }}
          >
            {mutation.isPending ? 'Processing...' : 'Create Team'}
          </Button>
        </div>
      </div>
    </Form>
  );
}

function CompleteStep() {
  const { form } = useMultiStepFormContext();

  return (
    <div className={'flex flex-col space-y-6'}>
      {createPortal(<Confetti numberOfPieces={500} recycle={false} />, document.body)}

      <div className={'flex flex-col space-y-2'}>
        <h1 className={'text-xl font-semibold'}>
          Hello, {form.getValues('profile').name}!
        </h1>

        <p className={'text-sm text-muted-foreground'}>
          You&apos;re almost done! Click the button below to finish the
          onboarding process.
        </p>
      </div>

      <Button asChild>
        <Link href={'/home'}>Finish</Link>
      </Button>
    </div>
  );
}
```

## Conclusion

By leveraging Makerkit's Multi-Step Form component, we've created a smooth, intuitive onboarding process. This approach:

1. Breaks down the onboarding into manageable steps
2. Provides clear visual feedback on progress
3. Allows for easy validation and error handling
4. Creates a flexible structure that can be easily extended or modified

With this onboarding flow in place, you're well on your way to creating a delightful first-time user experience that sets the tone for your entire application. Happy onboarding!
