Customize the Auth Flow
Capture custom signup fields, require terms acceptance, gate signup by email allowlist, or add a post-signup onboarding step.
What you are building
A customized signup or login flow. Common asks: capture extra fields at signup (company name, role), require terms-of-service acceptance, restrict signups to an email allowlist or company domain, or redirect new users to an onboarding flow after first login.
The default auth lives in actions/auth.ts and the forms in components/forms/. The patterns below extend without rewriting.
Capturing extra fields at signup
The default signup action takes email, password, and full name. To capture additional fields:
Step 1: extend the Zod schema and form
// actions/auth.ts
const signupSchema = z.object({
email: z.email(),
password: z.string().min(8),
fullName: z.string().min(1),
companyName: z.string().min(1),
role: z.enum(['founder', 'developer', 'other']),
})
export async function signup(formData: FormData) {
const parsed = signupSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
fullName: formData.get('fullName'),
companyName: formData.get('companyName'),
role: formData.get('role'),
})
if (!parsed.success) {
return { error: 'Invalid input' }
}
// ... existing Supabase signUp call ...
}
Step 2: store the extra fields
The profiles table is auto-created by a database trigger when a user signs up. To capture custom fields, either:
- Add columns to
profiles(recommended for solo founder + handful of fields). Add the column tosupabase/schema.sql, regenerate types, then insert the values from the signup action right after the user is created. - Use Supabase user metadata for one-off fields that do not need to be queryable. Pass them in the
signUpoptions.data:
const { data, error } = await supabase.auth.signUp({
email: parsed.data.email,
password: parsed.data.password,
options: {
data: {
full_name: parsed.data.fullName,
company_name: parsed.data.companyName,
role: parsed.data.role,
},
},
})
options.data lands in auth.users.raw_user_meta_data and is accessible via user.user_metadata. Use this for display-only fields; for anything you'll filter or query on, use a column on profiles.
Step 3: extend the form component
In components/forms/signup-form.tsx, add the new fields as inputs with matching name attributes:
<input name="companyName" placeholder="Company name" required />
<select name="role" required>
<option value="founder">Founder</option>
<option value="developer">Developer</option>
<option value="other">Other</option>
</select>
Requiring terms-of-service acceptance
Add a checkbox to the signup form and validate it in the Zod schema:
const signupSchema = z.object({
// ... existing fields ...
acceptTerms: z.literal('on', {
errorMap: () => ({ message: 'You must accept the terms' }),
}),
})
<label>
<input name="acceptTerms" type="checkbox" required />
I accept the <a href="/terms">Terms of Service</a> and{' '}
<a href="/privacy">Privacy Policy</a>
</label>
For legal compliance, also store the acceptance timestamp on the user's profile so you can prove the user accepted at a specific moment:
ALTER TABLE public.profiles
ADD COLUMN terms_accepted_at timestamptz;
Then in the signup Server Action, after the user is created:
await admin
.from('profiles')
.update({ terms_accepted_at: new Date().toISOString() })
.eq('id', user.id)
Gating signup by email allowlist or domain
For closed-beta or company-internal apps:
const ALLOWED_DOMAINS = ['yourcompany.com']
const ALLOWED_EMAILS = new Set(['founder@example.com'])
export async function signup(formData: FormData) {
const parsed = signupSchema.safeParse({ /* ... */ })
if (!parsed.success) return { error: 'Invalid input' }
const email = parsed.data.email.toLowerCase()
const domain = email.split('@')[1]
const isAllowed =
ALLOWED_EMAILS.has(email) || ALLOWED_DOMAINS.includes(domain)
if (!isAllowed) {
return { error: 'Signups are currently invite-only' }
}
// ... continue with Supabase signUp ...
}
For larger allowlists, store them in a database table (signup_allowlist with an email or domain column) and query it instead. For company SSO, configure SAML in Supabase Auth and skip the allowlist entirely.
Post-signup onboarding redirect
To send new users to an onboarding flow after first login (instead of straight to the dashboard), add an onboarding_completed_at column to profiles:
ALTER TABLE public.profiles
ADD COLUMN onboarding_completed_at timestamptz;
Then check it in proxy.ts (or in the dashboard layout):
// proxy.ts (relevant section)
const user = await getUser()
if (user && pathname.startsWith('/dashboard')) {
const admin = createAdminClient()
const { data: profile } = await admin
.from('profiles')
.select('onboarding_completed_at')
.eq('id', user.id)
.single()
if (!profile?.onboarding_completed_at) {
return NextResponse.redirect(new URL('/onboarding', request.url))
}
}
The /onboarding route is a separate page where you collect whatever first-run info you need (company info, team size, integrations). Mark onboarding_completed_at when the user finishes:
await admin
.from('profiles')
.update({ onboarding_completed_at: new Date().toISOString() })
.eq('id', user.id)
Common mistakes
- Trusting client-controlled fields for authorization. Email allowlists, terms acceptance, and role choices submitted via form are all client-controlled. Validate them server-side; the form can be tampered with.
- Storing TOS acceptance only in client state. Acceptance needs to be persisted server-side with a timestamp. Legal teams will ask for proof a specific user accepted on a specific date.
- Adding onboarding-gating in client components instead of the proxy. Client-side redirects can be bypassed by disabling JavaScript or navigating directly. Always enforce gating in
proxy.ts(middleware) or in Server Components. - Skipping rate limiting on the signup endpoint. Signup is a public endpoint and a common abuse target (mass account creation for spam, billing exhaustion). The default
signupaction already applies rate limiting; do not remove it when customizing.
What to read next
- Authentication feature docs for the broader auth surface.
- Add a Server Action for the canonical mutation pattern.
- Multi-tenancy and RBAC in Supabase if your customization involves team or tenant-scoped signup.