Skip to main content
Articles

Authentication for Remix Applications - Part 2

Author: Roy Anger
Published: (last updated )

Part 2 of 2. Start with Authentication for Remix Applications.

This is the second part of our series on adding authentication to a React Router v7 (formerly Remix) application. In Part 1, we covered the core setup, UI integration, and basic route protection. In this part, we dive into advanced topics including organizations and role-based access, migrating an existing app to Clerk, troubleshooting common errors, deployment considerations, and security best practices.

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

  1. Open the dashboard.
  2. Navigate to Configure → Organizations → Settings and toggle organizations on.
  3. (Optional) Configure which users can create organizations.
  4. 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

  1. Install @clerk/react-router.
  2. Set VITE_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY.
  3. Enable future: { v8_middleware: true } in react-router.config.ts.
  4. Export middleware = [clerkMiddleware()] from app/root.tsx.
  5. Add a loader that calls rootAuthLoader(args) to app/root.tsx.
  6. Wrap your <Outlet /> with <ClerkProvider loaderData={loaderData}>.
  7. Create splat routes for /sign-in and /sign-up with <SignIn /> and <SignUp />.
  8. Replace existing auth checks in loaders and actions with getAuth(args).
  9. 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:

Before (remix-auth)After (Clerk)
FormStrategy (email/password)<SignIn />
OAuth strategies (Google, GitHub, etc.)Enable providers in Clerk dashboard
authenticator.isAuthenticated(request)await getAuth(args)
authenticator.logout(request, { redirectTo: '/' }) (server)useClerk().signOut({ redirectUrl: '/' }) (client)

All the strategy-specific code disappears. OAuth providers move from app code to dashboard toggles.

One shift to plan for: remix-auth signs out server-side inside an action, while Clerk's signOut() runs on the client — through <UserButton />, <SignOutButton redirectUrl="/" />, or useClerk().signOut({ redirectUrl: '/' }). There is no server-side signOut() helper for loaders or actions. To end a session from the server (an admin-initiated force-logout, say), revoke it through the Backend API with clerkClient(args).sessions.revokeSession(sessionId), which signs the user out of the client that session belongs to.

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:

  1. Basic Export/Import with password hashes: cleanest if your hashes are bcrypt, argon2, or a supported pbkdf2/scrypt variant with reasonable cost factors.
  2. Force password reset via magic link: safer if hashes are weak or use an algorithm you'd rather not carry forward.
  3. 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 bills on MRU (Monthly Retained Users — users who return 24+ hours after signing up); the other providers bill on MAU (Monthly Active Users), so compare each provider against its own metric.

ApproachSetupCost @ 100K usersPrebuilt UIMFASAML/SCIMMaintenance
DIY (sessions + bcrypt)40–60hInfra only ($25–50/mo)Build itYou
remix-auth + strategies20–30hInfra onlyCommunity
Clerk~2h~$1,025/mo (Pro + 50K MRU overage)ProPro (metered)Clerk
Auth02–3dCustom quoteAuth0
Supabase Auth1–2d~$25/moArchivedTOTPSupabase
WorkOS1dFree up to 1M usersWorkOS

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 self-serve Professional plan tops out at 20,000 MAU ($3,200/mo); at 100K MAU you're into custom-quoted Enterprise pricing, so there's no published self-serve rate at that volume. Supabase Auth's Pro plan ($25/mo) includes 100,000 MAU, so 100K users sits right at the included ceiling — roughly $25/mo, with per-MAU overage ($0.00325/MAU) only kicking in above 100K. 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

  1. VITE_CLERK_PUBLISHABLE_KEY=pk_live_... set per-environment
  2. CLERK_SECRET_KEY=sk_live_... set per-environment
  3. VITE_CLERK_SIGN_IN_URL=/sign-in and VITE_CLERK_SIGN_UP_URL=/sign-up
  4. Production domain added to Clerk dashboard under Domains
  5. OAuth providers configured with custom credentials (Clerk's shared dev credentials don't work in production)
  6. 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

Checklist

Authentication UI

Checklist

Protection

Checklist

Organizations (if using)

Checklist

Production

Checklist

This concludes our two-part guide on authentication for Remix and React Router v7 applications. You now have a comprehensive understanding of how to implement, secure, and deploy authentication in your React Router apps, from basic setup to advanced organizational features and migration strategies.

Frequently Asked Questions

In this series

  1. Authentication for Remix Applications
  2. Authentication for Remix Applications - Part 2 (you are here)