Skip to main content
Docs

Build a custom flow for handling legal acceptance

Important

This guide applies to the following Clerk SDKs:

  • @clerk/react v6 or higher
  • @clerk/nextjs v7 or higher
  • @clerk/expo v3 or higher
  • @clerk/react-router v3 or higher
  • @clerk/tanstack-react-start v0.26.0 or higher

If you're using an older version of one of these SDKs, or are using the legacy API, refer to the legacy API documentation.

When the legal acceptance feature is enabled, users are required to agree to your Terms of Service and Privacy Policy before they can sign up to your application.

If you're using the <SignUp /> component, a checkbox appears and the legal acceptance flow is handled for you. However, if you're building a custom user interface, you need to handle legal acceptance in your sign-up form.

This guide demonstrates how to use the Clerk API to build a custom user interface for handling legal acceptance.

Before you start

By default, the legal acceptance feature is disabled. To enable it, navigate to the Legal page in the Clerk Dashboard.

To support legal acceptance in your sign-up custom flow, pass the legalAccepted boolean to whichever method is initializing the sign-up process (e.g., SignUp.password(), SignUp.create(), etc.). Because legalAccepted is tracked through a simple boolean, this means that you can add any kind of "legal acceptance" fields to your sign-up form that you want - you are not limited to only "Terms of Service" or "Privacy Policy" fields.

This example uses the email and password sign-up custom flow as a base. However, you can modify this approach according to the settings you've configured for your application's instance in the Clerk Dashboard.

Tip

Examples for this SDK aren't available yet. For now, try adapting the available example to fit your SDK.

app/sign-up/page.tsx
'use client'

import { useAuth, useSignUp } from '@clerk/nextjs'
import Link from 'next/link'
import { useRouter } from 'next/navigation'

