Skip to main content
Docs

Build a custom flow for adding a phone number to a user's account

Warning

This guide is for users who want to build a . To use a prebuilt UI, use the Account Portal pages or prebuilt components.

Users are able to add multiple phone numbers to their account. Adding a phone number requires the user to verify the phone number before it can be added to the user's account.

This guide demonstrates how to build a custom user interface that allows users to add and verify a phone number for their account.

Configure phone number verification

To use phone number verification, you first need to enable it for your application.

  1. In the Clerk Dashboard, navigate to the User & authentication page.
  2. Select the Phone tab and enable either Sign-up with phone or Sign-in with phone or both, depending on your application's needs.

Build the custom flow

Warning

Phone numbers must be in E.164 format.

Tip

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

app/account/add-phone/page.tsx
'use client'

import * as React from 'react'
import { useSession, useUser, useReverification } from '@clerk/nextjs'
import { PhoneNumberResource } from '@clerk/shared/types'

export default function Page() {
  const { isLoaded, isSignedIn, user } = useUser()
  const { session } = useSession()
  const createPhoneNumber = useReverification((phone: string) =>
    user?.createPhoneNumber({ phoneNumber: phone }),
  )

  const [phone, setPhone] = React.useState('')
  const [code, setCode] = React.useState('')
  const [isVerifying, setIsVerifying] = React.useState(false)
  const [successful, setSuccessful] = React.useState(false)
  const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()

  // Handle loading state
  if (!isLoaded) return <p>Loading...</p>

  // Handle signed-out state
  // If the user is trying to set up MFA, they should be able to access this page
  if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa')
    return <p>You must be signed in to access this page</p>

  // Handle addition of the phone number
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    try {
      // Add unverified phone number to user
      const res = await createPhoneNumber(phone)
      // Reload user to get updated User object
      await user.reload()

      // Create a reference to the new phone number to use related methods
      const phoneNumber = user.phoneNumbers.find((a) => a.id === res?.id)
      setPhoneObj(phoneNumber)

      // Send the user an SMS with the verification code
      phoneNumber?.prepareVerification()

      // Set to true to display second form
      // and capture the code
      setIsVerifying(true)
    } catch (err) {
      // See https://clerk.com/docs/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(err, null, 2))
    }
  }

  // Handle the submission of the verification form
  const verifyCode = async (e: React.FormEvent) => {
    e.preventDefault()
    try {
      // Verify that the provided code matches the code sent to the user
      const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })

      if (phoneVerifyAttempt?.verification.status === 'verified') {
        setSuccessful(true)
      } else {
        // If the status is not complete, check why. User may need to
        // complete further steps.
        console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
      }
    } catch (err) {
      console.error(JSON.stringify(err, null, 2))
    }
  }

  // Display a success message if the phone number was added successfully
  if (successful) return <h1>Phone added</h1>

  // Display the verification form to capture the code
  if (isVerifying) {
    return (
      <>
        <h1>Verify phone</h1>
        <div>
          <form onSubmit={(e) => verifyCode(e)}>
            <div>
              <label htmlFor="code">Enter code</label>
              <input
                onChange={(e) => setCode(e.target.value)}
                id="code"
                name="code"
                type="text"
                value={code}
              />
            </div>
            <div>
              <button type="submit">Verify</button>
            </div>
          </form>
        </div>
      </>
    )
  }

  // Display the initial form to capture the phone number
  return (
    <>
      <h1>Add phone</h1>
      <div>
        <form onSubmit={(e) => handleSubmit(e)}>
          <div>
            <label htmlFor="phone">Enter phone number</label>
            <input
              onChange={(e) => setPhone(e.target.value)}
              id="phone"
              name="phone"
              type="phone"
              value={phone}
            />
          </div>
          <div>
            <button type="submit">Continue</button>
          </div>
        </form>
      </div>
    </>
  )
}
app/(account)/add-phone.tsx
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { useSession, useUser } from '@clerk/expo'
import { PhoneNumberResource } from '@clerk/shared/types'
import { Redirect } from 'expo-router'
import * as React from 'react'
import { Pressable, StyleSheet, TextInput } from 'react-native'

