Skip to main content
Docs

Build a custom flow for authenticating with OAuth connections

Warning

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

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.

Before you start

You must configure your application instance through the Clerk Dashboard for the social connection(s) that you want to use. Visit the appropriate guide for your platform to learn how to configure your instance.

Build the custom flow

Tip

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

First, in your .env file, set the CLERK_SIGN_IN_URL environment variable to tell Clerk where the sign-in page is being hosted. Otherwise, your app may default to using the Account Portal sign-in page instead. This guide uses the /sign-in route.

.env
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in

The following example will both sign up and sign in users, eliminating the need for a separate sign-up page. However, if you want to have separate sign-up and sign-in pages, the sign-up and sign-in flows are equivalent, meaning that all you have to do is swap out the SignIn object for the SignUp object using the useSignUp() hook.

The following example:

  1. Accesses the SignIn object using the useSignIn() hook.
  2. Starts the authentication process by calling SignIn.sso(params). This method requires the following params:
    • redirectUrl: The URL that the browser will be redirected to once the user authenticates with the identity provider if no additional requirements are needed, and a session has been created.
    • redirectCallbackUrl: The URL that the browser will be redirected to once the user authenticates with the identity provider if additional requirements are needed.
  3. Creates a route at the URL that the redirectCallbackUrl param points to.
app/sign-in/page.tsx
'use client'

import { OAuthStrategy } from '@clerk/shared/types'
import { useSignIn } from '@clerk/nextjs'

export default function Page() {
  const { signIn, errors } = useSignIn()

  const signInWith = async (strategy: OAuthStrategy) => {
    const { error } = await signIn.sso({
      strategy,
      redirectCallbackUrl: '/sso-callback',
      redirectUrl: '/sign-in/tasks', // Learn more about session tasks at https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
    })
    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 (signIn.status === 'needs_second_factor') {
      // See https://clerk.com/docs/guides/development/custom-flows/authentication/multi-factor-authentication
    } else if (signIn.status === 'needs_client_trust') {
      // See https://clerk.com/docs/guides/development/custom-flows/authentication/client-trust
    } else {
      // Check why the sign-in is not complete
      console.error('Sign-in attempt not complete:', signIn)
    }
  }

  // Render a button for each supported OAuth provider
  // you want to add to your app. This example uses only Google.
  return (
    <>
      <button onClick={() => signInWith('oauth_google')}>Sign in with Google</button>
      {/* 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>}
    </>
  )
}
app/sso-callback/page.tsx
'use client'

import { useClerk, useSignIn, useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'
import { useEffect, useRef } from 'react'

export default function Page() {
  const clerk = useClerk()
  const { signIn } = useSignIn()
  const { signUp } = useSignUp()
  const router = useRouter()
  const hasRun = useRef(false)

  const navigateToSignIn = () => {
    router.push('/sign-in')
  }

  const finalizeSignIn = async () => {
    await signIn.finalize({
      navigate: async ({ session, decorateUrl }) => {
        // Handle session tasks
        // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
        if (session?.currentTask) {
          console.log(session?.currentTask)
          return
        }

        // If no session tasks, navigate the signed-in user to the home page
        const url = decorateUrl('/')
        if (url.startsWith('http')) {
          window.location.href = url
        } else {
          router.push(url)
        }
      },
    })
  }

  const finalizeSignUp = async () => {
    await signUp.finalize({
      navigate: async ({ session, decorateUrl }) => {
        // Handle session tasks
        // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
        if (session?.currentTask) {
          console.log(session?.currentTask)
          return
        }

        // If no session tasks, navigate the signed-in user to the home page
        const url = decorateUrl('/')
        if (url.startsWith('http')) {
          window.location.href = url
        } else {
          router.push(url)
        }
      },
    })
  }

  useEffect(() => {
    ;(async () => {
      if (!clerk.loaded || hasRun.current) {
        return
      }
      // Prevent Next.js from re-running this effect when the page is re-rendered during session activation.
      hasRun.current = true

      // If this was a sign-in, and it's complete, there's nothing else to do.
      if (signIn.status === 'complete') {
        await finalizeSignIn()
        return
      }

      // If the sign-up used an existing account, transfer it to a sign-in
      if (signUp.isTransferable) {
        await signIn.create({ transfer: true })
        const signInStatus = signIn.status as typeof signIn.status | 'complete'
        if (signInStatus === 'complete') {
          await finalizeSignIn()
          return
        }

        // If sign-in is not complete, additional information is needed
        // For this example, we'll navigate back to the sign-in page assuming that it handles these cases
        return navigateToSignIn()
      }

      if (
        signIn.status === 'needs_first_factor' &&
        !signIn.supportedFirstFactors?.every((f) => f.strategy === 'enterprise_sso')
      ) {
        // The sign-in requires the use of a configured first factor, so navigate to the sign-in page
        return navigateToSignIn()
      }

      // If the sign-in used an external account not associated with an existing user, create a sign-up
      if (signIn.isTransferable) {
        await signUp.create({ transfer: true })
        if (signUp.status === 'complete') {
          await finalizeSignUp()
          return
        }

        // If sign-up is not complete, additional information is needed
        // See https://clerk.com/docs/guides/development/custom-flows/authentication/oauth-connections#handle-missing-requirements
        return router.push('/sign-in/continue')
      }

      // If sign-up is complete, finalize it
      if (signUp.status === 'complete') {
        await finalizeSignUp()
        return
      }

      // If the sign-in requires MFA or a new password
      // For this example, we'll navigate back to the sign-in page assuming that it handles these cases
      if (signIn.status === 'needs_second_factor' || signIn.status === 'needs_new_password') {
        return navigateToSignIn()
      }

      // The external account used to sign-in or sign-up was already associated with an existing user and active
      // session on this client, so activate the session and navigate to the application.
      if (signIn.existingSession || signUp.existingSession) {
        const sessionId = signIn.existingSession?.sessionId || signUp.existingSession?.sessionId
        if (sessionId) {
          // Because we're activating a session that's not the result of a sign-in or sign-up, we need to use the
          // Clerk `setActive` API instead of the `finalize` API.
          await clerk.setActive({
            session: sessionId,
            navigate: async ({ session, decorateUrl }) => {
              // Handle session tasks
              // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
              if (session?.currentTask) {
                console.log(session?.currentTask)
                return
              }

              // If no session tasks, navigate the signed-in user to the home page
              const url = decorateUrl('/')
              if (url.startsWith('http')) {
                window.location.href = url
              } else {
                router.push(url)
              }
            },
          })
          return
        }
      }
    })()
  }, [clerk, signIn, signUp])

  return (
    <div>
      {/* Because a sign-in transferred to a sign-up might require captcha verification, make sure to render the
captcha element. */}
      <div id="clerk-captcha"></div>
    </div>
  )
}

