Remix v2 meta/SEO, sessions, auth, and CSRF. Use when working with document head, cookie sessions, auth gates, or CSRF protection. Triggers on meta export (v...
---
name: remix-v2-meta-sessions
description: Remix v2 meta/SEO, sessions, auth, and CSRF. Use when working with document head, cookie sessions, auth gates, or CSRF protection. Triggers on meta export (v2 array shape), links export, createCookieSessionStorage, commitSession, destroySession, requireUserId, remix-utils/csrf, remix-auth.
---
# Remix v2 Meta, Sessions, Auth, and CSRF
## Quick Reference
**v2 `meta` returns an array of descriptor objects** — NOT the v1 object shape.
A v1-style object literal still typechecks in stale codebases but renders no
tags at runtime.
```tsx
// app/routes/posts.$slug.tsx
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
if (!data?.post) return [{ title: "Not Found" }];
return [
{ title: `${data.post.title} | My Blog` },
{ name: "description", content: data.post.excerpt },
{ property: "og:title", content: data.post.title },
{ tagName: "link", rel: "canonical", href: data.post.url },
];
};
```
**Cookie session storage with secure defaults and secret rotation**:
```ts
// app/session.server.ts
import { createCookieSessionStorage } from "@remix-run/node";
type SessionData = { userId: string };
type SessionFlashData = { error: string };
const SESSION_SECRET = process.env.SESSION_SECRET;
if (!SESSION_SECRET) throw new Error("SESSION_SECRET is required");
export const { getSession, commitSession, destroySession } =
createCookieSessionStorage<SessionData, SessionFlashData>({
cookie: {
name: "__session",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 60 * 60 * 24 * 30,
secrets: [
SESSION_SECRET,
...(process.env.SESSION_SECRET_OLD ? [process.env.SESSION_SECRET_OLD] : []),
],
},
});
```
## Document Head: `meta` and `links`
`<Meta />` and `<Links />` must live inside `<head>` in `root.tsx`;
`<ScrollRestoration />`, `<Scripts />`, and `<LiveReload />` go at the end of
`<body>`. Missing either of these aggregators produces "css doesn't load" or
"meta tags missing" with no compile error.
```tsx
// app/root.tsx
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
```
**`<Meta />` and `<Links />` aggregate differently.** `<Links />` walks the
entire route match chain and renders **every** matched route's `links` export
— a stylesheet declared in a leaf route is rendered automatically and
unloaded on navigation away. `<Meta />` does **NOT** aggregate; Remix picks
the last matching route's `meta` array only. To inherit from parents in
`meta`, flatMap `matches` explicitly:
```tsx
import type { MetaFunction } from "@remix-run/node";
import type { loader as projectLoader } from "./project.$pid";
export const meta: MetaFunction<
typeof loader,
{ "routes/project.$pid": typeof projectLoader }
> = ({ data, matches }) => {
const parentMeta = matches.flatMap((m) => m.meta ?? []);
const project = matches.find((m) => m.id === "routes/project.$pid")?.data;
return [
...parentMeta,
{ title: `${data?.task.name} | ${project?.name}` },
];
};
```
The second generic on `MetaFunction` (keyed by route id) types
`matches.find(...).data` for parent routes. See
[references/meta-v2.md](references/meta-v2.md).
## Sessions
`commitSession` must be attached as a `Set-Cookie` header on every mutating
response. Remix does NOT auto-commit; calling `session.set(...)` and returning
plain `json(data)` silently drops the change.
```ts
return redirect("/dashboard", {
headers: { "Set-Cookie": await commitSession(session) },
});
```
`session.flash(key, value)` is read-once; the consuming loader must still call
`commitSession` after reading to clear the flash. See
[references/sessions.md](references/sessions.md).
## Auth: throw `redirect` from loaders
The canonical pattern is a `requireUserId(request)` helper that **throws**
`redirect()` for unauthenticated requests. The thrown response short-circuits
the loader; no top-level `return` is needed.
```ts
// app/auth.server.ts
import { redirect } from "@remix-run/node";
import { getSession } from "./session.server";
export async function requireUserId(request: Request): Promise<string> {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
if (!userId) {
const url = new URL(request.url);
const redirectTo = `${url.pathname}${url.search}`;
throw redirect(`/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}
return userId;
}
```
Never gate routes inside React components — the protected component still SSRs
and ships HTML/loader data to unauthenticated users. See
[references/auth-csrf.md](references/auth-csrf.md).
## CSRF
**Remix has no built-in CSRF protection.** Same-origin `<Form>` posts rely
entirely on whatever `SameSite` value you set on the session cookie.
`SameSite=Lax` blocks cookies on cross-site POST navigations in all current
browsers. (Chrome briefly had a 2-minute "Lax+POST" window in 2020 — removed
in 2021.) The real `Lax`-vs-`Strict` tradeoff is subdomain takeover: with
`Lax`, a compromised subdomain can initiate top-level GET nav with
credentials; with `Strict`, deep-link navigations from external sites lose
session. Apps that use `SameSite=None` for legitimate cross-site needs
(OAuth popups, iframe embeds) have no cookie-level CSRF protection at all.
Recommend `remix-utils/csrf` with a **dedicated** signed cookie — never
reuse the session cookie. Manual `fetch("/api/x", { method: "POST" })`
bypasses `AuthenticityTokenInput`, so any action that does not call
`csrf.validate(request)` is an attacker entry point.
## Gates (decision sequencing)
Answer **in order**. **Pass** means the condition is true; pick the API on the
same line and **stop**.
### Where does this `meta` tag live?
1. **Is it site-wide (charset, viewport, default OG image)?**
- **Pass →** Plain JSX inside `<head>` in `root.tsx`. Avoids the v2
no-merge surprise and prevents duplicate tags. **Stop.**
- **Fail →** Step 2.
2. **Is it route-specific (title, description, canonical, JSON-LD)?**
- **Pass →** `export const meta` in the leaf route file; if you need
parent values, `matches.flatMap((m) => m.meta ?? [])`. **Stop.**
### Auth check: `loader`, `action`, or helper?
1. **Is this a GET (page render) that must be protected?**
- **Pass →** Call `await requireUserId(request)` at the top of the
`loader`. **Stop.**
2. **Is this a POST/PUT/DELETE mutation that must be protected?**
- **Pass →** Call `await requireUserId(request)` at the top of the
`action`, AND call `await csrf.validate(request)`. **Stop.**
3. **Logout?**
- **Pass →** `action` only, never `loader`. A `<Link to="/logout">`
pointing at a loader is CSRF-able via `<img src="/logout">`. Use
`<Form method="post" action="/logout">`. **Stop.**
### Where does the CSRF token live?
1. **Are you using `remix-utils/csrf`?**
- **Pass →** A dedicated `createCookie("csrf", { ... })` cookie, separate
from the session cookie. The CSRF value is a signed string; the
session value is a serialized object — reusing one cookie throws on
validate. **Stop.**
- **Fail →** Step 2.
2. **No CSRF library?**
- **Pass →** Document the threat model; require `sameSite: "strict"` on
the session cookie and verify the `Origin` header in every action.
Prefer adding `remix-utils/csrf` instead.
## Additional Documentation
- **Meta v2**: See [references/meta-v2.md](references/meta-v2.md) for
descriptor types, parent merging via `matches`, JSON-LD, and v1→v2 migration
pitfalls.
- **Links**: See [references/links.md](references/links.md) for stylesheet,
preload, dns-prefetch, and the parent-aggregation behavior of `<Links />`.
- **Sessions**: See [references/sessions.md](references/sessions.md) for
`createCookieSessionStorage` config, `commitSession`/`destroySession`
patterns, flash messages, and database-backed sessions.
- **Auth and CSRF**: See [references/auth-csrf.md](references/auth-csrf.md)
for `requireUserId` helpers, login/logout actions, `remix-auth`, and
`remix-utils/csrf` wiring.
## v1 vs v2 Quick Comparison
| Concern | v1 | v2 |
|---|---|---|
| `meta` return shape | Object `{ title, description }` | Array `[{ title }, { name, content }]` |
| Parent meta merge | Auto-merged (last-write-wins per key) | No merge; last matching route only |
| `meta` argument for parent data | `parentsData` | `matches` (flatMap manually) |
| OG tags | `{ "og:title": "..." }` shorthand | `{ property: "og:title", content: "..." }` |
| Migration flag | `v2_meta: true` future flag | N/A (v2 default) |
don't have the plugin yet? install it then click "run inline in claude" again.
added explicit intent, inputs with env var and external connection setup, 10-step procedure covering session creation through CSRF validation, 9 decision points for meta placement/inheritance/auth/cookies/secrets/edge cases, and clear output contract plus outcome signals for each feature.
Build SEO-safe document heads, secure cookie sessions, auth gates, and CSRF protection in Remix v2. use this skill when you need to set meta tags (title, OG, canonical), manage user sessions with secure cookies, gate routes to authenticated users only, or defend mutations against cross-site request forgery. v2 changed the meta shape from objects to arrays and removed automatic parent merging; this skill covers all four concerns together because they're tightly coupled in auth flows.
environment variables:
SESSION_SECRET (required): at least 32 random characters, used to sign session cookies. rotate by adding old secret to SESSION_SECRET_OLD.NODE_ENV: set to "production" to enforce secure cookies (https-only, strict sameSite).external connections:
remix-utils/csrf: npm package for signed CSRF tokens (separate cookie, not reused from session). set up via createCookie("csrf", { secrets: [...] }).remix-auth: optional npm package for OAuth/social login, manages login/logout flow. requires strategy setup (google, github, etc.).createSessionStorage (slower, but survives server restarts).route-level context:
request.headers.get("Cookie"): always passed to getSession() to hydrate session data.request object in loaders/actions: needed to extract userId, check CSRF, and throw auth redirects.matches array in MetaFunction: passed automatically, used to flatMap parent meta and find parent loader data.input: SESSION_SECRET, SESSION_SECRET_OLD env vars, session data types.
createCookieSessionStorage from @remix-run/node.SessionData (userId, username, etc.) and SessionFlashData (error, success messages).SESSION_SECRET at runtime; throw if missing (do not silently default).[SESSION_SECRET, ...(SESSION_SECRET_OLD ? [SESSION_SECRET_OLD] : [])].createCookieSessionStorage({ cookie: { name: "__session", httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: 60*60*24*30, secrets: [...] } }).{ getSession, commitSession, destroySession } from the module.output: three session functions ready to import. old sessions (signed with SESSION_SECRET_OLD) still validate; new sessions sign with SESSION_SECRET.
input: existing app/root.tsx, route tree.
{ Links, Meta, Scripts, ScrollRestoration, LiveReload, Outlet } from @remix-run/react.<Meta /> and <Links /> inside <head> (after charset/viewport meta tags).<ScrollRestoration />, <Scripts />, <LiveReload /> at the end of <body> (before closing tag).<Outlet /> in <body> to render matched routes.output: root component structure that aggregates all child route meta and links. without this, CSS loads async and meta tags vanish.
input: loader data type, parent route IDs (if merging parent meta), MetaFunction type.
type { MetaFunction } from @remix-run/node.export const meta: MetaFunction<typeof loader, { "routes/parent.$id": typeof parentLoader }> with both generics.[{ title: "..." }, { name: "description", content: "..." }, { property: "og:title", content: "..." }, { tagName: "link", rel: "canonical", href: "..." }].matches.flatMap((m) => m.meta ?? []) first, then append route-specific tags.data?.post is falsy, return [{ title: "Not Found" }] early.output: array of tag descriptors. Remix renders them in <head> in the order returned.
input: getSession, session data type, redirect from @remix-run/node.
{ redirect } from @remix-run/node and { getSession } from ./session.server.export async function requireUserId(request: Request): Promise<string>.request.headers.get("Cookie").getSession(cookies) and read userId via session.get("userId").new URL(request.url) then ${pathname}${search}.redirect(/login?redirectTo=${encodeURIComponent(redirectTo)}) (do not return; throw it).output: synchronous helper that throws 302 redirects on auth failure, short-circuiting the loader.
input: loader function, requireUserId helper, request parameter.
await requireUserId(request) at the top of the loader (before any data fetches).output: GET requests from unauthenticated users redirect to login. authenticated users enter the loader.
input: session object, commitSession function, response headers object.
session.set("userId", newUserId) or session.flash("error", "..."), do not just return json/redirect.await commitSession(session) to serialize the session into a signed cookie string.return redirect("/dashboard", { headers: { "Set-Cookie": await commitSession(session) } }) or return json(data, { headers: { "Set-Cookie": await commitSession(session) } }).output: Set-Cookie response header with signed session cookie. browser stores it for future requests.
input: destroySession function, session object, POST action.
await destroySession(session) (no session.set needed).redirect("/", { headers: { "Set-Cookie": await destroySession(session) } }).output: user is logged out. next request has no session cookie.
input: remix-utils/csrf npm package, separate CSRF cookie, signed secrets.
npm install remix-utils (or yarn add).app/csrf.server.ts, import createCookie from @remix-run/node and csrf functions from remix-utils/csrf.const csrfCookie = createCookie("csrf", { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", secrets: [CSRF_SECRET, ...] }).const { commitToken, validateToken } = await initializeCsrf(csrfCookie, request) in loaders and actions.output: CSRF cookie and token helpers. do not reuse the session cookie; they have different serialization formats and will throw on validate.
input: AuthenticityTokenInput from remix-utils/csrf, loader that calls initializeCsrf.
const { commitToken } = await initializeCsrf(csrfCookie, request).return json({ csrfToken: commitToken() }, { headers: { "Set-Cookie": await csrfCookie.serialize(...) } }).AuthenticityTokenInput from remix-utils/csrf.<AuthenticityTokenInput token={csrfToken} /> inside every <Form method="post">.output: hidden input field with signed CSRF token. form POST includes it automatically.
input: validateToken from csrf setup, action request, form data.
await validateToken(request).validateToken throws a 403.output: cross-site POST requests are rejected. same-origin forms proceed.
<head> in root.tsx. this avoids the v2 no-merge behavior and prevents duplicates.meta from the leaf route. if you need parent values, flatMap matches manually.meta array only. to inherit parent meta, you must explicitly call matches.flatMap((m) => m.meta ?? []) and spread the result into your array.await requireUserId(request) at the top of the loader.await requireUserId(request) AND await validateToken(request) at the top of the action.<img src="/logout">. use <Form method="post" action="/logout"> instead.CSRF_SECRET. validate every POST/PUT/DELETE with validateToken(request). this defends against csrf even if an attacker tricks a user into visiting a malicious site (because the attacker never sees the CSRF cookie value, only the server does).sameSite: "lax" on the session cookie and verify the Origin header in actions. Lax blocks cookies on cross-site POST but allows top-level GET navigations (intended behavior for deep links from external sites). prefer adding remix-utils/csrf instead of rolling your own.SESSION_SECRET to new and SESSION_SECRET_OLD to old.if (!data?.post) return [{ title: "Not Found" }].[], Remix renders no meta tags for that route. parent routes' meta is not inherited.[{ title: data?.name || "Untitled" }].validateToken(request), the request throws 403.fetch("/api/x", { method: "POST", headers: { "csrf-token": token }, body: JSON.stringify(...) }) and validate with custom header check.validateToken() will fail to parse the session object as a CSRF token. throw separately, do not reuse.meta export: returns array of tag descriptor objects. each object has at least one of: title, name + content, property + content, tagName + attributes. Remix renders them as HTML tags in <head> in the order returned. no duplicate tags (deduping is your responsibility).
session functions: getSession(cookieHeader) returns a session object with .get(key) and .set(key, value) methods. commitSession(session) returns a string (cookie header value). destroySession(session) returns a string (cookie header value, max-age=0).
requireUserId function: takes request: Request, returns Promise<string> (userId). throws Response (302 redirect) on auth failure.
CSRF validate function: takes request: Request, returns Promise<string> (the CSRF token). throws Response (403 Forbidden) on invalid/missing token.
Set-Cookie header: placed in response headers, sent to browser. browser parses ; path=/; max-age=...; secure; httpOnly; sameSite=... and stores cookie for future requests. multiple Set-Cookie headers can be sent in one response (for session and CSRF cookies).
meta tags render: view page source (Ctrl+U), search for <title>, <meta name="description">, <meta property="og:title">. if absent, check that meta export returns an array (not an object) and that route is matched.
session persists: log in, refresh the page, still logged in. log out, refresh the page, redirects to login. if you're logged in but session.set() was called and lost, you forgot the Set-Cookie header in the response.
auth redirects work: try accessing a protected route without logging in. you should see a 302 redirect to /login?redirectTo=... in the network tab. the page never renders; the loader throws before returning HTML.
CSRF validates: submit a form with AuthenticityTokenInput. network tab shows 200. submit the same form from a malicious cross-origin site (in console, change the form action to point to your app). network tab shows 403 Forbidden. if forms fail on your own site, check that you're calling validateToken(request) in the action.
session expiry: after maxAge time passes (or manually delete cookie in DevTools), next request shows no session data. refresh the page, redirects to login if protected.
secret rotation: deploy new SESSION_SECRET, keep SESSION_SECRET_OLD. old user sessions still work. new logins use the new secret. after grace period, remove old secret and redeploy.