export default function Page() {
  const { isLoaded, isSignedIn, user } = useUser()
  const { session } = useSession()

  const [phone, setPhone] = React.useState('')
  const [code, setCode] = React.useState('')
  const [isVerifying, setIsVerifying] = React.useState(false)
  const [successful, setSuccessful] = React.useState(false)
  const [phoneObj, setPhoneObj] = React.useState<PhoneNumberResource | undefined>()

  // Handle loading state
  if (!isLoaded) {
    return (
      <ThemedView style={styles.container}>
        <ThemedText>Loading...</ThemedText>
      </ThemedView>
    )
  }

  // Handle signed-out state
  // If the user is trying to set up MFA, they should be able to access this page
  if (!isSignedIn && session?.currentTask?.key !== 'setup-mfa') {
    return <Redirect href="/sign-in" />
  }

  // Handle addition of the phone number
  const handleSubmit = async () => {
    try {
      // Add unverified phone number to user
      const res = await user?.createPhoneNumber({ phoneNumber: phone })
      // Reload user to get updated User object
      await user?.reload()

      // Create a reference to the new phone number to use related methods
      const phoneNumber = user?.phoneNumbers.find((a) => a.id === res?.id)
      setPhoneObj(phoneNumber)

      // Send the user an SMS with the verification code
      await phoneNumber?.prepareVerification()

      // Set to true to display second form
      // and capture the code
      setIsVerifying(true)
    } catch (err) {
      // See https://clerk.com/docs/guides/development/custom-flows/error-handling
      // for more info on error handling
      console.error(JSON.stringify(err, null, 2))
    }
  }

  // Handle the submission of the verification form
  const verifyCode = async () => {
    try {
      // Verify that the provided code matches the code sent to the user
      const phoneVerifyAttempt = await phoneObj?.attemptVerification({ code })

      if (phoneVerifyAttempt?.verification.status === 'verified') {
        setSuccessful(true)
      } else {
        // If the status is not complete, check why. User may need to
        // complete further steps.
        console.error(JSON.stringify(phoneVerifyAttempt, null, 2))
      }
    } catch (err) {
      console.error(JSON.stringify(err, null, 2))
    }
  }

  // Display a success message if the phone number was added successfully
  if (successful) {
    return (
      <ThemedView style={styles.container}>
        <ThemedText type="title" style={styles.title}>
          Phone added
        </ThemedText>
      </ThemedView>
    )
  }

  // Display the verification form to capture the code
  if (isVerifying) {
    return (
      <ThemedView style={styles.container}>
        <ThemedText type="title" style={styles.title}>
          Verify phone
        </ThemedText>
        <ThemedText style={styles.label}>Enter code</ThemedText>
        <TextInput
          style={styles.input}
          value={code}
          placeholder="Enter code"
          placeholderTextColor="#666666"
          onChangeText={setCode}
          keyboardType="numeric"
        />
        <Pressable
          style={({ pressed }) => [
            styles.button,
            !code && styles.buttonDisabled,
            pressed && styles.buttonPressed,
          ]}
          onPress={verifyCode}
          disabled={!code}
        >
          <ThemedText style={styles.buttonText}>Verify</ThemedText>
        </Pressable>
      </ThemedView>
    )
  }

  // Display the initial form to capture the phone number
  return (
    <ThemedView style={styles.container}>
      <ThemedText type="title" style={styles.title}>
        Add phone
      </ThemedText>
      <ThemedText style={styles.label}>Enter phone number</ThemedText>
      <TextInput
        style={styles.input}
        value={phone}
        placeholder="e.g +1234567890"
        placeholderTextColor="#666666"
        onChangeText={setPhone}
        keyboardType="phone-pad"
      />
      <Pressable
        style={({ pressed }) => [
          styles.button,
          !phone && styles.buttonDisabled,
          pressed && styles.buttonPressed,
        ]}
        onPress={handleSubmit}
        disabled={!phone}
      >
        <ThemedText style={styles.buttonText}>Continue</ThemedText>
      </Pressable>
    </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',
  },
})
AddPhoneView.swift
  import SwiftUI
  import ClerkKit

  struct AddPhoneView: View {
    @State private var phone = ""
    @State private var code = ""
    @State private var isVerifying = false
    @State private var newPhoneNumber: PhoneNumber?

    var body: some View {
      if newPhoneNumber?.verification?.status == .verified {
        Text("Phone added!")
      }

      if isVerifying {
        TextField("Enter code", text: $code)
        Button("Verify") {
          Task { await verifyCode(code) }
        }
      } else {
        TextField("Enter phone number", text: $phone)
        Button("Continue") {
          Task { await createPhone(phone) }
        }
      }
    }
  }

  extension AddPhoneView {

    func createPhone(_ phone: String) async {
      do {
        guard let user = Clerk.shared.user else { return }

        // Create the phone number
        let phoneNumber = try await user.createPhoneNumber(phone)

        // Send the user an SMS with the verification code
        self.newPhoneNumber = try await phoneNumber.sendCode()

        isVerifying = true
      } catch {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling
        dump(error)
      }
    }

    func verifyCode(_ code: String) async {
      do {
        guard let newPhoneNumber else { return }

        // Verify that the provided code matches the code sent to the user
        self.newPhoneNumber = try await newPhoneNumber.verifyCode(code)

        dump(self.newPhoneNumber?.verification?.status)
      } catch {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling
        dump(error)
      }
    }
  }