The following example will both sign up and sign in users, eliminating the need for a separate sign-up page.

The following example:

  1. Uses the useSSO()Expo Icon hook to access the startSSOFlow() method.
  2. Calls the startSSOFlow() method and passes 'oauth_google' and 'oauth_github' as the strategy param, but you can use any of the supported OAuth strategies. The optional redirect_url param is also set in order to redirect the user once they finish the authentication flow. The redirect URL must be registered in the Clerk Dashboard under Redirect URLs. Without this, the OAuth provider will complete authentication but the session will not be created.
    • If authentication is successful, the setActive() method is called to set the active session with the new createdSessionId.
    • If authentication is not successful, you can handle the missing requirements, such as MFA, using the signIn or signUp object returned from startSSOFlow(), depending on if the user is signing in or signing up. These objects include properties, like status, that can be used to determine the next steps. See the respective linked references for more information.
app/(auth)/sign-in.tsx
import * as React from 'react'
import * as AuthSession from 'expo-auth-session'
import * as WebBrowser from 'expo-web-browser'
import { useSSO } from '@clerk/expo'
import { ThemedText } from '@/components/themed-text'
import { ThemedView } from '@/components/themed-view'
import { type Href, useRouter } from 'expo-router'
import { Platform, Pressable, StyleSheet, View } from 'react-native'
import { OAuthStrategy } from '@clerk/types'

// Preloads the browser for Android devices to reduce authentication load time
// See: https://docs.expo.dev/guides/authentication/#improving-user-experience
export const useWarmUpBrowser = () => {
  React.useEffect(() => {
    if (Platform.OS !== 'android') return
    void WebBrowser.warmUpAsync()
    return () => {
      void WebBrowser.coolDownAsync()
    }
  }, [])
}