export default function Page() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const { isSignedIn } = useAuth()
  const router = useRouter()

  const handleSubmit = async (formData: FormData) => {
    const emailAddress = formData.get('email') as string
    const password = formData.get('password') as string
    const legalAccepted = formData.get('legalAccepted') as boolean

    const { error } = await signUp.password({
      emailAddress,
      password,
      legalAccepted,
    })
    if (error) {
      // See https://clerk.com/docs/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (!error) await signUp.verifications.sendEmailCode()
  }

  const handleVerify = async (formData: FormData) => {
    const code = formData.get('code') as string

    await signUp.verifications.verifyEmailCode({
      code,
    })
    if (signUp.status === 'complete') {
      await signUp.finalize({
        // Redirect the user to the home page after signing up
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            // Handle pending session tasks
            // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
            console.log(session?.currentTask)
            return
          }

          const url = decorateUrl('/')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    } else {
      // Check why the sign-up is not complete
      console.error('Sign-up attempt not complete:', signUp)
    }
  }

  if (signUp.status === 'complete' || isSignedIn) {
    return null
  }

  if (
    signUp.status === 'missing_requirements' &&
    signUp.unverifiedFields.includes('email_address') &&
    signUp.missingFields.length === 0
  ) {
    return (
      <>
        <h1>Verify your account</h1>
        <form action={handleVerify}>
          <div>
            <label htmlFor="code">Code</label>
            <input id="code" name="code" type="text" />
          </div>
          {errors.fields.code && <p>{errors.fields.code.message}</p>}
          <button type="submit" disabled={fetchStatus === 'fetching'}>
            Verify
          </button>
        </form>
        <button onClick={() => signUp.verifications.sendEmailCode()}>I need a new code</button>
      </>
    )
  }

  return (
    <>
      <h1>Sign up</h1>
      <form action={handleSubmit}>
        <div>
          <label htmlFor="email">Enter email address</label>
          <input id="email" type="email" name="email" />
          {errors.fields.emailAddress && <p>{errors.fields.emailAddress.message}</p>}
        </div>
        <div>
          <label htmlFor="password">Enter password</label>
          <input id="password" type="password" name="password" />
          {errors.fields.password && <p>{errors.fields.password.message}</p>}
        </div>
        <div>
          <label htmlFor="legalAccepted">
            I accept the <Link href="/terms">Terms of Service</Link> and{' '}
            <Link href="/privacy">Privacy Policy</Link>
          </label>
          <input id="legalAccepted" type="checkbox" name="legalAccepted" defaultChecked={false} />
        </div>
        <button type="submit" disabled={fetchStatus === 'fetching'}>
          Continue
        </button>
      </form>
      {/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
      {errors && <p>{JSON.stringify(errors, null, 2)}</p>}

      {/* Required for sign-up flows. Clerk's bot sign-up protection is enabled by default */}
      <div id="clerk-captcha" />
    </>
  )
}
app/(auth)/sign-up.tsx
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useAuth, useSignUp } from '@clerk/expo'
import { type Href, Link, useRouter } from 'expo-router'
import React from 'react'
import { Pressable, StyleSheet, TextInput, View } from 'react-native'

export default function Page() {
  const { signUp, errors, fetchStatus } = useSignUp()
  const { isSignedIn } = useAuth()
  const router = useRouter()

  const [emailAddress, setEmailAddress] = React.useState('')
  const [password, setPassword] = React.useState('')
  const [legalAccepted, setLegalAccepted] = React.useState(false)
  const [code, setCode] = React.useState('')

  const handleSubmit = async () => {
    const { error } = await signUp.password({
      emailAddress,
      password,
      legalAccepted,
    })
    if (error) {
      // See https://clerk.com/docs/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(error, null, 2))
      return
    }

    if (!error) await signUp.verifications.sendEmailCode()
  }

  const handleVerify = async () => {
    await signUp.verifications.verifyEmailCode({
      code,
    })
    if (signUp.status === 'complete') {
      await signUp.finalize({
        // Redirect the user to the home page after signing up
        navigate: ({ session, decorateUrl }) => {
          if (session?.currentTask) {
            // Handle pending session tasks
            // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
            console.log(session?.currentTask)
            return
          }

          const url = decorateUrl('/')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url as Href)
          }
        },
      })
    } else {
      // Check why the sign-up is not complete
      console.error('Sign-up attempt not complete:', signUp)
    }
  }

  if (signUp.status === 'complete' || isSignedIn) {
    return null
  }

  if (
    signUp.status === 'missing_requirements' &&
    signUp.unverifiedFields.includes('email_address') &&
    signUp.missingFields.length === 0
  ) {
    return (
      <ThemedView style={styles.container}>
        <ThemedText type="title" style={styles.title}>
          Verify your account
        </ThemedText>
        <ThemedText style={styles.label}>Code</ThemedText>
        <TextInput
          style={styles.input}
          value={code}
          placeholder="Enter your verification code"
          placeholderTextColor="#666666"
          onChangeText={(code) => setCode(code)}
          keyboardType="numeric"
        />
        {errors.fields.code && (
          <ThemedText style={styles.error}>{errors.fields.code.message}</ThemedText>
        )}
        <Pressable
          style={({ pressed }) => [
            styles.button,
            fetchStatus === 'fetching' && styles.buttonDisabled,
            pressed && styles.buttonPressed,
          ]}
          onPress={handleVerify}
          disabled={fetchStatus === 'fetching'}
        >
          <ThemedText style={styles.buttonText}>Verify</ThemedText>
        </Pressable>
        <Pressable
          style={({ pressed }) => [styles.secondaryButton, pressed && styles.buttonPressed]}
          onPress={() => signUp.verifications.sendEmailCode()}
        >
          <ThemedText style={styles.secondaryButtonText}>I need a new code</ThemedText>
        </Pressable>
      </ThemedView>
    )
  }

  return (
    <ThemedView style={styles.container}>
      <ThemedText type="title" style={styles.title}>
        Sign up
      </ThemedText>
      <ThemedText style={styles.label}>Enter email address</ThemedText>
      <TextInput
        style={styles.input}
        autoCapitalize="none"
        value={emailAddress}
        placeholder="Enter email"
        placeholderTextColor="#666666"
        onChangeText={(emailAddress) => setEmailAddress(emailAddress)}
        keyboardType="email-address"
      />
      {errors.fields.emailAddress && (
        <ThemedText style={styles.error}>{errors.fields.emailAddress.message}</ThemedText>
      )}
      <ThemedText style={styles.label}>Enter password</ThemedText>
      <TextInput
        style={styles.input}
        value={password}
        placeholder="Enter password"
        placeholderTextColor="#666666"
        secureTextEntry
        onChangeText={(password) => setPassword(password)}
      />
      {errors.fields.password && (
        <ThemedText style={styles.error}>{errors.fields.password.message}</ThemedText>
      )}
      <Pressable style={styles.legalRow} onPress={() => setLegalAccepted((v) => !v)}>
        <View style={[styles.checkbox, legalAccepted && styles.checkboxChecked]}>
          {legalAccepted && <ThemedText style={styles.checkmark}>✓</ThemedText>}
        </View>
        <View style={styles.legalLabelRow}>
          <ThemedText style={styles.legalLabel}>I accept the </ThemedText>
          <Link href={'/terms' as Href}>
            <ThemedText type="link">Terms of Service</ThemedText>
          </Link>
          <ThemedText style={styles.legalLabel}> and </ThemedText>
          <Link href={'/privacy' as Href}>
            <ThemedText type="link">Privacy Policy</ThemedText>
          </Link>
        </View>
      </Pressable>
      <Pressable
        style={({ pressed }) => [
          styles.button,
          fetchStatus === 'fetching' && styles.buttonDisabled,
          pressed && styles.buttonPressed,
        ]}
        onPress={handleSubmit}
        disabled={fetchStatus === 'fetching'}
      >
        <ThemedText style={styles.buttonText}>Continue</ThemedText>
      </Pressable>
      {errors && <ThemedText style={styles.debug}>{JSON.stringify(errors, null, 2)}</ThemedText>}

      <View nativeID="clerk-captcha" />
    </ThemedView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    padding: 20,
    gap: 12,
  },
  title: {
    marginBottom: 8,
  },
  label: {
    fontWeight: '600',
    fontSize: 14,
  },
  input: {
    borderWidth: 1,
    borderColor: '#ccc',
    borderRadius: 8,
    padding: 12,
    fontSize: 16,
    backgroundColor: '#fff',
  },
  button: {
    backgroundColor: '#0a7ea4',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    marginTop: 8,
  },
  buttonPressed: {
    opacity: 0.7,
  },
  buttonDisabled: {
    opacity: 0.5,
  },
  buttonText: {
    color: '#fff',
    fontWeight: '600',
  },
  secondaryButton: {
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    marginTop: 8,
  },
  secondaryButtonText: {
    color: '#0a7ea4',
    fontWeight: '600',
  },
  legalRow: {
    flexDirection: 'row',
    alignItems: 'center',
    gap: 8,
    marginTop: 4,
  },
  checkbox: {
    width: 22,
    height: 22,
    borderWidth: 2,
    borderColor: '#687076',
    borderRadius: 4,
    alignItems: 'center',
    justifyContent: 'center',
  },
  checkboxChecked: {
    backgroundColor: '#0a7ea4',
    borderColor: '#0a7ea4',
  },
  checkmark: {
    color: '#fff',
    fontSize: 14,
    fontWeight: 'bold',
  },
  legalLabelRow: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    alignItems: 'center',
    flex: 1,
  },
  legalLabel: {
    fontSize: 14,
    lineHeight: 20,
  },
  error: {
    color: '#d32f2f',
    fontSize: 12,
    marginTop: -8,
  },
  debug: {
    fontSize: 10,
    opacity: 0.5,
    marginTop: 8,
  },
})
LegalAcceptanceSignUpView.swift
import SwiftUI
import ClerkKit

