Retour aux skills
clerk/skillsContrôle réussi

SKILL DETAIL

clerk-react-router-patterns

clerk/skills/clerk-react-router-patterns

React Router v7/v8 patterns with Clerk — rootAuthLoader, getAuth in loaders,

Installations · 101Voir la source

Installation

npx skills add https://github.com/clerk/skills --skill clerk-react-router-patterns

Fichiers du skill

SKILL.md

Dernière synchronisation · 30 août 2026

evals/evals.json
{
  "skill_name": "clerk-react-router-patterns",
  "evals": [
    {
      "id": 1,
      "prompt": "i'm building a react router app and need to add clerk authentication. show me how to set up rootAuthLoader and ClerkProvider in root.tsx, plus the middleware so getAuth works in nested loaders.",
      "expected_output": "Sets up rootAuthLoader in root.tsx loader, renders ClerkProvider with loaderData in the default export, exports clerkMiddleware as middleware, adds ssr.noExternal for @clerk/react-router to vite.config.ts",
      "scaffold": "react-router-basic-auth",
      "expectations": [
        "Calls rootAuthLoader(args) inside the root.tsx loader function",
        "Renders <ClerkProvider loaderData={loaderData}> inside the root.tsx default export",
        "Does NOT use a ClerkApp HOC (it does not exist in @clerk/react-router)",
        "Exports middleware array with clerkMiddleware() from @clerk/react-router/server",
        "Imports rootAuthLoader and clerkMiddleware from @clerk/react-router/server",
        "Adds ssr: { noExternal: ['@clerk/react-router'] } to vite.config.ts (template uses React Router v8)",
        "Does NOT use @clerk/nextjs imports"
      ]
    },
    {
      "id": 2,
      "prompt": "i have a react router app with clerk. i need to protect the /dashboard route so only signed-in users can access it. redirect unauthenticated users to /sign-in.",
      "expected_output": "Uses getAuth in dashboard loader, throws redirect to /sign-in when userId is falsy",
      "scaffold": "react-router-basic-auth",
      "expectations": [
        "Imports getAuth from @clerk/react-router/server",
        "Calls getAuth(args) inside the dashboard loader",
        "Throws redirect('/sign-in') when userId is falsy",
        "Returns data from the loader for authenticated users",
        "Does NOT use middleware-only auth without checking in the loader"
      ]
    },
    {
      "id": 3,
      "prompt": "i need a form action in react router that saves data to the database. the action should only run for authenticated users and associate the data with their userId.",
      "expected_output": "Uses getAuth in action function, checks userId, throws 401 if missing, uses userId when saving",
      "scaffold": "react-router-basic-auth",
      "expectations": [
        "Imports getAuth from @clerk/react-router/server",
        "Calls getAuth(args) at the top of the action function",
        "Returns or throws a 401/403 response when userId is missing",
        "Uses userId when writing data to the database",
        "Reads form data from args.request.formData()"
      ]
    },
    {
      "id": 4,
      "prompt": "i want to show the current user's name and avatar in my react router app's navigation. the nav is a client component. how do i access user data?",
      "expected_output": "Uses useUser hook from @clerk/react-router in the nav component, shows user.firstName and user.imageUrl",
      "scaffold": "react-router-basic-auth",
      "expectations": [
        "Imports useUser from @clerk/react-router (not @clerk/nextjs)",
        "Destructures user from useUser()",
        "Handles the case where user is null/undefined (loading state)",
        "Displays user.firstName or user.fullName",
        "Does NOT fetch user data in a loader just to display it in a client nav"
      ]
    },
    {
      "id": 5,
      "prompt": "i need to load org-scoped data in a react router loader. if the user is not in an organization, redirect them to /select-org. if they are, fetch data for that org.",
      "expected_output": "Calls getAuth, checks orgId, redirects to /select-org if missing, uses orgId to fetch org data",
      "scaffold": "react-router-basic-auth",
      "expectations": [
        "Calls getAuth(args) and destructures both userId and orgId",
        "Throws redirect('/select-org') when orgId is falsy",
        "Uses orgId to scope the data fetch",
        "Also checks userId to catch fully unauthenticated users",
        "Returns the fetched org data from the loader"
      ]
    },
    {
      "id": 6,
      "prompt": "add an organization switcher to the dashboard nav so users can switch between their organizations. after switching, they should stay on the dashboard.",
      "expected_output": "Renders OrganizationSwitcher with afterSelectOrganizationUrl pointing to /dashboard",
      "scaffold": "react-router-basic-auth",
      "expectations": [
        "Imports OrganizationSwitcher from @clerk/react-router",
        "Sets afterSelectOrganizationUrl='/dashboard' or equivalent",
        "Places the component in a nav or header component",
        "Does NOT break existing authentication logic",
        "Does NOT import from @clerk/nextjs"
      ]
    }
  ]
}
references/loaders-actions.md
# Loaders and Actions Auth