WebBrowser.maybeCompleteAuthSession()

export default function Page() {
  useWarmUpBrowser()

  const { startSSOFlow } = useSSO()
  const router = useRouter()
  const [submittingStrategy, setSubmittingStrategy] = React.useState<OAuthStrategy | null>(null)

  const onPress = async (oauthStrategy: OAuthStrategy) => {
    setSubmittingStrategy(oauthStrategy)
    try {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: oauthStrategy,
        // For web, defaults to current path
        // For native, you must pass a scheme, like AuthSession.makeRedirectUri({ scheme, path })
        // For more info, see https://docs.expo.dev/versions/latest/sdk/auth-session/#authsessionmakeredirecturioptions
        redirectUrl: AuthSession.makeRedirectUri({
          scheme: 'clerkexpoquickstart',
          path: '/continue',
        }),
      })

      // If the session was created, set it as the active session
      if (createdSessionId) {
        setActive!({
          session: createdSessionId,
          navigate: async ({ session, decorateUrl }) => {
            // Handle session tasks
            // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
            if (session?.currentTask) {
              console.log(session?.currentTask)
              return
            }

            // If no session tasks, navigate the signed-in user to the home page
            const url = decorateUrl('/')
            if (url.startsWith('http')) {
              window.location.href = url
            } else {
              router.push(url as Href)
            }
          },
        })
      } else {
        // If the session was not created, navigate to the continue page to collect missing information
        router.push('/continue')
      }
    } catch (err) {
      console.error(JSON.stringify(err, null, 2))
    } finally {
      setSubmittingStrategy(null)
    }
  }

  const providers = [
    { strategy: 'oauth_google', name: 'Google' },
    { strategy: 'oauth_github', name: 'GitHub' },
  ]

  return (
    <ThemedView style={styles.container}>
      <ThemedText type="title" style={styles.title}>
        Sign in
      </ThemedText>
      <ThemedText style={styles.subtitle}>Choose a provider to continue</ThemedText>
      <View style={styles.buttonContainer}>
        {providers.map((provider) => {
          const strategy = provider.strategy as OAuthStrategy
          const isThisProviderLoading = submittingStrategy === strategy
          const isAnyLoading = submittingStrategy !== null

          return (
            <Pressable
              key={provider.strategy}
              style={({ pressed }) => [
                styles.button,
                isAnyLoading && !isThisProviderLoading && styles.buttonDisabled,
                pressed && styles.buttonPressed,
              ]}
              onPress={() => onPress(strategy)}
              disabled={isAnyLoading}
            >
              <ThemedText style={styles.buttonText}>
                {isThisProviderLoading ? 'Opening…' : `Sign in with ${provider.name}`}
              </ThemedText>
            </Pressable>
          )
        })}
      </View>
    </ThemedView>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    paddingHorizontal: 20,
    paddingVertical: 24,
  },
  title: {
    marginBottom: 4,
  },
  subtitle: {
    fontSize: 14,
    marginBottom: 8,
    opacity: 0.85,
  },
  buttonContainer: {
    gap: 12,
  },
  button: {
    backgroundColor: '#0a7ea4',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
    alignItems: 'center',
    alignSelf: 'stretch',
  },
  buttonPressed: {
    opacity: 0.7,
  },
  buttonDisabled: {
    opacity: 0.5,
  },
  buttonText: {
    color: '#fff',
    fontWeight: '600',
  },
  linkContainer: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 4,
    marginTop: 12,
    alignItems: 'center',
  },
})

Sign in using an OAuth provider (e.g., Google, GitHub, see all providers):

