Skip to main content
Articles

Add Clerk authentication to a React app with the Clerk CLI

Author: Roy Anger
Published:

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-run and --yes flags 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:

Checklist

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:

terminal
# macOS and Linux (Homebrew tap)
brew install clerk/stable/clerk
terminal
# Any platform with Node.js 20+ (cross-platform, including Windows)
npm install -g clerk

The 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 --yes

Anything 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.

Tip

clerk doctor is a good habit. Run it any time the CLI or your project feels off — it checks authentication state, env files, skill currency, and more in one pass. clerk doctor --spotlight formats the output for pasting into a bug report.

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 -y

This 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.

Note

Skills drift as Clerk ships. The content your agent sees is cached locally; new features, deprecations, and breaking changes only surface after a skill update. Re-run clerk skill install -y after each Clerk major release (Core 3 in March 2026 is the most recent) and any time an agent-generated snippet looks suspicious. clerk doctor calls out out-of-date skills when it sees them.

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 whoami

clerk 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 list

The 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 pnpm

The 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.ts

Three things matter for this guide:

  1. The underlying tool is Vite 8. Dev server runs on port 5173 by default, env vars use the VITE_ prefix, and pnpm build runs tsc -b && vite build. If you are familiar with Vite, this is the same Vite.
  2. 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-react predate Core 3 and will not work against the current types.
  3. The starter ships no router and no protected route. It is a single-page App.tsx with <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.

Note

Passing --pm pnpm tells the CLI to run pnpm install, but the generated lockfile for the React starter is still package-lock.json at the time of writing. pnpm dev, pnpm build, and pnpm install all work because package.json scripts are generic — the lockfile name is cosmetic, not functional. If this bothers you, delete package-lock.json and run pnpm install to regenerate pnpm-lock.yaml.

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_xxx

clerk 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.local

Expected 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.

Warning

Never rename CLERK_SECRET_KEY to VITE_CLERK_SECRET_KEY. Doing so tells Vite to inline the secret into the client bundle, which means anyone who loads your site can read it in the browser devtools. A 2024 write-up of a real breach (Sprocket Security) traced full CI/CD compromise to exactly this Vite misconfiguration on an unrelated app. The VITE_ prefix is the only meaningful client/server boundary in a Vite project — do not cross it.

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 doctor

Expected 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.

Tip

Whenever the dev server fails to render Clerk UI, try clerk doctor before opening devtools. Missing env files, drifted skill versions, an unselected application, and @clerk/react version mismatches all surface here first.

Run clerk doctor after env pull (green)

Restore the env file and re-run:

mv .env.local.bak .env.local
clerk doctor

Expected output:

✓ .env.local contains VITE_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY (development instance)

Green. Now you can start the dev server.

Start the dev server and sign up a test user

Run:

pnpm dev

Vite 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.

Important

If the starter's main.tsx reads publishableKey implicitly from the environment and you hit a TypeScript build error like TS2741: Property 'publishableKey' is missing, pass the prop explicitly:

import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react'
import App from './App'

createRoot(document.getElementById('root')!).render(
  <ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>
    <App />
  </ClerkProvider>,
)

The provider auto-reads the env var at runtime, but the TypeScript prop is non-optional in the current types. The explicit form works everywhere and is safer to copy into your own main.tsx.

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 it

Let's run four patches against the React instance. Start by snapshotting the baseline:

clerk config pull --output config.before.json

Patch 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}}' --yes

Warning

auth_passkey.enabled is not a real schema field. Patches that include it are accepted by the API but silently stripped on apply, and the diff looks like a no-op even though the CLI reports success. Always snapshot with clerk config pull and diff against the result to confirm a patch actually persisted. used_for_sign_in is the field that toggles passkey sign-in on.

Important

Passkeys are a paid-plan feature. On the free plan, config patch returns a 403 when you try to enable them. Plan and pricing details live on the Clerk Pricing page.

Patch 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}}' --yes

Each 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}}' --yes

lifetime: 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.

Important

Custom session configuration is a paid-plan feature. On the free plan, the patch returns 403. Pricing on the Clerk Pricing page.

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}}}' --yes

One 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.json

Expected 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.

Warning

clerk config put replaces the entire configuration with the supplied JSON. Fields you omit are reset to defaults — which, for a live application, can silently log every user out or strip OAuth providers. Use patch for incremental changes and reserve put for scripted instance re-creation with a reviewed-to-completion payload.

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 users

Output: 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-tokens

Fetch 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/applications

Tip

Platform API paths are prefixed with /platform/..., not /accounts. Calls like clerk api --platform /accounts return clerk_key_invalid because the routing table does not have an /accounts entry at the Platform level. Use clerk api --platform ls to browse the correct paths — /platform/applications, /platform/instances, and similar are the real endpoints.

Every 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:

CommandWhat it does
clerk --versionPrint CLI version.
clerk update --yesUpdate to latest stable. --channel canary for pre-release.
clerk auth loginBrowser-based OAuth to Clerk. Stores tokens in OS keychain.
clerk whoamiPrint the authenticated user and currently-selected app.
clerk apps listList Clerk applications you have access to.
clerk apps createCreate a new Clerk application without the dashboard.
clerk init --starter --framework react --pm pnpmScaffold a Vite + React + Clerk project.
clerk initSame, but runs in an existing project directory (auto-detects framework).
clerk link --app app_xxxAssociate the current project with a Clerk app.
clerk unlinkClear the current project's application link.
clerk env pullWrite .env.local with the linked app's keys. --instance prod for production.
clerk doctorPre-flight the local setup. --spotlight for bug-report formatting.
clerk config schemaPrint the configuration JSON schema.
clerk config pull --output <file>Snapshot the instance configuration.
clerk config patch --dry-run --json '{...}'Preview a config change.
clerk config patch --json '{...}' --yesApply a config change.
clerk config putReplace the entire configuration (destructive — use sparingly).
clerk api ls <resource>List endpoints under a resource.
clerk api /<path>Call a Backend API endpoint.
clerk api --platform /<path>Call a Platform API endpoint.
clerk skill install -yInstall Clerk-maintained agent skills globally.
clerk completion <shell>Generate shell completion for bash/zsh/fish.
clerk openOpen the Clerk Dashboard for the current app in your browser.

Anything not listed here: run clerk <subcommand> --help or clerk --help for the full surface.

FAQ