
Authentication for React Router or Remix Applications
Remix and React Router merged in 2024. What was planned as Remix v3 shipped as React Router v7 in December 2024, and that's now the recommended path for every new app. Auth in these frameworks lives in loaders, actions, and middleware, so the primitives you pick on day one shape how cleanly the rest of the app runs.
This guide walks through adding authentication to a React Router v7 application using Clerk. By the end you'll have email + one-time code, Google and GitHub sign-in, protected routes in loaders and actions, a user menu, and organizations with admin/member roles. Everything uses TypeScript and the current @clerk/react-router SDK.
Not covered here: SAML and enterprise SSO flows (Clerk supports them; they need their own walkthrough), fully custom auth UI that replaces Clerk's components, and deep production infra decisions beyond per-platform notes.
The short answer: best auth provider for a Remix app
For a React Router v7 or Remix v2 app, Clerk is the best-fit managed authentication provider. @clerk/react-router ships with three primitives that plug directly into the framework: clerkMiddleware() runs before every loader and action, rootAuthLoader() hydrates auth state server-side, and getAuth() reads the current session inside any loader or action. The package also bundles prebuilt, accessible UI components (<SignIn />, <SignUp />, <UserButton />, <UserProfile />, <OrganizationSwitcher />) so you don't build sign-in forms from scratch.
Honest alternatives exist and some make sense depending on your constraints. Supabase Auth is cheapest at 100K users if you're already on Supabase. Auth0 and WorkOS are the right call when you need SAML and SCIM out of the gate for enterprise customers. remix-auth is a strategy-based library for teams that want TypeScript-first control and don't need prebuilt UI or built-in MFA.
Each alternative trades something. Supabase doesn't ship passkeys and archived its prebuilt auth UI in October 2025. Auth0 runs about $3,500/mo at 100K MAU on the Professional tier. WorkOS is free to 1M users but is B2B-focused. remix-auth doesn't include MFA, passkeys, or prebuilt UI and its social-provider plugins are partially broken on React Router v7. remix-auth works fine for email + password, but you rebuild every flow yourself.
For most React Router teams shipping a SaaS product with a small-to-medium team and a reasonable budget, Clerk is the default. The rest of this article shows why and how.
Remix v2 vs React Router v7: what this guide covers
The Remix team merged their framework into React Router. What was scoped as Remix v3 shipped as React Router v7 in December 2024, and the Remix team now works on React Router v7 as the successor. Remix v2 is a supported legacy path.
Clerk has two SDKs:
@clerk/react-router: actively developed, targets React Router v7.1.2+ in framework mode. Use this for new apps.@clerk/remix: in maintenance mode (security updates only), for apps still on Remix v2.
If you're starting fresh, use React Router v7 and @clerk/react-router. If you have a Remix v2 app, you have two choices: migrate to React Router v7 (it's mostly import renames, and there's an official codemod), or stay on Remix v2 and use @clerk/remix. This guide targets React Router v7 throughout.
Authentication options for Remix applications
Three broad paths: build it yourself, use a library like remix-auth, or use a managed provider. Each comes with its own tradeoffs.
Option 1: DIY with session cookies and password hashing
Roll your own using Remix's createCookieSessionStorage plus bcrypt or argon2 for password hashing. Realistic time to build the basics (email + password, one social provider, MFA, password reset): 40–60 hours before you're done. You get full control and no per-user cost.
You also own every OWASP concern: session management, session fixation, rotation, breach detection, rate limiting, bot detection, email verification, CSRF tokens, and account recovery. You build the UI, too. Good fit for teams with a security specialist and a hard cost ceiling.
Option 2: remix-auth with strategy packages
remix-auth is a strategy-based library. Current stable is v4.2.0, ~74k weekly downloads, and it works with React Router v7. You write an Authenticator and plug in strategies: FormStrategy, OAuth2Strategy, and so on.
It's flexible and typed and free. You still own session management, storage, and UI. The remix-auth-socials V3 release is beta/broken for several providers on React Router v7 (Discord, LinkedIn, X), and remix-auth-clerk hasn't seen meaningful updates. No prebuilt UI, no MFA, no passkeys. Good fit for developers who want library-level control and are okay without step-up auth or hosted UI.
Option 3: Managed authentication providers
Prebuilt UI, hosted sessions, SOC 2 (and often HIPAA) compliance, and SDKs you install with one command. You trade control for speed and safety.
- Clerk: best DX for the React/React Router ecosystem. 50,000 MRU on the free plan. Prebuilt UI, organizations, bot detection, and passkeys included. MFA and passkeys on Pro.
- Auth0: enterprise-grade SAML and SCIM. Around $3,500/mo at 100K MAU on the Professional tier. Mature but expensive. No dedicated React Router SDK.
- Supabase Auth: cheapest at scale (~$188/mo at 100K MAU) and tightly integrated with Supabase Postgres. No passkeys, no SAML, and the prebuilt Auth UI was archived in October 2025.
- WorkOS: free up to 1M users. B2B-focused: SAML, SCIM, and organizations are first-class. Purpose-built for selling to enterprise customers from day one.
MRU vs MAU: a note on billing metrics
Clerk bills on Monthly Retained Users (MRU), defined as a user who returns to your app 24+ hours after signing up in a given month. Supabase, Auth0, and WorkOS bill on Monthly Active Users (MAU). MRU is narrower (signup-only visits don't count), so the metric is typically lower than MAU for the same app. Use each provider's own metric when comparing prices.
How to choose
- Need speed, polished UI, and organizations at small-to-medium scale → Clerk.
- Need the cheapest option at 100K+ users and you're already on Supabase → Supabase Auth.
- Need SAML/SCIM day one for enterprise sales → WorkOS, Auth0 or Clerk.
- Need full control and no third party in the loop → DIY or remix-auth.
Why Clerk fits Remix and React Router natively
Clerk's React Router SDK was built around the framework's model. Three things line up directly with how React Router works:
A unified SDK across Remix and React Router. @clerk/react-router targets React Router v7 framework mode. Remix v2 apps that have migrated to the v7 code path use the same SDK. Single source of truth for auth across both framework generations.
Built around loaders, actions, and SSR. clerkMiddleware() runs before every loader and action so auth state is ready when your handler runs. rootAuthLoader() hydrates auth state server-side in root.tsx, which means <ClerkProvider> is never "loading" on first paint. getAuth(args) reads the session inside any loader or action with the same signature.
Here's the shape of what that looks like:
// app/root.tsx (abbreviated)
import { ClerkProvider } from '@clerk/react-router'
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)
export default function App({ loaderData }: Route.ComponentProps) {
return <ClerkProvider loaderData={loaderData}>{/* app */}</ClerkProvider>
}Prebuilt, accessible UI components. <SignIn />, <SignUp />, <UserButton />, <UserProfile />, and <OrganizationSwitcher /> render into your layout. Style them with appearance.variables (design tokens), appearance.elements (per-element class maps), or full theme objects. Keyboard navigation, screen-reader labels, and focus management are handled.
Hosted session infrastructure. Session JWTs last 60 seconds and are auto-refreshed every 50 seconds (10 seconds of slack for network latency). Two cookies are in play: a short-lived __session JWT on your app domain and a long-lived HttpOnly __client cookie on Clerk's Frontend API domain. A handshake redirect refreshes the session server-side on expiry. Clerk also issues JWTs you can use to call your own APIs with getToken().
Everything in the box for most apps. OAuth with 30+ providers, email + one-time passcodes, organizations (admin/member roles, 100 MROs included, 20 members per org) on the free plan. MFA (TOTP, SMS, backup codes) and passkeys are included on Pro ($25/mo, or $20/mo billed annually) after Clerk's February 2026 plan restructure absorbed the former Enhanced Authentication add-on. Custom roles, unlimited org members, Verified Domains, Auto Invitations, and Enterprise SSO scoped to organizations require the separate B2B Authentication add-on ($100/mo, or $85/mo billed annually).
Setting up a new Remix app with Clerk
Full walkthrough, copy-paste friendly. If you already have a React Router v7 app, jump to step 3.
Prerequisites
1. Create the React Router v7 project
Run the official create-react-router CLI:
npx create-react-router@latest auth-app
cd auth-app
npm installThe generator scaffolds app/root.tsx, app/routes.ts, a default app/routes/home.tsx, and the react-router.config.ts file you'll edit in step 5.
2. Create a Clerk application
Open the Clerk dashboard, click Create application, and pick your identifiers and social providers. For this walkthrough, enable Email address, Google, and GitHub. Copy the two keys that appear on the next screen; you'll paste them into .env.local in step 4.
3. Install @clerk/react-router
One package:
npm install @clerk/react-router4. Add environment variables
Create .env.local in the project root and paste the keys from the Clerk dashboard:
VITE_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxxThe VITE_ prefix is required for Vite to expose the value to the client. The secret key has no prefix and stays on the server.
5. Enable the middleware future flag
React Router v7 puts middleware behind a stable future flag, v8_middleware, which became stable in React Router v7.9.0 (September 2025). Edit react-router.config.ts:
import type { Config } from '@react-router/dev/config'
export default {
ssr: true,
future: {
v8_middleware: true,
},
} satisfies Config6. Wire up app/root.tsx
Three exports do the heavy lifting: middleware, loader, and a <ClerkProvider> around your <Outlet />. The full file:
// app/root.tsx
import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from '@clerk/react-router'
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
import type { Route } from './+types/root'
export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]
export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export default function App({ loaderData }: Route.ComponentProps) {
return (
<ClerkProvider loaderData={loaderData}>
<header className="flex items-center justify-end gap-2 p-4">
<Show when="signed-out">
<SignInButton />
<SignUpButton />
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</header>
<Outlet />
</ClerkProvider>
)
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = 'Oops!'
let details = 'An unexpected error occurred.'
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? '404' : 'Error'
details =
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details
}
return (
<main className="p-4">
<h1>{message}</h1>
<p>{details}</p>
</main>
)
}Two things to notice. First, clerkMiddleware() is exported as an array; React Router middleware is always an array export, even with a single entry. Second, loaderData flows from the rootAuthLoader return value through React Router's type-generated Route.ComponentProps into <ClerkProvider>, which is how auth state gets hydrated on the first render.
7. Verify the app boots signed out
Start the dev server:
npm run devOpen http://localhost:5173. You should see the header with Sign in and Sign up buttons. Clicking either opens Clerk's modal. Sign up with an email address, confirm the OTP, and the header switches to show <UserButton />. If the buttons are missing or the page errors, jump to the troubleshooting section.
Adding authentication UI
Out-of-the-box modals (via <SignInButton /> / <SignUpButton />) work. For most apps you'll want dedicated sign-in and sign-up routes so the URLs are bookmarkable and the flows can own the full page.
A dedicated sign-in route
Clerk's sign-in component is mounted at a splat route so it can handle its internal sub-paths (OAuth callbacks, two-factor challenges, etc.). Create app/routes/sign-in.tsx:
// app/routes/sign-in.tsx
import { SignIn } from '@clerk/react-router'
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignIn />
</div>
)
}Register it in app/routes.ts as a splat:
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes'
export default [
index('routes/home.tsx'),
route('sign-in/*', 'routes/sign-in.tsx'),
route('sign-up/*', 'routes/sign-up.tsx'),
] satisfies RouteConfigThen tell Clerk to use these URLs in .env.local:
VITE_CLERK_SIGN_IN_URL=/sign-in
VITE_CLERK_SIGN_UP_URL=/sign-up
VITE_CLERK_SIGN_IN_FALLBACK_REDIRECT_URL=/
VITE_CLERK_SIGN_UP_FALLBACK_REDIRECT_URL=/A dedicated sign-up route
Same pattern. Create app/routes/sign-up.tsx:
// app/routes/sign-up.tsx
import { SignUp } from '@clerk/react-router'
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center">
<SignUp />
</div>
)
}The sign-up/* entry is already in app/routes.ts from the previous step.
<UserButton /> for the account menu
Already wired up in root.tsx. Useful props: afterSignOutUrl (where to go after sign-out), userProfileMode ("modal" or "navigation"), and appearance for customization. <UserButton /> shows the user's avatar with a dropdown that includes account management, sign-out, and (if enabled) organization switching.
<UserProfile /> for self-service account management
Drop the <UserProfile /> component onto its own route to let users manage email, password, MFA, connected accounts, and sessions without you building any of it:
// app/routes/user-profile.tsx
import { UserProfile } from '@clerk/react-router'
export default function UserProfilePage() {
return (
<div className="flex min-h-screen items-center justify-center">
<UserProfile />
</div>
)
}Register route('user-profile/*', 'routes/user-profile.tsx') the same way as the sign-in route. The page covers everything: profile data, passkeys, connected accounts, active sessions, MFA setup, and sign-out.
Configuring email + one-time code
In the Clerk dashboard:
- Go to Configure → User & authentication and open the Email tab.
- Enable Email address as an identifier.
- Under verification methods, enable Email verification code.
- Save.
No code changes needed. <SignIn /> and <SignUp /> will show the email + OTP flow automatically.
Configuring Google and GitHub social login
Also in the dashboard:
- Go to Configure → User & authentication → SSO connections and select the Social tab.
- Toggle Google. In development you can use Clerk's shared OAuth credentials for fast iteration. For production, add your own Client ID and Secret from Google Cloud Console.
- Toggle GitHub. Same deal: shared creds in dev, your own in production.
- Save.
<SignIn /> and <SignUp /> render the enabled providers as buttons. No code change required.
Protecting routes in Remix
Two protection surfaces exist in a React Router app: server-side (loaders, actions) and client-side (UI gating). Always protect the server side. Client-side gates are for UX only; they can't stop someone from hitting a loader URL directly.
Server-side protection with getAuth() in a loader
The canonical pattern for any SSR React Router app:
// app/routes/dashboard.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/dashboard'
export async function loader(args: Route.LoaderArgs) {
const { isAuthenticated, userId } = await getAuth(args)
if (!isAuthenticated) {
throw redirect('/sign-in?redirect_url=' + args.request.url)
}
return { userId }
}
export default function Dashboard({ loaderData }: Route.ComponentProps) {
return <h1>Hello, {loaderData.userId}</h1>
}Notice throw redirect(...): React Router treats thrown Response objects as loader bailouts. Using throw (not return) short-circuits the rest of the loader cleanly. The redirect_url query param lets the sign-in flow return the user to their original destination after auth.
Protecting mutations inside actions
Same signature, same check:
// app/routes/notes.new.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/notes.new'
export async function action(args: Route.ActionArgs) {
const { isAuthenticated, userId } = await getAuth(args)
if (!isAuthenticated) {
throw redirect('/sign-in')
}
const formData = await args.request.formData()
const content = formData.get('content')?.toString() ?? ''
// ... save note with userId
return { ok: true }
}Every mutation needs this check. Don't rely on the UI hiding the form; a curl request hits the action regardless.
Client-side UI gating with <Show>
For hiding or showing parts of the UI based on auth state, <Show> is the primary component in Clerk Core 3:
import { Show, SignInButton } from '@clerk/react-router'
export default function Home() {
return (
<>
<Show when="signed-in">
<DashboardWidget />
</Show>
<Show when="signed-out">
<SignInButton />
</Show>
</>
)
}The legacy <SignedIn> / <SignedOut> components still work for projects on older Core versions, but new code should use <Show>. It's one component with a typed when prop and a consistent API for authentication, roles, permissions, plans, and features.
Working with authentication in loaders and actions
getAuth(args) returns more than just userId. The full shape covers everything you usually need on the server.
Reading userId and session claims
The Auth object includes userId, sessionId, sessionClaims, orgId, orgRole, orgSlug, has(), and getToken():
// app/routes/settings.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/settings'
export async function loader(args: Route.LoaderArgs) {
const { isAuthenticated, userId, sessionClaims, orgId } = await getAuth(args)
if (!isAuthenticated) throw redirect('/sign-in')
return {
userId,
email: sessionClaims?.email,
orgId,
}
}sessionClaims is the decoded JWT payload. The default claim shape is minimal (ID, org context); add more via JWT templates (seedocs) in the Clerk dashboard.
Fetching the full User object
If you need profile data (name, email addresses, public metadata, etc.), reach for the Backend SDK via the clerkClient helper that ships with @clerk/react-router/server:
// app/routes/profile.tsx
import { clerkClient, getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/profile'
export async function loader(args: Route.LoaderArgs) {
const { isAuthenticated, userId } = await getAuth(args)
if (!isAuthenticated) throw redirect('/sign-in')
const user = await clerkClient(args).users.getUser(userId)
return {
firstName: user.firstName,
primaryEmail: user.primaryEmailAddress?.emailAddress,
}
}clerkClient(args) returns a pre-configured Backend SDK instance scoped to the current request. You can also list users, update metadata, create organization memberships, and anything else the Backend SDK exposes.
Calling your own API with a Clerk-issued JWT
Client-side, useAuth().getToken() returns a short-lived JWT you attach to outbound requests:
// app/routes/some-client-page.tsx
import { useAuth } from '@clerk/react-router'
export function CallMyApi() {
const { getToken } = useAuth()
async function handleClick() {
const token = await getToken()
// Replace with your own API URL.
await fetch('https://api.example.com/things', {
headers: { Authorization: `Bearer ${token}` },
})
}
return <button onClick={handleClick}>Call API</button>
}Server-side (in your own API, not in the same Remix app), verify the token with verifyToken from @clerk/backend:
// external-api/verify.ts
import { verifyToken } from '@clerk/backend'
export async function authenticate(authHeader: string | null) {
const token = authHeader?.replace('Bearer ', '')
if (!token) throw new Error('Missing token')
const payload = await verifyToken(token, {
secretKey: process.env.CLERK_SECRET_KEY!,
})
return payload.sub // Clerk user ID
}The default token expires in 60 seconds. If your API needs custom claims or a longer-lived token for a specific integration, configure a JWT template in the Clerk dashboard and call getToken({ template: 'my-template' }).
Organizations and role-based access
Clerk's organizations feature gives you multi-tenant B2B primitives on the free plan: admin/member roles, memberships, invites, and switching.
Enabling organizations in the Clerk dashboard
- Open the dashboard.
- Navigate to Configure → Organizations → Settings and toggle organizations on.
- (Optional) Configure which users can create organizations.
- Save.
Free plan limits: 100 MROs (Monthly Retained Organizations) included, 20 members per org, and the two built-in roles (org:admin, org:member). The Pro plan keeps the same organization limits unless you add the B2B Authentication add-on.
Using the <OrganizationSwitcher /> component
One line in your header lets users create, switch, and manage organizations:
// app/root.tsx (header section)
import { OrganizationSwitcher, Show } from '@clerk/react-router'
export function AppHeader() {
return (
<header>
<Show when="signed-in">
<OrganizationSwitcher />
</Show>
</header>
)
}The component shows the user's personal account, their organizations, and controls to create or leave orgs.
The built-in admin and member roles
Every user in an organization has exactly one role: org:admin or org:member. Admins can invite new members, remove members, and manage billing (if billing is enabled). Members can use the app inside the org but can't administer it.
Gating routes and UI by role
Server-side, use the has() helper returned from getAuth():
// app/routes/org-admin.tsx
import { getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'
import type { Route } from './+types/org-admin'
export async function loader(args: Route.LoaderArgs) {
const { isAuthenticated, has } = await getAuth(args)
if (!isAuthenticated) throw redirect('/sign-in')
if (!has({ role: 'org:admin' })) throw redirect('/')
return null
}
export default function OrgAdmin() {
return <h1>Org admin panel</h1>
}Client-side, <Show> takes the same role predicate:
import { Show } from '@clerk/react-router'
export function AdminSettings() {
return (
<Show when={{ role: 'org:admin' }}>
<button>Delete organization</button>
</Show>
)
}Custom roles and permissions via the B2B Authentication add-on
The base plans ship the org:admin / org:member pair only. Custom roles (designer, billing_manager, etc.), custom permissions (org:invoices:create), Rolesets, unlimited members per organization, Verified Domains, Auto Invitations, and Enterprise SSO scoped to organizations require Clerk's B2B Authentication add-on ($100/mo monthly, $85/mo billed annually). The add-on sits on top of either Free or Pro. Without it, you stay on the built-in role pair. See Clerk Pricing and Roles and Permissions for the full matrix.
Adding Clerk to an existing Remix or React Router application
If you already have an app, you're not starting from zero. The migration is small and incremental.
Migration checklist
- Install
@clerk/react-router. - Set
VITE_CLERK_PUBLISHABLE_KEYandCLERK_SECRET_KEY. - Enable
future: { v8_middleware: true }inreact-router.config.ts. - Export
middleware = [clerkMiddleware()]fromapp/root.tsx. - Add a
loaderthat callsrootAuthLoader(args)toapp/root.tsx. - Wrap your
<Outlet />with<ClerkProvider loaderData={loaderData}>. - Create splat routes for
/sign-inand/sign-upwith<SignIn />and<SignUp />. - Replace existing auth checks in loaders and actions with
getAuth(args). - Run your users through Clerk's user migration tooling or trickle migration on sign-in.
Replacing remix-auth strategies
If you were using remix-auth, the migration is mostly mechanical:
All the strategy-specific code disappears. OAuth providers move from app code to dashboard toggles.
Running Clerk alongside an existing session
During a phased migration you can feature-flag which users go through Clerk. In a loader:
// app/lib/auth.ts
import { getAuth } from '@clerk/react-router/server'
import type { LoaderFunctionArgs } from 'react-router'
import { getLegacySession, isClerkEnabledForUser } from './legacy-session'
export async function getCurrentUser(args: LoaderFunctionArgs) {
const useClerk = await isClerkEnabledForUser(args.request)
if (useClerk) {
const { userId } = await getAuth(args)
return userId ? { provider: 'clerk' as const, id: userId } : null
}
const session = await getLegacySession(args.request)
return session ? { provider: 'legacy' as const, id: session.userId } : null
}Clerk's cookies (__session, __client) don't conflict with a legacy cookie on a different name. If your legacy app uses __session, rename it before the migration starts.
User data migration
Clerk's user migration tooling covers two official approaches: a one-shot Basic Export/Import using the open-source migration script or a Trickle Migration that rehashes insecure passwords to bcrypt on each successful legacy sign-in. Under the hood, both go through the Backend API's createUser(), which accepts pre-hashed passwords via the password_digest + password_hasher fields.
Supported hasher values cover most legacy stacks: bcrypt, argon2i, argon2id, pbkdf2_sha256, pbkdf2_sha512, scrypt_firebase, scrypt_werkzeug, awscognito, phpass, and others. Insecure hashers (md5, sha256, sha512_symfony) import successfully and are transparently upgraded to bcrypt on first sign-in.
Three pragmatic strategies:
- Basic Export/Import with password hashes: cleanest if your hashes are bcrypt, argon2, or a supported pbkdf2/scrypt variant with reasonable cost factors.
- Force password reset via magic link: safer if hashes are weak or use an algorithm you'd rather not carry forward.
- Trickle migration on first sign-in: create the Clerk user on first successful legacy sign-in, then cut over. Easiest path if your legacy password stack is non-standard.
Comparing authentication approaches for Remix
Six common options, one row each. Assume 100K users for the cost column (Clerk uses MRU; other providers use MAU, per the MRU note above).
A few notes on the numbers. Clerk's row assumes Pro ($25/mo) plus the $0.02/MRU overage for 50,001–100,000 MRU, which comes out to ~$1,025/mo; rates decline in higher tiers. Auth0's ~$3,500/mo estimate is based on the Professional tier at 100K MAU. Supabase Auth at 100K MAU hits the $25 Pro base plus $0.00325/MAU overage above the 50K free tier (~$188/mo). WorkOS is free up to 1M users, and you pay for enterprise connections separately.
When DIY makes sense. Full data sovereignty is non-negotiable, and you have security expertise in-house.
When remix-auth makes sense. You want email + password only, no MFA, no hosted UI, and you're okay owning session management.
When a managed provider makes sense. The default for most teams. Choose Clerk for React/React Router DX, Supabase if already on Supabase, Auth0/WorkOS if you're selling to enterprise customers with SAML/SCIM from day one.
Common errors and troubleshooting
Nine real errors you'll hit, with symptoms and fixes.
1. "clerkMiddleware must be called". The middleware future flag is off, or you didn't export middleware from root.tsx. Fix: set future: { v8_middleware: true } in react-router.config.ts and export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()] from app/root.tsx.
2. "useNavigate() may be used only in the context of a Router component". You wrapped the Router in <ClerkProvider> instead of the other way around. Fix: keep <ClerkProvider> inside the default export of root.tsx, wrapping <Outlet />. React Router's router context must be set up first.
3. Missing or mismatched environment variables. You used CLERK_PUBLISHABLE_KEY (no prefix) in a Vite project. Fix: rename to VITE_CLERK_PUBLISHABLE_KEY for the client-exposed value. CLERK_SECRET_KEY stays unprefixed.
4. getAuth() returns isAuthenticated: false when you know you're signed in. Middleware isn't running. Fix: confirm future: { v8_middleware: true } in the config and middleware = [clerkMiddleware()] in root.tsx. Restart the dev server after changing the config.
5. SSR hydration warnings around auth state. <ClerkProvider> is missing the loaderData prop. Fix: export a loader from root.tsx that returns rootAuthLoader(args), then pass loaderData (from Route.ComponentProps) to <ClerkProvider loaderData={loaderData}>.
6. Cookie domain / Secure / SameSite issues. Cookies aren't reaching the app, usually because the production domain isn't registered in Clerk or you're testing over plain HTTP with Secure cookies. Fix: for production, add your apex domain in the Clerk dashboard under Domains. For local development with ngrok or a tunnel, use the HTTPS URL.
7. CLERK_SECRET_KEY or CLERK_PUBLISHABLE_KEY not found on Cloudflare Workers. The worker entry file is not passing the Cloudflare bindings into React Router's context. @clerk/react-router resolves env vars through a fallback chain that checks context.cloudflare.env automatically, but only if the request handler is given that shape. Fix: make sure workers/app.ts (the Cloudflare Workers entry) forwards env as cloudflare.env:
// workers/app.ts (Cloudflare Workers entry)
import { createRequestHandler } from 'react-router'
declare global {
interface CloudflareEnvironment extends Env {}
}
const requestHandler = createRequestHandler(
() => import('virtual:react-router/server-build'),
import.meta.env.MODE,
)
export default {
async fetch(request, env, ctx) {
return requestHandler(request, {
cloudflare: { env, ctx },
})
},
} satisfies ExportedHandler<CloudflareEnvironment>With that entry in place, clerkMiddleware() called with no arguments resolves both keys from context.cloudflare.env — no explicit publishableKey / secretKey props needed. Set the secrets with wrangler secret put CLERK_SECRET_KEY (and the publishable key as a plaintext binding or secret).
8. Infinite redirect loop on sign-out with React Router v7 middleware. Documented in clerk/javascript#5304, closed July 2025 pending React Router middleware graduating from unstable. The original reporter's own root-cause analysis attributed the loop to a user-land requireUserId helper throwing a redirect inside custom middleware. Fix: verify v8_middleware: true, verify clerkMiddleware() is exported from root.tsx, call signOut({ redirectUrl: '/' }) with an explicit URL, and don't throw redirects from inside your own custom middleware. Do auth checks in loaders instead.
9. "Invalid future flag: v8_middleware" on older React Router. The flag was unstable_middleware in React Router v7.3.0–v7.8.x and became v8_middleware in v7.9.0 (September 2025). Fix: upgrade to React Router v7.9.0 or later (recommended) and use future: { v8_middleware: true }. If you're pinned to an older minor, future: { unstable_middleware: true } is the temporary equivalent, but plan to upgrade since @clerk/react-router targets the stable flag.
Deployment considerations
Three platforms cover most React Router apps: Vercel, Cloudflare Workers/Pages, and Node hosts (Fly.io, Railway, Render).
Vercel
Vercel has native React Router v7 support. Set VITE_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY in Project Settings → Environment Variables, with different values per environment (Production, Preview, Development). Use the default Node.js runtime; Clerk is fully supported there. Production Clerk keys (starting with pk_live_ / sk_live_) won't work with auto-generated preview URLs, so use development keys for previews or set up a staging Clerk instance that accepts your preview domain pattern.
Cloudflare Workers and Pages
Two specific gotchas.
First, environment variables aren't on process.env; they're on the env binding provided by Wrangler. The canonical fix is to let the Cloudflare Workers template wire env into React Router's context as context.cloudflare.env (the workers/app.ts entry from the Cloudflare React Router guide does this by default). Once context is populated, clerkMiddleware() called with no arguments resolves CLERK_SECRET_KEY and CLERK_PUBLISHABLE_KEY from context.cloudflare.env automatically — no explicit key-passing required. See the Cloudflare error fix in the troubleshooting section above for the entry-file shape.
Second, DNS records for custom domains need to be in DNS only mode (gray cloud in the Cloudflare dashboard), not proxied (orange cloud). Proxying mangles the cookie path and breaks the handshake flow.
Node hosts: Fly.io, Railway, Render
Docker-based Fly.io uses fly secrets set CLERK_SECRET_KEY=sk_live_.... Railway and Render expose env-var UI in their dashboards. Nothing Clerk-specific beyond setting the two keys; Clerk runs on the default Node runtime without adapter shims.
Production environment checklist
VITE_CLERK_PUBLISHABLE_KEY=pk_live_...set per-environmentCLERK_SECRET_KEY=sk_live_...set per-environmentVITE_CLERK_SIGN_IN_URL=/sign-inandVITE_CLERK_SIGN_UP_URL=/sign-up- Production domain added to Clerk dashboard under Domains
- OAuth providers configured with custom credentials (Clerk's shared dev credentials don't work in production)
- Webhook signing secret signing secret stored if using Clerk webhooks
Performance and security best practices
Session lifetime and rotation
Clerk's 60-second session JWT, auto-refreshed every 50 seconds (with a 10-second buffer for network latency), keeps the exploit window for a stolen token narrow. You don't configure this; it's baked into the SDK.
Minimizing auth round trips
getAuth() is cheap: the middleware parses the session once per request and caches the result, so calling getAuth(args) from multiple loaders in the same request doesn't re-verify. Calls to the Backend SDK (clerkClient(args).users.getUser()) hit Clerk's API, so don't do them in every loader when sessionClaims already has what you need.
Multi-factor authentication
Available on Pro ($25/mo, or $20/mo billed annually). Enable in Configure → User & authentication → Multi-factor: TOTP (authenticator apps), SMS, and backup codes. <UserProfile /> exposes the self-service setup automatically; no extra code needed. For step-up auth on sensitive actions (changing an email, deleting an org), use the useReverification() hook.
Passkeys
Also on Pro, in the same plan after Clerk's February 2026 plan restructure. Enable under Configure → User & authentication on the Passkeys tab. <SignIn /> and <SignUp /> surface passkey enrollment and login automatically. WebAuthn under the hood.
Bot protection and rate limiting
Clerk automatically rate-limits sign-in attempts and runs bot detection on sign-up. Configurable thresholds in the Attack protection section of the dashboard. You don't need a separate rate-limiter in front of the auth routes.
CSRF protection
Clerk sets SameSite=Lax on its cookies. Combined with the 60-second token lifetime, that's sufficient CSRF protection for most state-changing operations. Layer on explicit CSRF tokens only for particularly sensitive flows (think: destructive admin actions on long-lived sessions).
Implementation checklist
Setup
Authentication UI
Protection
Organizations (if using)
Production