OAuthView.swift
  import SwiftUI
  import ClerkKit

  struct OAuthView: View {
    @Environment(Clerk.self) private var clerk

    var body: some View {
      // Render a button for each supported OAuth provider
      // you want to add to your app. This example uses Google.
      Button("Sign In with Google") {
        Task { await signInWithOAuth(provider: .google) }
      }
    }
  }

  extension OAuthView {

    func signInWithOAuth(provider: OAuthProvider) async {
      do {
        // Start the sign-in process using the selected OAuth provider.
        let result = try await clerk.auth.signInWithOAuth(provider: provider)

        // It is common for users who are authenticating with OAuth to use
        // a sign-in button when they mean to sign-up, and vice versa.
        // Clerk will handle this transfer for you if possible.
        // Therefore, a TransferFlowResult can be either a SignIn or SignUp.
        switch result {
        case .signIn(let signIn):
          switch signIn.status {
          case .complete:
            // If sign-in process is complete, navigate the user as needed.
            dump(clerk.session)
          default:
            // If the status is not complete, check why. User may need to
            // complete further steps.
            dump(signIn.status)
          }
        case .signUp(let signUp):
          switch signUp.status {
          case .complete:
            // If sign-up process is complete, navigate the user as needed.
            dump(clerk.session)
          default:
            // If the status is not complete, check why. User may need to
            // complete further steps.
            dump(signUp.status)
          }
        }
      } catch {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling.
        dump(error)
      }
    }
  }
OAuthViewModel.kt
package com.clerk.customflows.oauth

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.clerk.api.Clerk
import com.clerk.api.log.ClerkLog
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.signin.SignIn
import com.clerk.api.signup.SignUp
import com.clerk.api.sso.OAuthProvider
import com.clerk.api.sso.ResultType
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 OAuthViewModel : 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 signInWithOAuth(provider: OAuthProvider) {
  viewModelScope.launch {
    Clerk.auth
      .signInWithOAuth(provider)
      .onSuccess {
        when (it.resultType) {
          ResultType.SIGN_IN -> {
            // The OAuth flow resulted in a sign in
            if (it.signIn?.status == SignIn.Status.COMPLETE) {
              _uiState.value = UiState.Authenticated
            } else {
              // If the status is not complete, check why. User may need to
              // complete further steps.
            }
          }
          ResultType.SIGN_UP -> {
            // The OAuth flow resulted in a sign up
            if (it.signUp?.status == SignUp.Status.COMPLETE) {
              _uiState.value = UiState.Authenticated
            } else {
              // If the status is not complete, check why. User may need to
              // complete further steps.
            }
          }

          ResultType.UNKNOWN -> {
            ClerkLog.e("Unknown result type after OAuth redirect")
          }
        }
      }
      .onFailure {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling
        Log.e("OAuthViewModel", it.errorMessage, it.throwable)
      }
  }
}

sealed interface UiState {
  data object Loading : UiState

  data object SignedOut : UiState

  data object Authenticated : UiState
}
}
OAuthActivity.kt
package com.clerk.customflows.oauth

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.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.clerk.api.sso.OAuthProvider

class OAuthActivity : ComponentActivity() {
val viewModel: OAuthViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
      when (state) {
        OAuthViewModel.UiState.Authenticated -> Text("Authenticated")
        OAuthViewModel.UiState.Loading -> CircularProgressIndicator()
        OAuthViewModel.UiState.SignedOut -> {
          val provider = OAuthProvider.GOOGLE // Or .GITHUB, .SLACK etc.
          Button(onClick = { viewModel.signInWithOAuth(provider) }) {
            Text("Sign in with ${provider.name}")
          }
        }
      }
    }
  }
}
}

Handle missing requirements

Depending on your instance settings, users might need to provide extra information before their sign-up can be completed, such as when a first and last name or accepting legal terms is required. In these cases, the SignUp object returns a status of "missing_requirements" along with a missingFields array. You can use this information to build a UI that handles the missing requirements. If the missing fields are first and last name or legal acceptance, you can pass them to the signUp.update() method. If the missing fields are identifiers, see the appropriate custom flow guide. For example, if your app's settings require a phone number, see the phone OTP custom flow guide to handle collecting and verifying the phone number.

Quiz

Why does the "Continue" page use the useSignUp() hook? What if a user is using this flow to sign in?

Tip

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

app/sign-in/continue/page.tsx
'use client'

import { useSignUp } from '@clerk/nextjs'
import { useRouter } from 'next/navigation'