struct LegalAcceptanceSignUpView: View {
@Environment(Clerk.self) private var clerk
@State private var email = ""
@State private var password = ""
   @State private var legalAccepted = false

var body: some View {
  TextField("Enter email address", text: $email)
  SecureField("Enter password", text: $password)
     Toggle("I accept the Terms of Service and Privacy Policy", isOn: $legalAccepted)
  Button("Continue") {
    Task { await submit(email: email, password: password, legalAccepted: legalAccepted) }
  }
}
}

extension LegalAcceptanceSignUpView {

func submit(email: String, password: String, legalAccepted: Bool) async {
  do {
    // Include legal acceptance in the sign-up payload
    let signUp = try await clerk.auth.signUp(
      emailAddress: email,
      password: password,
         legalAccepted: legalAccepted
    )

    dump(signUp.status)
  } catch {
    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
    // for more info on error handling
    dump(error)
  }
}
}
LegalAcceptanceViewModel.kt
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.network.serialization.errorMessage
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.signup.SignUp
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch

class LegalAcceptanceViewModel : ViewModel() {
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState = _uiState.asStateFlow()

    init {
        combine(Clerk.isInitialized, Clerk.userFlow) { isInitialized, user ->
            _uiState.value = when {
                !isInitialized -> UiState.Loading
                user != null -> UiState.Authenticated
                else -> UiState.SignedOut
            }
        }.launchIn(viewModelScope)
    }

