
Add Clerk authentication to a React app with the Clerk CLI
How do I add Clerk authentication to a React app with the Clerk CLI?
Run clerk init --starter --framework react to scaffold a Vite + React 19 single-page app with @clerk/react v6, <ClerkProvider> wired in main.tsx, <Show when="signed-in"> gating, and populated environment variables — one command, no dashboard round-trip. Then run clerk env pull to write keys, clerk doctor to verify the setup, and clerk config patch to enable passkeys, sign-in methods, session lifetime, and lockout policy as version-controllable JSON. The walkthrough below covers the full sequence end-to-end, signing up a real user, and inspecting the result with clerk api.
Not covered here: building your own backend to verify Clerk-issued tokens, React Router or TanStack Router integration (the CLI starter ships neither), and Next.js App Router patterns (the Next CLI article covers those). If you need server-side rendering or middleware-based route protection, Next.js or TanStack Start is the better starting point.
Why the Clerk CLI for React
React is the broadest surface Clerk ships for. The pre-CLI path — install @clerk/react, hand-wire <ClerkProvider> in main.tsx, paste keys from a dashboard tab into .env.local, and hope you got the provider placement right — worked, but it was fragile. Every tutorial had to re-explain the wiring, and AI coding agents couldn't reliably reproduce the setup without stepping out to a browser.
The CLI changes the answer. clerk init --starter --framework react scaffolds a Vite + React 19 project with @clerk/react v6, a wired <ClerkProvider>, <Show when="signed-in"> gating, and populated environment variables. One command. The same CLI also unlocks three things the dashboard-era tutorials never could:
clerk config patch— apply an instance configuration diff as reviewable JSON, with--dry-runand--yesflags so changes flow through code review, not a toggle.clerk api— call any Backend or Platform API endpoint with your authenticated context already resolved.clerk doctor— pre-flight your local setup and surface missing keys, drifted skill versions, or configuration mismatches.
Taken together, the CLI is the first Clerk surface that treats a React app setup as a scriptable, auditable pipeline. That is the shape AI coding agents need and the shape a serious team wants in CI. This guide assumes you have decided a client-rendered React SPA is the right shape for your product. If you are not sure — or you know you will need server-side sessions, middleware, or server-only secrets in route handlers — jump to the Next.js or TanStack Start entries in this cluster before you start.
Prerequisites
You need:
You do not need Git initialized, a deployment target, or OAuth provider credentials to follow this walkthrough. OAuth social providers work in development with Clerk's shared credentials; production OAuth requires your own credentials, which the "Pull environment variables" section flags.
Install or update the Clerk CLI
The Clerk CLI README advertises two install methods: a Homebrew tap for macOS and Linux, and an npm package that works anywhere Node.js is installed (including Windows). Pick one — whichever you pick, updates happen in-place with clerk update:
# macOS and Linux (Homebrew tap)
brew install clerk/stable/clerk# Any platform with Node.js 20+ (cross-platform, including Windows)
npm install -g clerkThe Homebrew tap lives at github.com/clerk/homebrew-stable and pulls the matching prebuilt binary from the CLI's GitHub releases. The npm package is published as clerk (not @clerk/cli) — a small wrapper that downloads the right native binary on install. Both paths land the same clerk executable on your PATH.
Verify the install and update if needed:
clerk --version
clerk update --yesAnything older than the 2026-04-22 release lacks clerk init, clerk config, clerk skill, and the --mode agent flag. clerk update --yes upgrades to the latest stable; clerk update --channel canary opts into pre-release builds if you want to track fixes between stable releases.
Install the Clerk agent skill and update existing Clerk skills
If you are using an AI coding agent (Claude Code, Codex, Aider, Gemini CLI, or similar), install the Clerk skill bundle once per machine:
clerk skill install -yThis installs Clerk-maintained agent skills into your global agent config (for Claude Code, ~/.claude/skills/). The bundle covers framework-specific guidance for React, Next.js, TanStack Start, Expo, Astro, and others, plus shared skills like clerk-setup, clerk-webhooks, and clerk-custom-ui. The agent loads the right skill by topic, so you do not have to remember names.
If you have hand-authored Clerk skills from earlier experiments, review them after the install — the bundled skills supersede the stale ones, and keeping both causes the agent to mix old and new APIs in the same file.
Log in and pick an application
The CLI holds credentials in your OS keychain after a browser-based OAuth flow:
clerk auth login
clerk whoamiclerk auth login opens your browser to Clerk, you approve the CLI, and the token lands in the keychain. clerk whoami prints your email and which application is currently selected. If no app is selected, the next command asks you to pick one:
clerk apps listThe list shows each app's human name and app_… ID. You will pass the ID to clerk init (or clerk link) in the next sections — copy it.
Scaffold a React app with clerk init --starter
From an empty parent directory, run:
clerk init --starter --framework react --pm pnpmThe CLI prompts for a project name (defaults to my-clerk-react-app), creates the directory, installs dependencies, links the project to a Clerk application (prompting you to pick from clerk apps list or to create a new one), and writes .env.local.
Here is what lands in the directory:
my-clerk-react-app/
├── .env.local # VITE_CLERK_PUBLISHABLE_KEY + CLERK_SECRET_KEY
├── index.html
├── package.json # vite ^8.0, react ^19.2, @clerk/react ^6.4
├── src/
│ ├── App.tsx # sample page
│ ├── main.tsx # <ClerkProvider> wrapper, <Show> gating
│ └── ...
├── tsconfig.json
└── vite.config.tsThree things matter for this guide:
- The underlying tool is Vite 8. Dev server runs on port 5173 by default, env vars use the
VITE_prefix, andpnpm buildrunstsc -b && vite build. If you are familiar with Vite, this is the same Vite. - The Clerk package is
@clerk/react, not@clerk/clerk-react. Core 3 (March 2026) renamed the SPA SDK. Legacy snippets that import from@clerk/clerk-reactpredate Core 3 and will not work against the current types. - The starter ships no router and no protected route. It is a single-page
App.tsxwith<Show when="signed-in">/<Show when="signed-out">gating the header. Adding a router (React Router, TanStack Router) is your call and out of scope for the CLI scaffold.
Link to an existing Clerk app (optional)
If clerk init linked you to the wrong application, or you want to point the project at production later, unlink and relink:
clerk unlink
clerk link --app app_xxxclerk unlink clears the local link (in .clerk/config.json inside the project). clerk link --app <id> associates the project with a different application. Re-running clerk env pull after a relink rewrites .env.local with the new app's keys, so the dev server picks up the change on next restart.
Linking is per-project, not per-machine. A single machine can have multiple projects pointed at different apps (staging, prod, per-client instances) without interference.
Pull environment variables
The starter already pulled env vars during clerk init. If you later add a teammate, move to a new machine, or rotate a key, pull again:
clerk env pull
cat .env.localExpected output:
VITE_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...VITE_CLERK_PUBLISHABLE_KEY is the client-exposed publishable key. Vite inlines every variable prefixed with VITE_ into the client bundle at build time, which is expected for this key — publishable keys identify your Clerk instance to the Frontend API and carry no authority beyond that.
CLERK_SECRET_KEY is the server-side secret key. Vite does not expose unprefixed variables to the client bundle, so it stays out of your browser-shipped JavaScript. The CLI writes it anyway so the same .env.local works when (not if) you add a backend service or use tooling like clerk doctor. Treat the presence of the secret key in the file as a placeholder for the future, not as an invitation to read it from React code.
If you need production keys on a CI server or in a deploy pipeline, pass --instance prod to clerk env pull. Development and production keys are different and non-overlapping; the CLI surfaces whichever one the linked application's instance selector points at.
Run clerk doctor the first time (it should fail)
Before you start the dev server, prove the safety net works. Move the env file out of the way and run:
mv .env.local .env.local.bak
clerk doctorExpected output:
! No .env.local or .env file found
Run `clerk env pull` to create one with your Clerk keys.clerk doctor is doing its job: it spotted the missing env file before the dev server would have handed you an opaque "publishable key is required" error. Two unrelated warnings may show up in the same output (missing ~/.clerk/config.json, shell completion not installed) — both are noisy-but-not-broken and do not affect the app.
Run clerk doctor after env pull (green)
Restore the env file and re-run:
mv .env.local.bak .env.local
clerk doctorExpected output:
✓ .env.local contains VITE_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY (development instance)pnpm devVite prints a URL like http://localhost:5173/. Open it. The starter renders a header with a sign-in button and a sign-up button. Click Sign up, enter an email address, complete the one-time code, and the header flips to show <UserButton /> — Clerk's prebuilt account menu with sign-out, account management, and (if enabled) organization switching.
A real sign-up creates a real user in your Clerk development instance. You can confirm with clerk api /users from the project directory — the new user's record comes back as JSON.
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.