export default function Page() {
  const router = useRouter()
  // Use `useSignUp()` hook to access the `SignUp` object
  // `missing_requirements` and `missingFields` are only available on the `SignUp` object
  const { signUp } = useSignUp()

  const handleSubmit = async (formData: FormData) => {
    const firstName = formData.get('firstName') as string
    const lastName = formData.get('lastName') as string

    // Update the `SignUp` object with the missing fields
    // This example collects first and last name and passes it to SignUp.update() but you can modify this example for whatever settings you have enabled in the Clerk Dashboard
    await signUp.update({ firstName, lastName })

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: async ({ session, decorateUrl }) => {
          // Handle session tasks
          // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
          if (session?.currentTask) {
            console.log(session?.currentTask)
            return
          }

          // If no session tasks, navigate the signed-in user to the home page
          const url = decorateUrl('/')
          if (url.startsWith('http')) {
            window.location.href = url
          } else {
            router.push(url)
          }
        },
      })
    } else if (signUp.status !== 'missing_requirements') {
      // Check why the sign-up is not complete
      console.error('Sign-up attempt not complete:', signUp.status)
    }
  }

  // If the sign-up is complete, the user shouldn't be on this page
  if (signUp.status === 'complete') {
    router.push('/')
  }

  return (
    <div>
      <h1>Continue sign-up</h1>
      <form action={handleSubmit}>
        <label htmlFor="firstName">First name</label>
        <input type="text" name="firstName" id="firstName" />
        <label htmlFor="lastName">Last name</label>
        <input type="text" name="lastName" id="lastName" />

        <button type="submit">Submit</button>
      </form>

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

export default function Page() {
  const router = useRouter()
  // Use `useSignUp()` hook to access the `SignUp` object
  // `missing_requirements` and `missingFields` are only available on the `SignUp` object
  const { signUp, errors: signUpErrors, fetchStatus: signUpFetchStatus } = useSignUp()
  const { signIn, errors: signInErrors, fetchStatus: signInFetchStatus } = useSignIn()
  const [firstName, setFirstName] = React.useState('')
  const [lastName, setLastName] = React.useState('')

  // If the sign-in or sign-up is complete, the user doesn't need to be on this page
  if (signIn.status === 'complete' || signUp.status === 'complete') {
    return router.push('/')
  }

  const handleSubmit = async () => {
    // Update the `SignUp` object with the missing fields
    // This example collects first and last name, but you can modify it for whatever settings you have enabled in the Clerk Dashboard
    await signUp.update({ firstName, lastName })

    if (signUp.status === 'complete') {
      await signUp.finalize({
        navigate: ({ session, decorateUrl }) => {
          // Handle session tasks
          // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
          if (session?.currentTask) {
            console.log(session?.currentTask)
            return
          }

          // If no session tasks, navigate the signed-in user to the home page
          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.status, signUp.missingFields)
    }
  }

  if (signUp.status !== 'missing_requirements') {
    // You can use this page to handle other statuses
    // This example only handles the missing_requirements status
    return null
  }

  const canSubmit =
    firstName.trim().length > 0 && lastName.trim().length > 0 && signUpFetchStatus !== 'fetching'

  return (
    <ThemedView style={styles.flex}>
      <ScrollView
        style={styles.scroll}
        contentContainerStyle={styles.container}
        keyboardShouldPersistTaps="handled"
      >
        <ThemedText type="title" style={styles.title}>
          Continue sign-up
        </ThemedText>
        <ThemedText style={styles.hint}>Add your name to finish creating your account.</ThemedText>

        <View style={styles.fieldBlock}>
          <ThemedText style={styles.label}>First name</ThemedText>
          <TextInput
            style={styles.input}
            value={firstName}
            placeholder="First name"
            placeholderTextColor="#666666"
            onChangeText={setFirstName}
            autoCapitalize="words"
            autoCorrect
          />
          {signUpErrors.fields?.firstName ? (
            <ThemedText style={styles.error}>{signUpErrors.fields.firstName.message}</ThemedText>
          ) : null}
        </View>

        <View style={styles.fieldBlock}>
          <ThemedText style={styles.label}>Last name</ThemedText>
          <TextInput
            style={styles.input}
            value={lastName}
            placeholder="Last name"
            placeholderTextColor="#666666"
            onChangeText={setLastName}
            autoCapitalize="words"
            autoCorrect
          />
          {signUpErrors.fields?.lastName ? (
            <ThemedText style={styles.error}>{signUpErrors.fields.lastName.message}</ThemedText>
          ) : null}
        </View>

        <Pressable
          style={({ pressed }) => [
            styles.button,
            !canSubmit && styles.buttonDisabled,
            pressed && styles.buttonPressed,
          ]}
          onPress={handleSubmit}
          disabled={!canSubmit}
        >
          <ThemedText style={styles.buttonText}>
            {signUpFetchStatus === 'fetching' ? 'Submitting…' : 'Continue'}
          </ThemedText>
        </Pressable>
        {/* For your debugging purposes. You can just console.log errors, but we put them in the UI for convenience */}
        {signUpErrors && (
          <ThemedText style={styles.error}>{JSON.stringify(signUpErrors, null, 2)}</ThemedText>
        )}

        {/*
          Web Next.js flows use <div id="clerk-captcha" /> for bot protection.
          On native, Clerk handles risk checks without that DOM mount; use Clerk docs if you need a WebView CAPTCHA.
        */}
      </ScrollView>
    </ThemedView>
  )
}

const styles = StyleSheet.create({
  flex: {
    flex: 1,
  },
  scroll: {
    flex: 1,
  },
  container: {
    flexGrow: 1,
    padding: 20,
    gap: 12,
  },
  title: {
    marginBottom: 4,
  },
  hint: {
    fontSize: 14,
    marginBottom: 8,
    opacity: 0.85,
  },
  fieldBlock: {
    gap: 6,
  },
  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',
  },
  linkContainer: {
    marginTop: 12,
    alignItems: 'flex-start',
  },
  error: {
    color: '#d32f2f',
    fontSize: 12,
    marginTop: 4,
  },
})
OAuthView.swift
  import SwiftUI
  import ClerkKit

  struct OAuthView: View {
    @Environment(Clerk.self) private var clerk

    @State private var username = ""

    var body: some View {
      VStack(spacing: 16) {
        // Render UI based on the current in-progress sign-up status.
        switch clerk.auth.currentSignUp?.status {
        case .missingRequirements:
          // If username is required, collect it and submit it to the SignUp object.
          if clerk.auth.currentSignUp?.missingFields.contains(.username) == true {
            TextField("Choose a username", text: $username)

            Button("Continue") {
              Task { await submitUsername() }
            }
          } else {
            // Other missing fields (phone, legalAccepted, etc.) should be handled here.
            Text("Additional steps required.")
          }

        default:
          // If user is signed in, show signed-in state.
          if let user = clerk.user {
            Text("Signed in as: \(user.id)")
            Button("Sign Out") {
              Task { try await clerk.auth.signOut() }
            }
          } else {
            // If user is signed out, start OAuth flow.
            Button("Sign In with Google") {
              Task { await signInWithOAuth(provider: .google) }
            }
          }
        }
      }
      .padding()
    }

    private func signInWithOAuth(provider: OAuthProvider) async {
      do {
        // Start OAuth sign-in with the selected provider.
        try await clerk.auth.signInWithOAuth(provider: provider)
        // UI will react to currentSignUp/current session changes:
        // - If OAuth completes immediately, `clerk.user` will be available.
        // - If additional fields are required, `currentSignUp.status` will be `.missingRequirements`.
      } catch {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling.
        dump(error)
      }
    }

    private func submitUsername() async {
      do {
        // Update the in-progress SignUp with the missing username field.
        try await clerk.auth.currentSignUp?.update(
          username: username.trimmingCharacters(in: .whitespacesAndNewlines)
        )
      } catch {
        // See https://clerk.com/docs/guides/development/custom-flows/error-handling
        // for more info on error handling.
        dump(error)
      }
    }
  }

After OAuth, if Clerk transferred the attempt to a sign-up with missing fields, read Clerk.auth.currentSignUp and check SignUp.status for MISSING_REQUIREMENTS. This example uses first and last name for the missing requirements, so pass these collected values through signUp.update() (SignUp.SignUpUpdateParams.Standard), then when the sign-up is COMPLETE, call Clerk.auth.setActive() with createdSessionId. Use missingFields to decide which inputs to show beyond this example.

OAuthContinueSignUpViewModel.kt
package com.clerk.customflows.oauth

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 com.clerk.api.signup.update
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 OAuthContinueSignUpViewModel : 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 -> {
              // `missing_requirements` and `missingFields` exist on the in-progress SignUp
              // (same object as `Clerk.auth.currentSignUp` in the Android SDK).
              when (Clerk.auth.currentSignUp?.status) {
                SignUp.Status.MISSING_REQUIREMENTS -> UiState.CollectMissingFields
                SignUp.Status.COMPLETE -> UiState.Authenticated
                else -> UiState.NotApplicable
              }
            }
          }
      }
      .launchIn(viewModelScope)
  }

  fun submitMissingFields(firstName: String, lastName: String) {
    val signUp = Clerk.auth.currentSignUp ?: return
    if (signUp.status != SignUp.Status.MISSING_REQUIREMENTS) return

    viewModelScope.launch {
      signUp
        .update(
          SignUp.SignUpUpdateParams.Standard(
            firstName = firstName.trim(),
            lastName = lastName.trim(),
          ),
        )
        .onSuccess { updated ->
          when (updated.status) {
            SignUp.Status.COMPLETE -> {
              val sessionId = updated.createdSessionId
              if (sessionId != null) {
                Clerk.auth
                  .setActive(sessionId = sessionId)
                  .onSuccess {
                    // If the session has pending tasks, handle them next.
                    // See https://clerk.com/docs/guides/development/custom-flows/authentication/session-tasks
                  }
                  .onFailure {
                    // See https://clerk.com/docs/guides/development/custom-flows/error-handling
                    Log.e("OAuthContinueSignUp", it.errorMessage, it.throwable)
                  }
              }
            }
            SignUp.Status.MISSING_REQUIREMENTS -> {
              Log.d(
                "OAuthContinueSignUp",
                "Still missing: ${updated.missingFields}",
              )
            }
            else -> {
              Log.e(
                "OAuthContinueSignUp",
                "Sign-up not complete: ${updated.status}",
              )
            }
          }
        }
        .onFailure {
          // See https://clerk.com/docs/guides/development/custom-flows/error-handling
          Log.e("OAuthContinueSignUp", it.errorMessage, it.throwable)
        }
    }
  }

  sealed interface UiState {
    data object Loading : UiState

    data object Authenticated : UiState

    /** Show this screen only while `currentSignUp.status == MISSING_REQUIREMENTS`. */
    data object CollectMissingFields : UiState

    /** No in-progress sign-up or status is not missing requirements—navigate elsewhere. */
    data object NotApplicable : UiState
  }
}
OAuthContinueSignUpActivity.kt
package com.clerk.customflows.oauth

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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
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

