Skip to main content
Articles

Authentication for Remix Applications

Author: Roy Anger
Published: (last updated )

This is the first part of a two-part series on adding authentication to a React Router v7 (formerly Remix) application. In this part, we cover the core setup, including choosing an authentication provider, installing Clerk, adding sign-in and sign-up UI, protecting routes and actions, and reading session data.

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's passkey support is still in beta and it archived its prebuilt auth UI in October 2025. Auth0's self-serve Professional pricing tops out at 20,000 MAU — $3,200/mo — with larger volumes custom-quoted. 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:

  1. @clerk/react-router: actively developed, targets React Router v7.1.2+ in framework mode. Use this for new apps.
  2. @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.

Note

React Router v7 has three modes: declarative, data, and framework. This article covers framework mode, which gives you file-based routing, loaders, actions, SSR by default, and the Vite plugin. If you're in declarative mode (SPA-only), most patterns still apply, but you won't have server-side loaders.

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, ~130k 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.

  1. Clerk: best DX for the React/React Router ecosystem. 50,000 MRU on the free plan. Prebuilt UI, organizations, and bot detection included. MFA and passkeys on Pro.
  2. Auth0: enterprise-grade SAML and SCIM. Self-serve Professional pricing tops out at 20,000 MAU ($3,200/mo); higher volumes are custom-quoted. Mature but expensive. No dedicated React Router SDK.
  3. Supabase Auth: low cost at scale — Pro is $25/mo and includes 100K MAU and tightly integrated with Supabase Postgres. Passkeys are still in beta, there's no SAML, and the prebuilt Auth UI was archived in October 2025.
  4. 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

  1. Need speed, polished UI, and organizations at small-to-medium scale → Clerk.
  2. Need the cheapest option at 100K+ users and you're already on Supabase → Supabase Auth.
  3. Need SAML/SCIM day one for enterprise sales → WorkOS, Auth0 or Clerk.
  4. 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 (up to 3 enabled on the free plan, unlimited on Pro), email + one-time passcodes, and 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

Checklist

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 install

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

4. 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_xxx

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

Note

If you're on React Router v7.3.0–v7.8.x, the flag was called unstable_middleware. Upgrade to v7.9.0+ and use v8_middleware; @clerk/react-router targets the stable flag.

6. 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 dev

Open 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 RouteConfig

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

  1. Go to Configure → User & authentication and open the Email tab.
  2. Enable Email address as an identifier.
  3. Under verification methods, enable Email verification code.
  4. 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:

  1. Go to Configure → User & authentication → SSO connections and select the Social tab.
  2. 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.
  3. Toggle GitHub. Same deal: shared creds in dev, your own in production.
  4. 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 (see the docs) 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' }).

This concludes the first part of our guide on authentication for Remix and React Router v7 applications. We've covered the core setup, UI integration, and basic route protection. In Part 2, we'll explore advanced topics including organizations and role-based access, migrating an existing app to Clerk, troubleshooting common errors, and deployment considerations.

Frequently Asked Questions

In this series

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