## getAuth in Loaders

```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 { userId, orgId, sessionId } = await getAuth(args)
  if (!userId) throw redirect('/sign-in')
  return { data: await db.query(userId) }
}
```

## getAuth in Actions

```tsx
export async function action(args: Route.ActionArgs) {
  const { userId } = await getAuth(args)
  if (!userId) throw new Response('Unauthorized', { status: 401 })

  const fd = await args.request.formData()
  await db.insert({ userId, title: fd.get('title') })
  return redirect('/dashboard')
}
```

## rootAuthLoader Callback Form

Pass a callback to `rootAuthLoader` when you need auth state in the root route:

```tsx
import { rootAuthLoader } from '@clerk/react-router/server'

export async function loader(args: Route.LoaderArgs) {
  return rootAuthLoader(args, async ({ request }) => {
    const { userId } = request.auth
    if (!userId) return { user: null }
    return { user: await db.getUser(userId) }
  })
}
```

## Available Auth Fields

| Field | Type | Description |
|-------|------|-------------|
| `userId` | `string \| null` | Current user ID |
| `sessionId` | `string \| null` | Current session ID |
| `orgId` | `string \| null` | Active org ID |
| `orgRole` | `string \| null` | User's role in active org |
| `has()` | `function` | Check permissions/features |

## CRITICAL

- `getAuth` throws if `clerkMiddleware` is not installed
- `rootAuthLoader` must be called in `root.tsx` loader — not just any loader
- Import `redirect` from `react-router`, not from `@remix-run/react-router`
references/protected-routes.md
# Protected Routes

## Loader-Level Protection (Recommended)

Check auth in every loader that needs it. No central middleware needed:

```tsx
export async function loader(args: Route.LoaderArgs) {
  const { userId } = await getAuth(args)
  if (!userId) throw redirect('/sign-in')
  return json({ data: await fetchData(userId) })
}
```

## Middleware-Level Protection

Protect entire route subtrees via middleware in the route file:

```tsx
import { clerkMiddleware, getAuth } from '@clerk/react-router/server'
import { redirect } from 'react-router'

export const middleware = [
  clerkMiddleware(),
  async function requireAuth(args: Route.MiddlewareArgs, next: () => Promise<Response>) {
    const { userId } = await getAuth(args)
    if (!userId) return redirect('/sign-in')
    return next()
  },
]
```

## Client-Side Guard

For client-only redirects after hydration:

```tsx
import { useAuth } from '@clerk/react-router'
import { useNavigate } from 'react-router'

export function ProtectedPage() {
  const { isSignedIn, isLoaded } = useAuth()
  const navigate = useNavigate()

  useEffect(() => {
    if (isLoaded && !isSignedIn) navigate('/sign-in')
  }, [isLoaded, isSignedIn])

  if (!isLoaded || !isSignedIn) return null
  return <Dashboard />
}
```

> Prefer loader-level protection — it runs on the server before any HTML is sent.

## Org-Gated Routes

