netlify-identity-tanstack-start
$
npx mdskill add netlify/swar-templates/netlify-identity-tanstack-startIntegrates Netlify Identity into TanStack Start projects for authentication across SSR, SPA, APIs, and server functions.
- Adds login, signup, route protection, and role-based access control to TanStack Start apps on Netlify.
- Uses the @netlify/identity package and requires a real Netlify deployment for functionality.
- Activates when users mention specific terms like '@netlify/identity' or request auth for TanStack Start.
- Provides integration patterns for middleware, webhooks, and client-side state management in the app.
SKILL.md
.github/skills/netlify-identity-tanstack-startView on GitHub ↗
---
name: netlify-identity-tanstack-start
description: Upgrade a stock TanStack Start project to use Netlify Identity for authentication via the @netlify/identity package. Use this skill whenever the user wants to add Netlify Identity auth to a TanStack Start app, integrate login/signup into TanStack Start, protect routes or server functions with Netlify Identity, add role-based access control, or wire up Netlify Identity webhooks. Also use when the user mentions '@netlify/identity', 'nf_jwt', or asks about auth for TanStack Start on Netlify. Covers SSR pages, SPA pages, API routes, server functions, middleware, route guards, role-based access, identity webhooks, and client-side auth state.
---
# Netlify Identity + TanStack Start Integration
This skill extends the base Netlify Identity skill with TanStack Start-specific integration patterns. It covers upgrading a stock TanStack Start project to use Netlify Identity for authentication across every integration surface: SSR pages, SPA pages, API routes, server functions, middleware, route guards, role-based access control, identity event webhooks, and client-side auth state management. For general Netlify Identity concepts, error handling, and API reference, refer to the base Netlify Identity skill.
> ⚠️ **This integration does NOT work on localhost.** Netlify Identity requires the `nf_jwt` cookie to be set by a real Netlify deployment. Running `netlify dev` does NOT provide a working Identity service locally — it only proxies your site, not the GoTrue identity backend. Authentication only functions when the app is deployed to a Netlify staging or production environment. For testing, deploy a branch preview or a staging site.
## Architecture overview
This integration uses a single package: **`@netlify/identity`**. It works on both client and server and abstracts all JWT extraction, cookie handling, and GoTrue API validation behind simple `getUser()` / `login()` / `logout()` calls.
The `nf_jwt` cookie is the bridge between client and server. During SSR or server function calls, the browser sends this cookie automatically on same-origin requests. `@netlify/identity`'s `getUser()` reads and validates this cookie automatically — no manual token extraction or external API calls required.
## Prerequisites
Before starting, ensure:
1. The TanStack Start project deploys to Netlify (using `@netlify/vite-plugin-tanstack-start`)
2. Identity is automatically enabled when the deploy includes Identity code. Default settings:
- **Registration** - Open (anyone can sign up). Change to Invite only in **Project configuration > Identity** if needed.
- **Autoconfirm** - Off (new signups require email confirmation). Enable in **Project configuration > Identity** to skip confirmation during development.
3. The app is deployed to a Netlify staging or production environment (authentication cannot be tested on localhost)
## Step-by-step integration
### Step 1: Install dependencies
```bash
npm install @netlify/identity
```
That's the only package needed. It replaces `netlify-identity-widget`, `gotrue-js`, and `jose`.
### Step 2: Environment variables
Create or update `.env`:
```env
# Required: Your Netlify site URL
VITE_NETLIFY_SITE_URL=https://your-site.netlify.app
```
No JWT secret needed — `@netlify/identity` handles validation internally.
### Step 3: Create the auth utility module
Create `src/lib/auth.ts`:
```typescript
// src/lib/auth.ts
import { createServerFn } from '@tanstack/react-start'
import { getUser, type User } from '@netlify/identity'
export type { User as IdentityUser }
export const getServerUser = createServerFn({ method: 'GET' }).handler(
async () => {
const user = await getUser()
return (user ?? null) as any
}
)
```
`@netlify/identity`'s `getUser()` handles cookie extraction and JWT validation automatically on the server. No manual token parsing, no GoTrue fetch, no `jose`.
### Step 4: Create the auth middleware
TanStack Start middleware provides a composable way to inject auth context into server functions. Create `src/middleware/identity.ts`:
```typescript
// src/middleware/identity.ts
import { createMiddleware } from '@tanstack/react-start'
import { getUser, type User } from '@netlify/identity'
/**
* Middleware that extracts the Netlify Identity user from the request.
* Provides { user } in context. Does NOT throw on unauthenticated requests.
*/
export const identityMiddleware = createMiddleware().server(async ({ next }) => {
const user: User | null = (await getUser()) ?? null
return next({ context: { user } })
})
/**
* Middleware that requires authentication. Throws if no valid user.
*/
export const requireAuthMiddleware = createMiddleware().server(async ({ next }) => {
const user = await getUser()
if (!user) throw new Error('Authentication required')
return next({ context: { user } })
})
/**
* Middleware that requires a specific role.
*/
export function requireRoleMiddleware(role: string) {
return createMiddleware().server(async ({ next }) => {
const user = await getUser()
if (!user) throw new Error('Authentication required')
if (!user.roles?.includes(role)) throw new Error(`Role '${role}' required`)
return next({ context: { user } })
})
}
```
### Step 5: Create the client-side auth context
Create `src/lib/identity-context.tsx`. This React context wraps `@netlify/identity` and provides auth state to the entire app:
```tsx
// src/lib/identity-context.tsx
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from 'react'
import { getUser, logout as nlLogout, onAuthChange, type User } from '@netlify/identity'
interface IdentityContextValue {
user: User | null
ready: boolean
logout: () => Promise<void>
}
const IdentityContext = createContext<IdentityContextValue | null>(null)
export function IdentityProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [ready, setReady] = useState(false)
useEffect(() => {
// Initialize from current session
getUser().then((u) => {
setUser(u ?? null)
setReady(true)
})
// Subscribe to auth changes
const unsubscribe = onAuthChange((u) => {
setUser(u ?? null)
})
return unsubscribe
}, [])
return (
<IdentityContext.Provider value={{ user, ready, logout: nlLogout }}>
{children}
</IdentityContext.Provider>
)
}
export function useIdentity() {
const ctx = useContext(IdentityContext)
if (!ctx) throw new Error('useIdentity must be used within an IdentityProvider')
return ctx
}
```
### Step 6: Create the CallbackHandler component
OAuth providers (GitHub, Google, etc.) redirect back to your site with auth tokens in the URL hash. Create `src/components/CallbackHandler.tsx` to handle this:
```tsx
// src/components/CallbackHandler.tsx
import { useEffect } from 'react'
import { handleAuthCallback } from '@netlify/identity'
const AUTH_HASH_PATTERN =
/^#(confirmation_token|recovery_token|invite_token|email_change_token|access_token)=/
export function CallbackHandler({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (AUTH_HASH_PATTERN.test(window.location.hash)) {
handleAuthCallback()
}
}, [])
return <>{children}</>
}
```
### Step 7: Wire into the app root
In `src/routes/__root.tsx`, wrap the app with both `IdentityProvider` and `CallbackHandler`:
```tsx
// In src/routes/__root.tsx
import { IdentityProvider } from '../lib/identity-context'
import { CallbackHandler } from '../components/CallbackHandler'
export const Route = createRootRoute({
component: () => (
<IdentityProvider>
<CallbackHandler>
{/* existing layout, <Outlet />, etc. */}
</CallbackHandler>
</IdentityProvider>
),
})
```
**Hydration note**: `IdentityProvider` renders `children` immediately without blocking. The `ready` flag from `useIdentity()` tells components when auth state is known. During SSR, `ready` is `false` and `user` is `null`. This avoids hydration mismatches because the server and initial client render both show the "not yet loaded" state.
### Step 8: SSR pages
For SSR pages that need auth state during server rendering, use `beforeLoad` with the `getServerUser` server function:
```tsx
// src/routes/dashboard.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getServerUser } from '../lib/auth'
export const Route = createFileRoute('/dashboard')({
beforeLoad: async () => {
const user = await getServerUser()
if (!user) {
throw redirect({ to: '/login' })
}
return { user }
},
component: DashboardPage,
})
function DashboardPage() {
const { user } = Route.useRouteContext()
return <div>Welcome, {user.name || user.email}</div>
}
```
**How this works during SSR**: `beforeLoad` runs on the server. `getServerUser` calls `getUser()` from `@netlify/identity`, which reads the `nf_jwt` cookie from the incoming request and validates it. If missing or invalid, it redirects to `/login`.
### Step 9: SPA pages
For pages where auth is only needed client-side:
```tsx
// src/routes/profile.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useIdentity } from '../lib/identity-context'
export const Route = createFileRoute('/profile')({
component: ProfilePage,
})
function ProfilePage() {
const { user, ready } = useIdentity()
const navigate = useNavigate()
if (!ready) return <div>Loading...</div>
if (!user) {
navigate({ to: '/login' })
return null
}
return <div>Hello, {user.name || user.email}</div>
}
```
### Step 10: Login page
Use `login()`, `signup()`, and `oauthLogin()` directly from `@netlify/identity` for a headless login UI:
```tsx
// src/routes/login.tsx
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { login, signup, oauthLogin } from '@netlify/identity'
import { useIdentity } from '../lib/identity-context'
import { useState } from 'react'
export const Route = createFileRoute('/login')({
component: LoginPage,
})
function LoginPage() {
const { user, ready } = useIdentity()
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
if (!ready) return <div>Loading...</div>
if (user) {
navigate({ to: '/dashboard' })
return null
}
const handleLogin = async () => {
await login(email, password)
navigate({ to: '/dashboard' })
}
const handleSignup = async () => {
await signup(email, password, { full_name: name })
navigate({ to: '/dashboard' })
}
return (
<div>
<h1>Sign in</h1>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Name (signup only)" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
<button onClick={handleLogin}>Log in</button>
<button onClick={handleSignup}>Sign up</button>
<button onClick={() => oauthLogin('github')}>Continue with GitHub</button>
</div>
)
}
```
### Step 11: API routes
Use `createFileRoute` with `server.handlers` for API routes:
```typescript
// src/routes/api/whoami.ts
import { createFileRoute } from '@tanstack/react-router'
import { getUser } from '@netlify/identity'
export const Route = createFileRoute('/api/whoami')({
server: {
handlers: {
GET: async () => {
const user = await getUser()
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(
JSON.stringify({ id: user.id, email: user.email, name: user.name }),
{ headers: { 'Content-Type': 'application/json' } }
)
},
},
},
})
```
### Step 12: Server functions with middleware
Apply middleware to server functions that require authentication:
```typescript
// src/lib/todos.ts
import { createServerFn } from '@tanstack/react-start'
import { requireAuthMiddleware } from '../middleware/identity'
export const getTodos = createServerFn({ method: 'GET' })
.middleware([requireAuthMiddleware])
.handler(async ({ context }) => {
// context.user is a valid User from @netlify/identity
const { user } = context
return await db.todos.findMany({ where: { userId: user.id } })
})
export const createTodo = createServerFn({ method: 'POST' })
.middleware([requireAuthMiddleware])
.handler(async ({ context, data }) => {
const { user } = context
return await db.todos.create({
data: { ...data, userId: user.id },
})
})
```
### Step 13: Role-based access control
Roles are stored in `user.roles` as a `string[]`. Use them in route guards:
```tsx
// src/routes/admin.tsx
import { createFileRoute, redirect } from '@tanstack/react-router'
import { getServerUser } from '../lib/auth'
export const Route = createFileRoute('/admin')({
beforeLoad: async () => {
const user = await getServerUser()
if (!user) throw redirect({ to: '/login' })
if (!user.roles?.includes('admin')) throw redirect({ to: '/unauthorized' })
return { user }
},
component: AdminPage,
})
```
For server functions with role requirements:
```typescript
import { createServerFn } from '@tanstack/react-start'
import { requireAuthMiddleware, requireRoleMiddleware } from '../middleware/identity'
export const deleteUser = createServerFn({ method: 'POST' })
.middleware([requireAuthMiddleware, requireRoleMiddleware('admin')])
.handler(async ({ context, data }) => {
// Only admins reach this point
return await db.users.delete({ where: { id: data.userId } })
})
```
### Step 14: Netlify Identity webhook handlers
Netlify Identity fires server-side webhook events on user lifecycle actions. Create Netlify Functions (not TanStack Start server functions) to handle them:
```typescript
// netlify/functions/identity-signup.ts
import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions'
const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => {
const user = JSON.parse(event.body || '{}')
// Return modified metadata to customize the user record on signup
return {
statusCode: 200,
body: JSON.stringify({
app_metadata: {
roles: ['user'],
},
user_metadata: {
...user.user_metadata,
signed_up_at: new Date().toISOString(),
},
}),
}
}
export { handler }
```
Available webhook events:
- `identity-signup` — Fires when a new user signs up. Return modified metadata to customize the user record.
- `identity-login` — Fires on each login. Cannot modify the user.
- `identity-validate` — Fires on token validation. Return non-200 to reject the token.
Configure these in Netlify dashboard > Site configuration > Identity > Notifications.
### Step 15: Signup and email confirmation flow
Netlify Identity's email/password signup requires email confirmation before the user can log in. The full flow:
1. User calls `signup(email, password, { full_name: name })` — Netlify sends a confirmation email
2. User clicks the link in the email — browser is redirected to your site with `#confirmation_token=...` in the URL hash
3. `CallbackHandler` detects the hash and calls `handleAuthCallback()`, which confirms the account and logs the user in
4. `onAuthChange` fires, updating `user` in the `IdentityProvider`
This means **the user is not logged in immediately after calling `signup()`**. Show a "check your email" message instead of redirecting to a protected page:
```tsx
function SignupPage() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [name, setName] = useState('')
const [state, setState] = useState<'idle' | 'pending' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('')
const handleSignup = async () => {
setState('pending')
try {
await signup(email, password, { full_name: name })
// signup() resolves after sending the confirmation email.
// The user is NOT yet authenticated — show a confirmation prompt.
setState('idle') // or a dedicated 'confirm-email' state
alert(`Confirmation email sent to ${email}. Click the link to finish signing up.`)
} catch (err: any) {
setErrorMsg(err.message || 'Signup failed')
setState('error')
}
}
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Full name" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
{state === 'error' && <p>{errorMsg}</p>}
<button onClick={handleSignup} disabled={state === 'pending'}>
{state === 'pending' ? 'Sending…' : 'Sign up'}
</button>
</div>
)
}
```
**Other token types handled by `CallbackHandler`:**
- `confirmation_token` — email signup confirmation (described above)
- `recovery_token` — password reset link
- `invite_token` — admin-invited user accepting their invite
- `email_change_token` — user confirming a new email address
- `access_token` — OAuth provider redirect (GitHub, Google, etc.)
All of these are handled automatically by the existing `CallbackHandler` — no extra code needed per flow. The `handleAuthCallback()` call covers all cases.
**Password recovery flow:**
```tsx
import { requestPasswordRecovery } from '@netlify/identity'
async function handleForgotPassword(email: string) {
await requestPasswordRecovery(email)
alert(`Password reset email sent to ${email}`)
}
```
After the user clicks the reset link, `CallbackHandler` handles the `#recovery_token=...` hash, logs them in, and they can update their password via `user.update({ password: newPassword })`.
**Invite flow:**
Users invited via the Netlify dashboard or `POST /.netlify/identity/invite` receive an invite email. When they click the link, `CallbackHandler` handles the `#invite_token=...` hash. Netlify Identity will prompt them to set a password automatically — no custom code required beyond `CallbackHandler` being mounted.
### Step 16: Logout
Use `logout()` from `@netlify/identity` directly or via the identity context:
```tsx
function LogoutButton() {
const { logout } = useIdentity()
const navigate = useNavigate()
const handleLogout = async () => {
await logout()
navigate({ to: '/' })
}
return <button onClick={handleLogout}>Log out</button>
}
```
`logout()` clears the session and the `nf_jwt` cookie automatically.
## netlify.toml configuration
```toml
[build]
command = "vite build"
publish = "dist/client"
[dev]
command = "npm run dev:vite"
targetPort = 3000
port = 8888
```
In `package.json`, configure two dev scripts:
- `npm run dev` — runs `netlify dev` (proxies through Netlify but does NOT provide working Identity locally)
- `npm run dev:vite` — runs Vite directly (no identity, fastest iteration)
## Gotchas and edge cases
### ⚠️ Localhost does not work
**Authentication does not function on localhost.** The `@netlify/identity` package requires the `nf_jwt` cookie to be set by a real Netlify deployment. Running `netlify dev` only proxies your site — it does not run the GoTrue identity backend. To test authentication, deploy a branch preview or staging environment on Netlify.
### Hydration mismatches
The identity context is browser-only. `IdentityProvider` renders children immediately with `ready: false` and `user: null` during SSR. This matches the initial client render, avoiding hydration mismatches. Components should gate on the `ready` flag before displaying auth-dependent content.
### Token expiry during long sessions
Netlify Identity JWTs expire after 1 hour. `@netlify/identity` handles token refresh automatically via `onAuthChange`. For long-lived SPA sessions, ensure `onAuthChange` is subscribed (handled in the `IdentityProvider` above) so the user state stays current.
### Edge function interference
If TanStack Start's Netlify deployment creates catch-all edge function patterns, these can intercept `/.netlify/identity` endpoints. Verify identity endpoints remain accessible after deployment. If blocked, add an exclusion in `netlify.toml`:
```toml
[[edge_functions]]
path = "/*"
function = "server"
excludedPath = "/.netlify/*"
```
## User type shape
The `User` type from `@netlify/identity` has these fields:
```typescript
interface User {
id: string // UUID
email: string
name: string // display name (from user_metadata.full_name)
metadata: Record<string, unknown> // full user_metadata
appMetadata: Record<string, unknown>
roles: string[] // from app_metadata.roles
pictureUrl?: string
}
```
Note: Use `user.name`, `user.metadata`, `user.roles` — NOT `user.user_metadata`, `user.app_metadata.authorization.roles`.
## File structure after integration
```
src/
├── components/
│ └── CallbackHandler.tsx # OAuth redirect handler
├── lib/
│ ├── auth.ts # getServerUser server function
│ └── identity-context.tsx # Client-side React context
├── middleware/
│ └── identity.ts # TanStack Start middleware
├── routes/
│ ├── __root.tsx # IdentityProvider + CallbackHandler wrap app
│ ├── login.tsx # Login/signup page (headless API)
│ ├── dashboard.tsx # Protected SSR page (beforeLoad redirect)
│ ├── admin.tsx # Role-gated route
│ └── api/
│ └── whoami.ts # Protected API route (server.handlers)
netlify/
└── functions/
├── identity-signup.ts # Webhook: new user signup
└── identity-validate.ts # Webhook: token validation
.env # VITE_NETLIFY_SITE_URL
netlify.toml # Build + dev configuration
```
More from netlify/swar-templates
- content-collectionsUse content-collections for type-safe content management with markdown files. Use when building blogs, documentation sites, or any content-driven pages with frontmatter and markdown.
- netlify-forms-tanstackHandle Netlify Forms in TanStack Start. Use when implementing contact forms, signup forms, or any form submission handling on Netlify-hosted TanStack Start sites.
- tanstack-start-api-routesCreate API routes (server routes) in TanStack Start for handling HTTP requests. Use when building REST APIs, webhooks, or any HTTP endpoint that returns data rather than rendering a page.
- tanstack-start-loadersLoad data for TanStack Start routes using beforeLoad and loader functions. Use when fetching data for pages, implementing route guards, or setting up route context. IMPORTANT - Loaders should call server functions for data access.
- tanstack-start-project-setupSet up and configure TanStack Start projects. Use when creating new projects, configuring the router, setting up TanStack Query integration, or configuring build settings.
- tanstack-start-routesCreate and manage routes in TanStack Start using file-based routing. Use when adding new pages, configuring layouts, setting up nested routes, or working with route parameters.
- tanstack-start-server-functionsCreate server functions in TanStack Start for server-side logic callable from anywhere. Use for database access, API calls with secrets, mutations, server-only code, or when you must use .inputValidator(...) for createServerFn inputs.
- tanstack-start-typesafe-routingImplement type-safe navigation and links in TanStack Start. Use when creating links, navigating programmatically, working with search params, or accessing route parameters with full TypeScript support.