AddPhoneViewModel.kt
package com.clerk.customflows.addphone

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.flatMap
import com.clerk.api.network.serialization.onFailure
import com.clerk.api.network.serialization.onSuccess
import com.clerk.api.phonenumber.PhoneNumber
import com.clerk.api.phonenumber.attemptVerification
import com.clerk.api.phonenumber.prepareVerification
import com.clerk.api.user.createPhoneNumber
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 AddPhoneViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.NeedsVerification)
val uiState = _uiState.asStateFlow()

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

fun createPhoneNumber(phoneNumber: String) {
  val user = requireNotNull(Clerk.userFlow.value)

  // Add an unverified phone number to the user,
  // then send the user an SMS with the verification code
  viewModelScope.launch {
    user
      .createPhoneNumber(phoneNumber)
      .flatMap { it.prepareVerification() }
      .onSuccess {
        // Update the state to show that the phone number has been created
        // and that the user needs to verify the phone number
        _uiState.value = UiState.Verifying(it)
      }
      .onFailure {
        Log.e(
          "AddPhoneViewModel",
          "Failed to create phone number and prepare verification: ${it.errorMessage}",
        )
      }
  }
}

fun verifyCode(code: String, newPhoneNumber: PhoneNumber) {
  viewModelScope.launch {
    newPhoneNumber
      .attemptVerification(code)
      .onSuccess {
        // Update the state to show that the phone number has been verified
        _uiState.value = UiState.Verified
      }
      .onFailure {
        Log.e("AddPhoneViewModel", "Failed to verify phone number: ${it.errorMessage}")
      }
  }
}

sealed interface UiState {
  data object Loading : UiState

  data object NeedsVerification : UiState

  data class Verifying(val phoneNumber: PhoneNumber) : UiState

  data object Verified : UiState

  data object SignedOut : UiState
}
}
AddPhoneActivity.kt
package com.clerk.customflows.addphone

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.phonenumber.PhoneNumber

class AddPhoneActivity : ComponentActivity() {
val viewModel: AddPhoneViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    AddPhoneView(
      state = state,
      onCreatePhoneNumber = viewModel::createPhoneNumber,
      onVerifyCode = viewModel::verifyCode,
    )
  }
}
}

@Composable
fun AddPhoneView(
state: AddPhoneViewModel.UiState,
onCreatePhoneNumber: (String) -> Unit,
onVerifyCode: (String, PhoneNumber) -> Unit,
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
  when (state) {
    AddPhoneViewModel.UiState.NeedsVerification -> {
      InputContentView(buttonText = "Continue", placeholder = "Enter phone number") {
        onCreatePhoneNumber(it)
      }
    }

    AddPhoneViewModel.UiState.Verified -> Text("Verified!")

    is AddPhoneViewModel.UiState.Verifying -> {
      InputContentView(buttonText = "Verify", placeholder = "Enter code") {
        onVerifyCode(it, state.phoneNumber)
      }
    }

    AddPhoneViewModel.UiState.Loading -> CircularProgressIndicator()
    AddPhoneViewModel.UiState.SignedOut -> Text("You must be signed in to add a phone number.")
  }
}
}

@Composable
fun InputContentView(
buttonText: String,
placeholder: String,
modifier: Modifier = Modifier,
onClick: (String) -> Unit,
) {
var input by remember { mutableStateOf("") }
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier) {
  TextField(
    modifier = Modifier.padding(bottom = 16.dp),
    value = input,
    onValueChange = { input = it },
    placeholder = { Text(placeholder) },
  )
  Button(onClick = { onClick(input) }) { Text(buttonText) }
}
}

Feedback

What did you think of this content?

Last updated on