Onboarding
Add an onboarding process after registration on your mobile app to collect more data on your new users.
The onboarding process is built on the top of the Quick Form component that use Stepper under the hood.
Redirection
After registration, the user is redirected to the /app/(app)/(tabs)/index.tsx endpoint. You are using the /app/_layout.tsx file to redirect the user to the onboarding page if the user is not onboarded.
We are using an completedOnboarding column in the user table to check if the user has completed the onboarding process.
This simple procedure allows us to make sure that all users are onboarded before accessing the dashboard.
Config object
To update the onboarding flow, you have to edit the apps/mobile/config/onboarding.config.ts file :
import { Icon } from '@kit/native-ui/icon'; import { StepperPrevious } from '@kit/native-ui/stepper'; import { Text } from '@kit/native-ui/text'; import { REGISTERED_SETTINGS_INPUTS } from '@kit/settings/www/ui'; import { LogicInputConfig, QuickFormInputConfig, QuickFormUIComponent, QuickFormWrapperConfig, type QuickFormConfig, } from '@kit/utils/quick-form'; import { View } from 'react-native'; import { z } from 'zod'; import type { EXTRA_INPUTS } from '~/app/(app)/screens/settings/[...settings]'; import { PathPreview } from '~/components/path-preview'; import { clientTrpc } from '~/utils/trpc-client'; export const onboardingSchema = { userImageUrl: z .string({ invalid_type_error: 'Image must be a string.', }) .optional() .nullable(), userName: z .string({ required_error: 'Name is required.', invalid_type_error: 'Name must be a string.', }) .trim() .min(1, 'Name is required.') .max(64, 'Maximum 64 characters allowed.'), userPhone: z .string({ invalid_type_error: 'Phone must be a string.', }) .trim() .max(16, 'Maximum 16 characters allowed.') .optional() .or(z.literal('')), userEmail: z.string().email('Please enter a valid email'), userRole: z.enum(['designer', 'programmer', 'product_manager', 'tester', 'marketer']), orgLogoUrl: z.string().optional().nullable(), orgName: z.string().min(3, 'A minimum of 3 characters is required.'), orgSlug: z.string().min(3, 'A minimum of 3 characters is required.'), orgSize: z.enum(['1', '2-10', '11-50', '51-100', '+100']), }; type OnboardingSchema = typeof onboardingSchema; type OnboardingSettings = typeof REGISTERED_SETTINGS_INPUTS & typeof EXTRA_INPUTS; export const settingsForOrgCreation: ( | QuickFormUIComponent | QuickFormInputConfig<Pick<OnboardingSchema, 'orgLogoUrl' | 'orgName' | 'orgSlug'>, OnboardingSettings> | QuickFormWrapperConfig<Pick<OnboardingSchema, 'orgLogoUrl' | 'orgName' | 'orgSlug'>, OnboardingSettings> | LogicInputConfig<Pick<OnboardingSchema, 'orgLogoUrl' | 'orgName' | 'orgSlug'>, OnboardingSettings> )[] = [ { type: 'user_media', slug: 'orgLogoUrl', triggerClassName: 'size-32 rounded-full mx-auto', imageClassName: 'w-full h-full object-cover', placeholder: <Icon name="Store" className="text-muted-foreground h-12 w-12" />, }, { type: 'text', slug: 'orgName', label: 'Name', }, { type: 'text', slug: 'orgSlug', label: 'Slug', }, { type: 'ui', render: <PathPreview />, }, ]; const goBackButton = ( <View> <StepperPrevious className="active:bg-muted mr-auto border-none" variant={'ghost'} size="icon"> <Icon name="ArrowLeft" size={24} className="text-foreground" /> </StepperPrevious> </View> ); export const onboardingConfig: QuickFormConfig<OnboardingSchema, OnboardingSettings> = { id: 'onboarding', submitButton: { hidden: true }, schema: onboardingSchema, settings: [ { type: 'stepper', nextButton: { className: 'mx-auto w-full h-10', }, hidePrevious: true, contentClassName: 'flex mt-8 gap-4 flex-col', steps: [ { type: 'step', label: 'User', header: ( <> <Text className="text-xl leading-none font-semibold tracking-tight lg:text-2xl"> Set up your profile </Text> <Text className="text-muted-foreground text-sm lg:text-base"> Make sure your profile information is correct. You'll be able to change this later. </Text> </> ), settings: [ { type: 'ui', render: <View style={{paddingTop: 32}} />, }, { type: 'user_media', slug: 'userImageUrl', className: 'mx-auto', triggerClassName: 'size-32 rounded-full mx-auto', imageClassName: 'w-full h-full object-cover', placeholder: ( <View className="border-input flex size-24 items-center justify-center rounded-full border border-dashed"> <Icon name="Image" size={20} /> </View> ), }, { type: 'text', slug: 'userName', label: 'Name', }, // { // type: 'phone', // slug: 'userPhone', // label: 'Phone', // }, { type: 'text', slug: 'userEmail', label: 'Email', disabled: true, }, ], }, { type: 'step', label: 'Profession', settings: [ { type: 'ui', render: goBackButton, }, { type: 'question_select', slug: 'userRole', question: 'Who are you ?', questionDescription: 'Help us understand your role to personalize your experience.', answers: [ { value: 'designer', label: 'Designer', icon: 'Sparkles', description: 'Create beautiful and intuitive user interfaces and experiences.', }, { value: 'programmer', label: 'Programmer', icon: 'Code', description: 'Build robust software solutions and develop cutting-edge applications.', }, { value: 'product_manager', label: 'Product manager', icon: 'ShoppingCart', description: 'Strategize product vision, roadmap planning, and feature development.', }, { value: 'tester', label: 'Tester', icon: 'TestTubeDiagonal', description: 'Ensure quality through comprehensive testing and bug identification.', }, { value: 'marketer', label: 'Marketer', icon: 'Store', description: 'Drive growth through strategic marketing campaigns and user acquisition.', }, ], }, ], }, { type: 'step', label: 'Logo', header: ( <> <h1 className="text-xl leading-none font-semibold tracking-tight lg:text-2xl"> Create your first organization </h1> <p className="text-muted-foreground text-sm lg:text-base"> We just need some basic info to get your organization set up. You’ll be able to edit this later. </p> </> ), settings: [ { type: 'ui', render: goBackButton, }, ...settingsForOrgCreation, ], async canGoNext(form) { const slug = form.getValues('orgSlug'); if (!slug || slug.length <= 3) return true; // will be blocked by form schema const result = await clientTrpc.checkIfSlugIsAvailable.fetch({ slug }); if (!result?.serverError && !result.isAvailable) { form.setError('orgSlug', { type: 'validate', message: 'This slug is already taken.', }); return false; } return true; }, }, { type: 'step', label: 'Size', settings: [ { type: 'ui', render: goBackButton, }, { type: 'question_select', slug: 'orgSize', question: 'What is the size of your organization ?', answerClassName: 'items-center', questionDescription: 'This helps us tailor features and recommendations to your organization size.', answers: [ { value: '1', label: 'Just me', }, { value: '2-10', label: '2-10', }, { value: '11-50', label: '11-50', }, { value: '51-100', label: '51-100', }, { value: '+100', label: '+100', }, ], }, ], }, ], }, ], };
How to implement the authentication feature in an existing application.
Boost accessibility and reach a wider audience by translating your app into multiple languages.
How is this guide?
Last updated on 1/18/2026