class OAuthContinueSignUpActivity : ComponentActivity() {
  private val viewModel: OAuthContinueSignUpViewModel by viewModels()

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContent {
      val uiState by viewModel.uiState.collectAsStateWithLifecycle()
      OAuthContinueSignUpScreen(
        uiState = uiState,
        onSubmit = viewModel::submitMissingFields,
      )
    }
  }
}

@Composable
private fun OAuthContinueSignUpScreen(
  uiState: OAuthContinueSignUpViewModel.UiState,
  onSubmit: (String, String) -> Unit,
) {
  var firstName by remember { mutableStateOf("") }
  var lastName by remember { mutableStateOf("") }

  Column(
    modifier =
      Modifier
        .fillMaxSize()
        .padding(24.dp),
    verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
    horizontalAlignment = Alignment.CenterHorizontally,
  ) {
    when (uiState) {
      OAuthContinueSignUpViewModel.UiState.Loading -> {
        CircularProgressIndicator()
      }
      OAuthContinueSignUpViewModel.UiState.Authenticated -> {
        Text("Signed in")
      }
      OAuthContinueSignUpViewModel.UiState.NotApplicable -> {
        Text("Nothing to continue.")
      }
      OAuthContinueSignUpViewModel.UiState.CollectMissingFields -> {
        Text("Continue sign-up")
        Text("Add your name to finish creating your account.")
        TextField(
          value = firstName,
          onValueChange = { firstName = it },
          modifier = Modifier.fillMaxWidth(),
          label = { Text("First name") },
          singleLine = true,
        )
        TextField(
          value = lastName,
          onValueChange = { lastName = it },
          modifier = Modifier.fillMaxWidth(),
          label = { Text("Last name") },
          singleLine = true,
        )
        val canSubmit = firstName.isNotBlank() && lastName.isNotBlank()
        Button(
          onClick = { onSubmit(firstName, lastName) },
          enabled = canSubmit,
        ) {
          Text("Continue")
        }
      }
    }
  }
}

Feedback

What did you think of this content?

Last updated on