```tsx
export async function loader(args: Route.LoaderArgs) {
  const { userId, orgId, orgRole } = await getAuth(args)
  if (!userId) throw redirect('/sign-in')
  if (!orgId) throw redirect('/select-org')
  if (orgRole !== 'org:admin') throw new Response('Forbidden', { status: 403 })
  return { settings: await fetchOrgSettings(orgId) }
}
```
references/ssr-auth.md
# SSR Auth

## Pass Auth State to Components

Load user data in the loader, consume in the component:

```tsx
import { getAuth } from '@clerk/react-router/server'
import { clerkClient } from '@clerk/react-router/server'

export async function loader(args: Route.LoaderArgs) {
  const { userId } = await getAuth(args)
  if (!userId) throw redirect('/sign-in')

  const client = clerkClient(args)
  const user = await client.users.getUser(userId)
  return {
    firstName: user.firstName,
    imageUrl: user.imageUrl,
  }
}

export default function Dashboard({ loaderData }: Route.ComponentProps) {
  return <h1>Hello {loaderData.firstName}</h1>
}
```

## useAuth vs getAuth

| | `useAuth` (client) | `getAuth` (server) |
|--|--|--|
| Where | Components, hooks | Loaders, actions |
| Returns | Sync reactive state | Async auth object |
| Import | `@clerk/react-router` | `@clerk/react-router/server` |

## getToken for External APIs

```tsx
export async function loader(args: Route.LoaderArgs) {
  const auth = await getAuth(args)
  const token = await auth.getToken({ template: 'supabase' })
  const data = await fetchFromSupabase(token)
  return { data }
}
```

## Session Claims

```tsx
export async function loader(args: Route.LoaderArgs) {
  const { sessionClaims, userId } = await getAuth(args)
  const role = sessionClaims?.metadata?.role
  if (role !== 'admin') throw new Response('Forbidden', { status: 403 })
  return { adminData: await fetchAdminData() }
}
```
SKILL.md
---
name: clerk-react-router-patterns
description: 'React Router v7/v8 patterns with Clerk — rootAuthLoader, getAuth in loaders,
  clerkMiddleware, protected routes, SSR user data, org switching. Triggers on: react-router
  auth, rootAuthLoader, getAuth loader, react-router protected route, loader authentication,
  SSR auth react-router, useNavigate may be used only in the context of a Router.'
license: MIT
allowed-tools: WebFetch
metadata:
  author: clerk
  version: 1.1.0
---

# React Router Patterns

SDK: `@clerk/react-router` v3.5+. Supports React Router v7.9+ and v8.

## What Do You Need?

| Task | Reference |
|------|-----------|
| Auth in loaders and actions | references/loaders-actions.md |
| Protected routes and redirects | references/protected-routes.md |
| SSR user data and session | references/ssr-auth.md |

## React Router v7 vs v8

Check the installed `react-router` major version before scaffolding — the config differs:

| | v7.9+ | v8+ |
|--|--|--|
| Middleware API | Opt-in: set `future: { v8_middleware: true }` in `react-router.config.ts` | Always on — do NOT set the flag (v8 removed it) |
| `ssr.noExternal` workaround (below) | Not needed | **Required** |

## Minimal Setup

### 1. vite.config.ts (v8 only — REQUIRED)

React Router v8 ships development/production conditional exports. In `react-router dev`,
Vite externalizes `@clerk/react-router` for SSR, so Node resolves the production build of
react-router while the app code gets the development build — two module instances, two
Router contexts. Every request then fails during SSR with:

```
Error: useNavigate() may be used only in the context of a <Router> component.
```