    fun signUp(email: String, password: String, legalAccepted: Boolean) {
        viewModelScope.launch {
            // Include legal acceptance in the sign-up payload.
            Clerk.auth.signUp {
                this.email = email
                this.password = password
                this.legalAccepted = legalAccepted
            }.onSuccess {
                if (it.status == SignUp.Status.COMPLETE) {
                    _uiState.value = UiState.Authenticated
                } else {
                    // If the status is not complete, check why. User may need to
                    // complete further steps, such as email verification.
                    Log.d("LegalAcceptance", "Sign-up status: ${it.status}")
                }
            }.onFailure {
                // See https://clerk.com/docs/guides/development/custom-flows/error-handling
                // for more info on error handling
                Log.e("LegalAcceptance", it.errorMessage, it.throwable)
            }
        }
    }

    sealed interface UiState {
        data object Loading : UiState

        data object SignedOut : UiState

        data object Authenticated : UiState
    }
}
LegalAcceptanceActivity.kt
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle

class LegalAcceptanceActivity : ComponentActivity() {
    private val viewModel: LegalAcceptanceViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            val state by viewModel.uiState.collectAsStateWithLifecycle()
            var email by remember { mutableStateOf("") }
            var password by remember { mutableStateOf("") }
            var legalAccepted by remember { mutableStateOf(false) }

            Column(
                modifier = Modifier.fillMaxSize().padding(24.dp),
                verticalArrangement = Arrangement.Center,
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                when (state) {
                    LegalAcceptanceViewModel.UiState.Authenticated -> Text("Authenticated")
                    LegalAcceptanceViewModel.UiState.Loading -> CircularProgressIndicator()
                    LegalAcceptanceViewModel.UiState.SignedOut -> {
                        TextField(
                            value = email,
                            onValueChange = { email = it },
                            label = { Text("Email") },
                            modifier = Modifier.fillMaxWidth()
                        )
                        TextField(
                            value = password,
                            onValueChange = { password = it },
                            label = { Text("Password") },
                            visualTransformation = PasswordVisualTransformation(),
                            modifier = Modifier.fillMaxWidth().padding(top = 12.dp)
                        )
                        Row(
                            modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
                            verticalAlignment = Alignment.CenterVertically
                        ) {
                            Checkbox(
                                checked = legalAccepted,
                                onCheckedChange = { legalAccepted = it }
                            )
                            Text("I accept the Terms of Service and Privacy Policy")
                        }
                        Button(
                            modifier = Modifier.padding(top = 12.dp),
                            enabled = email.isNotBlank() && password.isNotBlank() && legalAccepted,
                            onClick = { viewModel.signUp(email, password, legalAccepted) }
                        ) {
                            Text("Continue")
                        }
                    }
                }
            }
        }
    }
}

Feedback

What did you think of this content?

Last updated on