
Add Clerk authentication to a React app with the Clerk CLI - Part 2
Part 2 of 2. Start with Add Clerk authentication to a React app with the Clerk CLI.
This is Part 2 of a two-part series on adding Clerk authentication to a React application using the Clerk CLI. Part 1 covered the CLI installation, scaffolding the Vite React app, and getting the dev server running. This part covers React SPA-specific auth patterns and managing the Clerk instance as code.
React-specific: protecting a route and understanding SPA auth considerations
React SPA + Clerk is a specific shape, and a few of its details only matter in this shape. This section covers them all in one place so you can decide up front whether a pure SPA is the right fit.
Gating UI with <Show>
In Clerk Core 3, the React SDK unifies every conditional render behind one component. The legacy <SignedIn> / <SignedOut> components were removed from @clerk/react v6 — the current primitive is <Show> with a typed when prop:
// src/App.tsx
import { Show, UserButton } from '@clerk/react'
function Dashboard() {
return (
<section>
<h2>Dashboard</h2>
<p>Signed-in-only content.</p>
</section>
)
}
export default function App() {
return (
<main>
<header>
<Show when="signed-in">
<UserButton />
</Show>
<Show when="signed-out">
<p>Sign in to see the dashboard.</p>
</Show>
</header>
<Show when="signed-in">
<Dashboard />
</Show>
</main>
)
}The when prop takes "signed-in", "signed-out", or an authorization object (role, permission, feature, plan) — one component for both authentication and authorization checks. <Show> is purely visual: it only controls what renders. Any data the signed-in UI depends on still needs to come from an API call that a backend has independently verified.
Route gating without a router
The CLI starter does not ship a router. You have two options:
- Conditional render only — wrap the component (or the whole page) in
<Show when="signed-in">as above. Fine for a single-page dashboard. - Add a router — install
react-router-dom(or TanStack Router), define routes, and gate them with<Show>or a custom guard. The pattern is the same either way: client-side guards are UX, not security.
For a custom guard, useAuth() from @clerk/react returns { isLoaded, isSignedIn, userId, getToken }. Wrap any route or component in a conditional that waits for isLoaded (to avoid rendering during the initial auth resolve) and then branches on isSignedIn:
import { useAuth } from '@clerk/react'
import { Navigate } from 'react-router-dom'
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { isLoaded, isSignedIn } = useAuth()
if (!isLoaded) return null
if (!isSignedIn) return <Navigate to="/sign-in" replace />
return <>{children}</>
}Use hooks when you need to run code conditionally (not just render), use <Show> when you only need to render conditionally.
Where the publishable key lives in your bundle
The publishable key ships to the client, and that is deliberate. It identifies your Clerk Frontend API to the browser and cannot be used to call the Backend API or modify user records (see How Clerk works for the underlying model). The authority surface of a publishable key is: tell Clerk "I am this instance, here is a session, please issue a JWT." That is the entire capability.
The secret key never ships to the client. In a pure SPA there is no place to use it safely — it belongs on a server. clerk env pull writes it to .env.local because your future backend will need it and keeping both keys in one file avoids drift. Until that backend exists, the secret key sits there unused.
OAuth redirect URLs on production deploys
In development, OAuth flows (Google, GitHub, etc.) use Clerk's shared OAuth credentials, which are pre-allowlisted for localhost. In production, each origin you deploy to — https://app.example.com, https://staging.example.com — must be allowlisted in your Clerk instance before first sign-in will complete. Missing this step is the most common "it worked locally, broken in prod" OAuth failure.
Allowlist production origins via the Clerk Dashboard (production deploy checklist, customize redirect URLs) or via the Backend API. If you enable your own OAuth providers (not Clerk's shared credentials), each provider also has its own authorized-redirect list inside that provider's console — Google, GitHub, and Apple all require the provider-side allowlist independently of Clerk's side. Plan both sides of that allowlist change into the same deploy.
When React SPA + Clerk is not enough
A pure client-rendered React app using Clerk is the right shape when:
- Your app is fully client-rendered and talks to APIs you already secure elsewhere.
- The sensitive data your app shows is gated by its own API's authorization, not by what the React tree renders.
- You do not need server-side session reads or middleware-based route protection.
If any of those do not hold — if you need server-side rendering, protected API routes on the same origin, or server-only secrets in request handlers — reach for Next.js or TanStack Start. Both ship dedicated Clerk SDKs (@clerk/nextjs v6, @clerk/tanstack-react-start v1) with server-side auth helpers, middleware, and SSR-aware providers. Switching framework later is annoying but possible; getting the shape right up front is cheaper.
If you have a backend (Express, Hono, FastAPI, Go, whatever), Clerk issues a short-lived JWT for each signed-in user that your SPA attaches to outbound requests as a Bearer token via useAuth().getToken(). Your backend must verify that token — signature, issuer, expiry, and any required claims — on every protected request using Clerk's server SDK or a JWKS lookup. The "How do I verify Clerk tokens on my backend?" question in the FAQ points at the dedicated guide; that workflow is out of scope for this article.
Configure Clerk as code: clerk config
The click-through dashboard is still there when you want it, but the CLI now exposes instance configuration as pull-able, patch-able JSON. Combined with git, that is the "config as code" workflow dashboards never supported: every sign-in option, passkey toggle, session policy, and attack-protection rule is a diff on a branch, reviewed and merged like any other change.
The core loop is four commands:
clerk config schema # what fields exist
clerk config pull --output config.before.json # snapshot the current state
clerk config patch --dry-run --json '{...}' # preview a change
clerk config patch --json '{...}' --yes # apply itLet's run four patches against the React instance. Start by snapshotting the baseline:
clerk config pull --output config.before.jsonPatch 1 — enable passkeys
Passkeys are WebAuthn-backed credentials (biometric or device-bound) that replace passwords. The correct schema field is used_for_sign_in:
clerk config patch --dry-run --json '{"auth_passkey":{"used_for_sign_in":true}}'
clerk config patch --json '{"auth_passkey":{"used_for_sign_in":true}}' --yesPatch 2 — add username as a sign-in method
Enable username-based sign-in and sign-up:
clerk config patch --dry-run --json '{"auth_username":{"used_for_sign_in":true,"used_for_sign_up":true}}'
clerk config patch --json '{"auth_username":{"used_for_sign_in":true,"used_for_sign_up":true}}' --yesEach identifier (email, phone, username, first name, last name) has the same two flags — used_for_sign_in and used_for_sign_up — which is how you mix and match the shape of your sign-in form as code.
Patch 3 — set session lifetime
Session policy lives under session. The correct schema field is lifetime (seconds), not inactivity_timeout_in_ms:
clerk config patch --dry-run --json '{"session":{"lifetime":7200}}'
clerk config patch --json '{"session":{"lifetime":7200}}' --yeslifetime: 7200 sets a two-hour session token lifetime. The Clerk frontend auto-refreshes tokens before expiry, so lifetime sets the outer bound, not the frequency of a full sign-in.
Patch 4 — harden attack protection
Block disposable-email providers and tighten the brute-force lockout threshold in one patch:
clerk config patch --dry-run --json '{"auth_access_control":{"block_disposable_email_domains":true},"auth_attack_protection":{"user_lockout":{"max_attempts":10}}}'
clerk config patch --json '{"auth_access_control":{"block_disposable_email_domains":true},"auth_attack_protection":{"user_lockout":{"max_attempts":10}}}' --yesOne JSON payload can touch unrelated subsystems; config patch merges into the existing configuration and only persists fields the schema recognizes.
Confirm the diff
Snapshot again and diff:
clerk config pull --output config.after.json
diff config.before.json config.after.jsonExpected output (abbreviated):
auth_access_control.block_disposable_email_domains: false → true
auth_attack_protection.user_lockout.max_attempts: 100 → 10
auth_passkey.used_for_sign_in: false → true
auth_username.used_for_sign_in: false → true
auth_username.used_for_sign_up: false → true
session: null → { allowed_clock_skew: 5, claims: {}, lifetime: 7200 }If your diff has a field listed in one of the patches but missing from the after snapshot, that field name is not in the current schema. Re-check clerk config schema and adjust.
Commit config.before.json and config.after.json (or their delta) to your repo. When the next engineer asks "when did we turn on passkeys?" the answer is in git log, not a dashboard's audit tab.
Verify the config changes worked
Reload the dev server (Ctrl-C, pnpm dev). Visit / signed in, open the <UserButton /> menu, and pick Manage account. The <UserProfile /> component now exposes a Passkeys section with a Register a passkey button — that is patch 1 taking effect. The sign-in page now accepts a username field for new sign-ups (patch 2). Your session tokens now expire after two hours (patch 3 — open devtools and inspect the __session cookie's exp claim to confirm).
If any of these do not appear, run clerk doctor and re-diff the before/after snapshots. Silent schema strips are the most common reason a patch looks like it applied but did not. See the warning in Patch 1 for the canonical example.
Inspect your instance with clerk api
clerk api is an HTTP client pre-authenticated to your linked instance. It covers both the Backend API (users, organizations, sessions, etc.) and the Platform API (accounts, applications, instances — for managing Clerk itself).
List available endpoints:
clerk api ls usersOutput: the endpoints under /users, with HTTP method and path. The same ls works against any resource:
clerk api ls organizations
clerk api ls sessions
clerk api ls sign-in-tokensFetch an actual resource:
clerk api /users
clerk api /users/<user_id>The first returns the user list as JSON (empty array until you sign up). The second returns one user record.
For the Platform API — the surface that manages your Clerk account itself rather than the users inside one instance — pass --platform:
clerk api --platform ls
clerk api --platform /platform/applicationsEvery clerk api call is a real API call against your real instance. Use --instance dev / --instance prod to target a specific instance if the app has both. Pipe the output through jq for quick filtering:
clerk api /users | jq '.[].email_addresses[0].email_address'CLI reference (quick skim)
The commands this article used, plus the handful you will reach for next:
Anything not listed here: run clerk <subcommand> --help or clerk --help for the full surface.
Conclusion
You have now explored React SPA-specific authentication patterns and learned how to manage your Clerk instance configuration as code using the CLI. By treating your authentication setup as an auditable pipeline, you ensure a more robust and team-friendly development workflow.
FAQ
In this series
- Add Clerk authentication to a React app with the Clerk CLI
- Add Clerk authentication to a React app with the Clerk CLI - Part 2 (you are here)