**`npm ls react-router` shows a single copy — that does NOT rule this out.** The
duplication is per export condition, not per installed copy. Do not chase duplicate
installs; add the workaround (upstream issue:
https://github.com/remix-run/react-router/issues/15232):

```ts
import { reactRouter } from '@react-router/dev/vite'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [reactRouter()],
  ssr: {
    noExternal: ['@clerk/react-router'],
  },
})
```

### 2. root.tsx

```tsx
import { Outlet } from 'react-router'
import { rootAuthLoader, clerkMiddleware } from '@clerk/react-router/server'
import { ClerkProvider } from '@clerk/react-router'
import type { Route } from './+types/root'

export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]

export async function loader(args: Route.LoaderArgs) {
  return rootAuthLoader(args)
}

export default function App({ loaderData }: Route.ComponentProps) {
  return (
    <ClerkProvider loaderData={loaderData}>
      <Outlet />
    </ClerkProvider>
  )
}
```

There is no `ClerkApp` HOC in `@clerk/react-router` (that was the `@clerk/remix` API).
Render `<ClerkProvider loaderData={loaderData}>` inside the default export and pass it
the root route's `loaderData`.

### 3. react-router.config.ts (v7 only)

```ts
import type { Config } from '@react-router/dev/config'

export default {
  future: {
    v8_middleware: true,
  },
} satisfies Config
```

On v8, omit the `future` block entirely — the flag no longer exists.

> **Required**: `rootAuthLoader` must be called in `root.tsx`'s loader. Without it, `getAuth` throws in nested loaders.

## Mental Model

React Router v7/v8 uses a middleware + loader pipeline. Clerk plugs into both layers:

- **Middleware** (`clerkMiddleware()`) — runs on every request, attaches auth to context
- **`rootAuthLoader`** — required in `root.tsx` to pass Clerk state to the client
- **`getAuth(args)`** — called inside any loader/action to get the current user

```
Request → clerkMiddleware() → rootAuthLoader → page loader → component
                 ↓                   ↓               ↓
           attaches auth      injects state     getAuth(args)
           to context         to response       reads context
```

## Auth in Loaders

```tsx
import { getAuth } from '@clerk/react-router/server'
import type { Route } from './+types/dashboard'

export async function loader(args: Route.LoaderArgs) {
  const { userId } = await getAuth(args)
  if (!userId) throw redirect('/sign-in')

  const data = await fetchUserData(userId)
  return { data }
}
```

## Auth in Actions

```tsx
import { getAuth } from '@clerk/react-router/server'

export async function action(args: Route.ActionArgs) {
  const { userId, orgId } = await getAuth(args)
  if (!userId) throw new Response('Unauthorized', { status: 401 })

  const formData = await args.request.formData()
  await saveData(userId, orgId, formData)
  return redirect('/dashboard')
}
```

## Client Components

```tsx
import { useAuth, useUser } from '@clerk/react-router'

export function Profile() {
  const { userId, isSignedIn } = useAuth()
  const { user } = useUser()
  if (!isSignedIn) return null
  return <p>{user?.firstName}</p>
}
```

## Org Switching

```tsx
import { OrganizationSwitcher } from '@clerk/react-router'

export function Nav() {
  return <OrganizationSwitcher afterSelectOrganizationUrl="/dashboard" />
}
```

```tsx
export async function loader(args: Route.LoaderArgs) {
  const { userId, orgId } = await getAuth(args)
  if (!userId) throw redirect('/sign-in')
  if (!orgId) throw redirect('/select-org')

  return { data: await fetchOrgData(orgId) }
}
```

## Common Pitfalls

| Symptom | Cause | Fix |
|---------|-------|-----|
| `useNavigate() may be used only in the context of a <Router>` thrown from ClerkProvider during SSR in dev (v8) | Vite dev SSR externalizes `@clerk/react-router`, which then loads react-router's production build while the app uses the development build — two Router contexts. A single copy in `npm ls` does not rule this out. | Add `ssr: { noExternal: ['@clerk/react-router'] }` to `vite.config.ts`. Do NOT downgrade to v7 |
| Build error: `ClerkApp` is not exported | `ClerkApp` does not exist in `@clerk/react-router` | Use `<ClerkProvider loaderData={loaderData}>` in root.tsx's default export |
| `clerkMiddleware() not detected` | Missing middleware (or on v7, missing `v8_middleware` future flag) | Export `middleware = [clerkMiddleware()]` from root route; on v7 also set `future: { v8_middleware: true }` |
| Unknown future flag error/warning (v8) | `v8_middleware` flag left in `react-router.config.ts` after upgrading | Remove the `future.v8_middleware` entry — middleware is always on in v8 |
| `getAuth` returns empty userId | `rootAuthLoader` not called | Call `rootAuthLoader(args)` in `root.tsx` loader |
| Infinite redirect loop | Redirect target is also protected | Exclude `/sign-in` from protection check |
| `redirect` not working in action | Using `Response` instead of `throw redirect()` | Use `throw redirect('/path')` from `react-router` |

## Import Map

| What | Import From |
|------|-------------|
| `getAuth` | `@clerk/react-router/server` |
| `rootAuthLoader` | `@clerk/react-router/server` |
| `clerkMiddleware` | `@clerk/react-router/server` |
| `ClerkProvider` | `@clerk/react-router` |
| `useAuth`, `useUser` | `@clerk/react-router` |
| `OrganizationSwitcher` | `@clerk/react-router` |

## See Also

- `clerk-setup` - Initial Clerk install
- `clerk-custom-ui` - Custom flows & appearance
- `clerk-orgs` - B2B organizations

## Docs

[React Router SDK](https://clerk.com/docs/react-router/getting-started/quickstart)
templates/react-router-basic-auth/app/app.css
templates/react-router-basic-auth/app/root.tsx
import { ClerkProvider, SignInButton, SignUpButton, Show, UserButton } from '@clerk/react-router'
import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router'
import { clerkMiddleware, rootAuthLoader } from '@clerk/react-router/server'

import type { Route } from './+types/root'
import stylesheet from './app.css?url'

export const middleware: Route.MiddlewareFunction[] = [clerkMiddleware()]

export const loader = (args: Route.LoaderArgs) => rootAuthLoader(args)

export const links: Route.LinksFunction = () => [
  { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
  {
    rel: 'preconnect',
    href: 'https://fonts.gstatic.com',
    crossOrigin: 'anonymous',
  },
  {
    rel: 'stylesheet',
    href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap',
  },
  { rel: 'stylesheet', href: stylesheet },
]

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-center py-8 px-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.'
  let stack: string | undefined

  if (isRouteErrorResponse(error)) {
    message = error.status === 404 ? '404' : 'Error'
    details =
      error.status === 404 ? 'The requested page could not be found.' : error.statusText || details
  } else if (import.meta.env.DEV && error && error instanceof Error) {
    details = error.message
    stack = error.stack
  }

  return (
    <main className="pt-16 p-4 container mx-auto">
      <h1>{message}</h1>
      <p>{details}</p>
      {stack && (
        <pre className="w-full p-4 overflow-x-auto">
          <code>{stack}</code>
        </pre>
      )}
    </main>
  )
}
templates/react-router-basic-auth/app/routes.ts
import { type RouteConfig, index } from "@react-router/dev/routes"

export default [index("routes/home.tsx")] satisfies RouteConfig
templates/react-router-basic-auth/app/routes/home.tsx
export default function Home() {
	return <h1>Home</h1>
}
templates/react-router-basic-auth/package.json
{
  "name": "react-router-basic-auth",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "react-router dev",
    "build": "react-router build"
  },
  "dependencies": {
    "react": "^19.2.7",
    "react-dom": "^19.2.7",
    "react-router": "^8.0.0",
    "@clerk/react-router": "^3.5.5"
  },
  "devDependencies": {
    "@react-router/dev": "^8.0.0",
    "@react-router/node": "^8.0.0",
    "vite": "^8.0.0"
  }
}
templates/react-router-basic-auth/react-router.config.ts
import type { Config } from '@react-router/dev/config'

export default {
  ssr: true,
} satisfies Config
templates/react-router-basic-auth/vite.config.ts
import { reactRouter } from "@react-router/dev/vite"
import { defineConfig } from "vite"

export default defineConfig({
	plugins: [reactRouter()],
	// React Router v8 dev SSR externalizes @clerk/react-router, which then resolves
	// react-router's production build while the app uses the development build —
	// two Router contexts, and useNavigate() throws inside ClerkProvider.
	// See https://github.com/remix-run/react-router/issues/15232
	ssr: {
		noExternal: ["@clerk/react-router"],
	},
})