
Remix V2 Meta Sessions
- 27 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-meta-sessions is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-meta-sessions
- AI & Agent Building
- AI-coding skill
Remix V2 Meta Sessions by the numbers
- 27 all-time installs (skills.sh)
- Ranked #9,597 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/existential-birds/beagle --skill remix-v2-meta-sessionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 27 |
|---|---|
| repo stars | ★ 74 |
| Last updated | July 21, 2026 |
| Repository | existential-birds/beagle ↗ |
What it does
Helps with ai & agent building tasks.
Files
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.
// 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:
// 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.
// 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:
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.
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.
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.
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.
// 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.
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>inroot.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 metain 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 →
actiononly, neverloader. 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 for
descriptor types, parent merging via matches, JSON-LD, and v1→v2 migration pitfalls.
- Links: See references/links.md for stylesheet,
preload, dns-prefetch, and the parent-aggregation behavior of <Links />.
- Sessions: See references/sessions.md for
createCookieSessionStorage config, commitSession/destroySession patterns, flash messages, and database-backed sessions.
- Auth and CSRF: See 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) |
Auth and CSRF Reference
Remix v2 ships no built-in auth and no built-in CSRF protection. The conventional patterns are a requireUserId(request) helper that throws redirect() from loaders and actions, optionally paired with remix-auth for strategy-based flows, plus remix-utils/csrf for token-based CSRF.
The requireUserId Helper
The canonical Remix auth pattern is a helper that loads the session, returns the userId if present, and throws redirect(...) otherwise. Thrown responses short-circuit the loader; there's no top-level return needed at the call site.
// 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;
}
// Optional "may be logged out" variant.
export async function getUserId(request: Request): Promise<string | null> {
const session = await getSession(request.headers.get("Cookie"));
return session.get("userId") ?? null;
}Usage in a protected loader:
// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { requireUserId } from "~/auth.server";
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const data = await getDashboardData(userId);
return json({ data });
}Same pattern works in actions — call requireUserId first, then run the mutation. Treating auth as the first line of every protected loader and action is the SSR-correct approach: unauthenticated users never see the protected component's HTML, never receive its loader data, and never get a flash of protected content.
Auth Checks in React Components Are Wrong
// DO NOT DO THIS
function Dashboard() {
const user = useUser();
if (!user) return <Navigate to="/login" />;
return <ProtectedContent />;
}The component still SSRs and ships its HTML to unauthenticated users. The loader's data is already in the document. The <Navigate> redirect fires after hydration — a visible flash of protected content. Always gate at the loader.
Login Action
// app/routes/login.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getSession, commitSession } from "~/session.server";
import { verifyLogin } from "~/models/user.server";
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const form = await request.formData();
const email = String(form.get("email") ?? "");
const password = String(form.get("password") ?? "");
const user = await verifyLogin(email, password);
if (!user) {
session.flash("error", "Invalid email or password");
return redirect("/login", {
headers: { "Set-Cookie": await commitSession(session) },
});
}
session.set("userId", user.id);
return redirect("/dashboard", {
headers: { "Set-Cookie": await commitSession(session) },
});
}Failed-login response uses session.flash so the message survives the redirect and clears on the next read. Successful login sets userId and redirects; both paths attach commitSession as Set-Cookie.
Logout Action
Logout MUST live in an action, never a loader. A <Link to="/logout"> pointing at a loader is CSRF-able via <img src="/logout"> — any third-party page can force-logout your users.
// app/routes/logout.tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { getSession, destroySession } from "~/session.server";
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/login", {
headers: { "Set-Cookie": await destroySession(session) },
});
}In the UI:
<Form method="post" action="/logout">
<button type="submit">Log out</button>
</Form>remix-auth Brief Overview
For multi-strategy auth (OAuth, magic links, multiple providers), remix-auth provides an Authenticator<User> that wraps your session storage and runs strategy classes.
// app/auth.server.ts
import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";
import { sessionStorage } from "~/session.server";
// Snippet targets remix-auth@^2; v3 dropped the sessionStorage argument from the constructor.
export const authenticator = new Authenticator<User>(sessionStorage);
authenticator.use(
new FormStrategy(async ({ form }) => {
const email = String(form.get("email"));
const password = String(form.get("password"));
const user = await verifyLogin(email, password);
if (!user) throw new Error("Invalid credentials");
return user;
}),
"user-pass",
);In an action:
return authenticator.authenticate("user-pass", request, {
successRedirect: "/dashboard",
failureRedirect: "/login",
});remix-auth requires session storage and writes the user into the session itself. If you already have a working requireUserId helper, adding remix-auth only makes sense once you have multiple strategies to manage.
CSRF: Remix Has None
Remix ships no CSRF tooling. Same-origin <Form> posts rely entirely on the SameSite value of the session cookie. SameSite=Lax blocks most cross-origin POSTs but allows top-level GET navigations and has a known exception for <form method="post"> POSTs in some browsers. Subdomain takeovers and older browsers leave further gaps.
The community answer is remix-utils/csrf — a separate signed cookie that issues per-session tokens, validated in every mutating action.
CSRF Setup with remix-utils
The CSRF token lives in its OWN cookie, never the session cookie. The session cookie holds a serialized object; the CSRF cookie holds a signed string. Reusing one cookie for both throws on validate.
// app/utils/csrf.server.ts
import { createCookie } from "@remix-run/node";
import { CSRF } from "remix-utils/csrf/server";
export const csrfCookie = createCookie("csrf", {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: [process.env.CSRF_SECRET!],
});
export const csrf = new CSRF({
cookie: csrfCookie,
secret: process.env.CSRF_SECRET!,
});Issue the token in root.tsx's loader and provide it to the component tree:
// app/root.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { Outlet, Scripts, ScrollRestoration, useLoaderData } from "@remix-run/react";
import { AuthenticityTokenProvider } from "remix-utils/csrf/react";
import { csrf } from "~/utils/csrf.server";
export async function loader({ request }: LoaderFunctionArgs) {
const [token, cookieHeader] = await csrf.commitToken(request);
return json(
{ csrf: token },
{ headers: cookieHeader ? { "Set-Cookie": cookieHeader } : {} },
);
}
export default function App() {
const { csrf: token } = useLoaderData<typeof loader>();
return (
<html lang="en">
{/* head ... */}
<body>
<AuthenticityTokenProvider token={token}>
<Outlet />
</AuthenticityTokenProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}Attach the token to every mutating form:
import { Form } from "@remix-run/react";
import { AuthenticityTokenInput } from "remix-utils/csrf/react";
export default function CommentForm() {
return (
<Form method="post">
<AuthenticityTokenInput />
<textarea name="body" />
<button type="submit">Post</button>
</Form>
);
}For useFetcher().submit(...) calls, use useAuthenticityToken() to read the token and include it in the submission payload manually.
Validating in Actions
Every mutating action must call csrf.validate(request). Skip it and the action becomes the entry point an attacker uses.
import type { ActionFunctionArgs } from "@remix-run/node";
import { CSRFError } from "remix-utils/csrf/server";
import { csrf } from "~/utils/csrf.server";
export async function action({ request }: ActionFunctionArgs) {
try {
await csrf.validate(request);
} catch (err) {
if (err instanceof CSRFError) {
throw new Response("Bad CSRF", { status: 403 });
}
throw err;
}
// ...continue with mutation
}CSRFError exposes codes (missing_token_in_cookie, invalid_token_in_cookie, tampered_token_in_cookie, missing_token_in_body, mismatched_token) for finer-grained handling if needed.
The Manual fetch POST Bypass
Hand-rolled fetch("/api/x", { method: "POST" }) skips AuthenticityTokenInput entirely, so csrf.validate(request) throws. The fix is to use <Form> or useFetcher().submit(...), which round-trip through the form-data submission path the token wiring expects. If you genuinely need a JSON fetch, manually attach the token in the body or header — and audit every mutating action to confirm csrf.validate is the first thing it does. An action without validation, reachable via plain fetch, is an open CSRF hole regardless of any other defense.
Links Reference
The Remix v2 links route export returns an array of LinkDescriptor objects, mirroring the shape of meta. <Links /> (from @remix-run/react) placed inside <head> in root.tsx aggregates every matched route's links into the document head.
Imports
import type { LinksFunction } from "@remix-run/node";A LinkDescriptor is one of two unions:
HtmlLinkDescriptor— fields mirror the<link>element. Per Remix v2
the rel type is LiteralUnion<"alternate" | "dns-prefetch" | "icon" | "manifest" | "modulepreload" | "next" | "pingback" | "preconnect" | "prefetch" | "preload" | "prerender" | "search" | "stylesheet", string> — any string is accepted at compile time; common non-literal values include canonical and apple-touch-icon. Other fields: href, as, type, media, crossOrigin, imageSrcSet, imageSizes, integrity, disabled, hrefLang.
PrefetchPageDescriptor—{ page: string }. Tells Remix to preload the
module graph and loader data for the given route path.
Stylesheets
The most common use is route-scoped stylesheets:
// app/routes/dashboard.tsx
import type { LinksFunction } from "@remix-run/node";
import dashboardStyles from "~/styles/dashboard.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: dashboardStyles },
];When the user navigates away from /dashboard, Remix removes its stylesheet from the document head. Route-scoped stylesheets prevent CSS bleeding between unrelated parts of the app.
Preload, dns-prefetch, preconnect
import type { LinksFunction } from "@remix-run/node";
export const links: LinksFunction = () => [
{ rel: "dns-prefetch", href: "https://cdn.example.com" },
{ rel: "preconnect", href: "https://cdn.example.com", crossOrigin: "anonymous" },
{
rel: "preload",
as: "image",
href: "/img/hero.jpg",
imageSrcSet: "/img/hero-sm.jpg 480w, /img/hero-lg.jpg 1200w",
imageSizes: "(max-width: 600px) 480px, 1200px",
},
];imageSrcSet and imageSizes mirror the responsive-image attributes from <img srcset> / <img sizes>; together they let the browser pick the right asset to preload for the current viewport.
Page Preloads
PrefetchPageDescriptor triggers a module and loader-data preload for a route the user is likely to visit:
export const links: LinksFunction = () => [
{ page: "/dashboard" },
];Use sparingly — every preloaded page fires its loaders. Reserve for genuinely likely next-clicks (a marketing page that almost always leads to /signup, for example).
Canonical and Alternate
While tagName: "link" inside the meta export can emit <link> elements, dedicated SEO-stable links (canonical, alternate) live better in links:
export const links: LinksFunction = () => [
{ rel: "canonical", href: "https://example.com/posts/intro" }, // canonical is not in the literal union; accepted via the open-string fallback
{ rel: "alternate", hrefLang: "fr", href: "https://example.com/fr/posts/intro" },
];Per-request canonical URLs (those that depend on the request URL) still belong in the meta export via { tagName: "link", rel: "canonical" } so they can read location and data.
Favicons and Icons
export const links: LinksFunction = () => [
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
{ rel: "apple-touch-icon", href: "/apple-touch-icon.png" },
];These typically live in root.tsx's links export so they appear on every page.
Parent Aggregation Behavior
Unlike meta, <Links /> does aggregate across the matched route tree. Every matched route contributes its links descriptors to the document head; on navigation, Remix removes the ones from routes that are no longer matched.
This is the behavior most developers expect for stylesheets: the root stylesheet stays, the dashboard stylesheet appears when you navigate to /dashboard, and it disappears when you leave.
Root Document Scaffolding
// app/root.tsx
import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import globalStyles from "~/styles/global.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: globalStyles },
{ rel: "icon", href: "/favicon.svg", type: "image/svg+xml" },
];
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>
);
}If <Links /> is omitted from <head>, no stylesheets load and no preload hints fire — the symptom is "css is broken in production" with no errors in the console.
Meta v2 Reference
The Remix v2 meta route export returns an array of `MetaDescriptor` objects. This is the single most important fact about v2 meta: a v1-style object literal ({ title, description }) typechecks against the old MetaFunction signature in stale codebases but produces zero rendered meta tags at runtime.
Imports
import type { MetaFunction } from "@remix-run/node";
// MetaDescriptor union type is exported from @remix-run/react
import type { MetaDescriptor } from "@remix-run/react";<Meta /> (from @remix-run/react) is the aggregator placed inside <head> in root.tsx. It walks the matched route tree and renders descriptors from the last matching route's meta export.
Descriptor Types
A MetaDescriptor is a union of:
| Shape | Renders as |
|---|---|
{ title: string } | <title> |
{ name: string, content: string } | <meta name=... content=...> |
{ property: string, content: string } | <meta property=... content=...> (OG, Twitter) |
{ httpEquiv: string, content: string } | <meta http-equiv=... content=...> |
{ charSet: "utf-8" } | <meta charset="utf-8"> |
{ tagName: "link", ...HtmlLinkAttrs } | <link> (canonical, alternate) |
{ "script:ld+json": object } | <script type="application/ld+json"> |
Use the explicit { property, content } shape for any og:* or twitter:* tag — the v1 shorthand { "og:title": "..." } is silently dropped in v2.
Complete Example
// app/routes/posts.$slug.tsx
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug);
if (!post) throw new Response("Not Found", { status: 404 });
return json({ post });
}
export const meta: MetaFunction<typeof loader> = ({ data, location }) => {
// Null-guard: meta runs on error/404 too; data may be undefined.
if (!data?.post) return [{ title: "Not Found" }];
const url = `https://example.com${location.pathname}`;
return [
{ title: `${data.post.title} | My Blog` },
{ name: "description", content: data.post.excerpt },
{ property: "og:title", content: data.post.title },
{ property: "og:description", content: data.post.excerpt },
{ property: "og:url", content: url },
{ property: "og:type", content: "article" },
{ name: "twitter:card", content: "summary_large_image" },
{ tagName: "link", rel: "canonical", href: url },
{
"script:ld+json": {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: data.post.title,
datePublished: data.post.publishedAt,
author: { "@type": "Person", name: data.post.authorName },
},
},
];
};The matches Argument (parent meta access)
v2 replaces v1's parentsData with a matches array. Each match exposes id, pathname, params, data, handle, and meta — the descriptors the parent route's meta function returned (or would return).
import type { MetaFunction } from "@remix-run/node";
export const meta: MetaFunction = ({ matches }) => {
const parentMeta = matches
.flatMap((m) => m.meta ?? [])
// Child overrides the title; everything else from parents is kept.
.filter((tag) => !("title" in tag));
return [...parentMeta, { title: "Dashboard" }];
};To read a specific parent loader's data:
import type { MetaFunction } from "@remix-run/node";
import type { loader as rootLoader } from "~/root";
export const meta: MetaFunction<typeof loader, { root: typeof rootLoader }> = ({
data,
matches,
}) => {
const rootMatch = matches.find((m) => m.id === "root");
const siteName = rootMatch?.data?.siteName ?? "Site";
return [{ title: `${data.title} | ${siteName}` }];
};Parent Merge Behavior (v1 vs v2)
v1 merged parent + child meta automatically with last-write-wins per key. v2 picks the array returned by the last matching route only; sibling routes are not combined and parent descriptors are NOT inherited unless the child opts in via matches.
Consequence: if root.tsx defines its title via a meta export, every leaf route that needs a title must either return a { title } of its own OR flatMap the parent meta. Most teams sidestep this by putting charset, viewport, and any truly site-wide tags as plain JSX inside <head>:
// app/root.tsx
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>This keeps site-wide tags out of the merge logic entirely.
JSON-LD (script:ld+json)
The "script:ld+json" descriptor is the only supported way to emit structured-data scripts through the meta export. The value is a plain object that Remix serializes as JSON.
{
"script:ld+json": {
"@context": "https://schema.org",
"@type": "Organization",
name: "Example Corp",
url: "https://example.com",
},
}Two routes both emitting script:ld+json with identical contents will produce a React "same key" warning. Remix does not auto-deduplicate; keep JSON-LD blocks in a single route (typically the leaf), not in root plus the leaf.
v1 → v2 Migration Pitfalls
1. Object return still typechecks against the v1 `MetaFunction`. Grep for export const meta followed by return { or => ({. If the function returns an object literal, the route renders no meta tags. Convert to an array of descriptors.
2. `parentsData` was removed. Replace with matches. The @remix-run/v1-meta compatibility package exposes getMatchesData() for gradual migration; new code should use matches directly.
3. OG shorthand keys are dropped. Replace { "og:title": "..." } with { property: "og:title", content: "..." }.
4. Default merge reversed. Child routes inherit nothing from parents unless they merge via matches.flatMap. Codebases relying on inherited titles will render the parent's title on the leaf — or no title at all.
5. `v2_meta: true` future flag was the migration switch in late v1. Codebases that ran with it enabled before upgrading rarely have issues; codebases that jumped straight to v2 with v1-shaped meta exports are the ones that need cleanup.
6. `charset` and `viewport` rendered through the `meta` export can duplicate when parent merge happens manually. Most teams keep both as inline JSX in root.tsx to avoid the issue.
Client-Side document.title Is Wrong
Setting document.title = "..." (or doing the same inside useEffect) bypasses SSR — search bots and social previews see the default title, and users see a visible flash on hydration. Always use the meta export with { title }. If the title must update from client state, return it from a loader and re-render via revalidation.
Sessions Reference
Remix v2 sessions are built around a storage factory that returns three functions: getSession, commitSession, and destroySession. The factory you choose controls where session data lives; the API is identical for all of them.
Storage Factories
| Factory | Import | When to use |
|---|---|---|
createCookieSessionStorage<Data, Flash> | @remix-run/node | Default. Payload lives in the cookie itself (signed, size-limited). |
createMemorySessionStorage | @remix-run/node | Dev-only. Lost on restart; not multi-process safe. |
createFileSessionStorage | @remix-run/node | Single-server Node/Deno deployments. |
createSessionStorage | @remix-run/node | Custom backend (DB, Redis) via createData / readData / updateData / deleteData. |
For most apps createCookieSessionStorage is correct; cookies are signed, stateless, and require no separate session store. Switch to createSessionStorage only when payload size exceeds ~4 KB or you need server-side revocation.
Secure Cookie Configuration
Remix sets NO secure defaults. Every flag is caller-responsibility. Missing httpOnly exposes the session to XSS; missing secure lets the cookie leak over HTTP; missing sameSite opens CSRF surface.
// 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, // 30 days
secrets: [
SESSION_SECRET,
...(process.env.SESSION_SECRET_OLD ? [process.env.SESSION_SECRET_OLD] : []),
],
},
});Notes on each flag:
name: Prefix__is a convention for "framework-owned" cookies; pick
something distinct from app data cookies.
httpOnly: true: Blocksdocument.cookieaccess from JavaScript.
Mandatory.
secure: Read from env. Hardcodingtruebreakshttp://localhostdev;
hardcoding false ships an insecure cookie to production.
sameSite:"lax"is the modern default. Use"strict"if you can
accept that links from external sites to authenticated pages will land the user logged-out on the first request. "none" requires secure: true.
path: "/": Cookie sent on every request to the domain.maxAge: Seconds. Omit for a session-lifetime cookie that disappears
when the browser closes.
domain: Set explicitly only when sharing the session across subdomains.secrets: An array. See secret rotation below.
Secret Rotation
Remix signs new cookies with secrets[0] and verifies incoming cookies against any entry in the array. Rotation is therefore a prepend:
secrets: [
process.env.SESSION_SECRET, // new — used for signing
process.env.SESSION_SECRET_OLD, // old — still validates existing sessions
],Replacing the secret outright (instead of prepending) instantly invalidates every existing session. After maxAge has elapsed since the rotation, you can drop the old entry.
Never commit a real secret to source or to .env.example. Anyone with the secret can forge session cookies.
Reading and Writing Sessions
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { getSession, commitSession } from "~/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const userId = session.get("userId");
return json({ userId });
}The Session instance exposes get, set, has, unset, and flash.
commitSession Is Mandatory on Mutations
Remix does NOT auto-commit. Calling session.set("userId", id) and then return json(data) ships zero Set-Cookie header — the change exists only in memory and is dropped when the response sends.
Every response that touches the session must attach the committed cookie:
import { redirect } from "@remix-run/node";
import { getSession, commitSession } from "~/session.server";
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
session.set("userId", user.id);
return redirect("/dashboard", {
headers: { "Set-Cookie": await commitSession(session) },
});
}Same pattern works with json:
return json(data, {
headers: { "Set-Cookie": await commitSession(session) },
});destroySession for Logout
import { redirect } from "@remix-run/node";
import { getSession, destroySession } from "~/session.server";
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/login", {
headers: { "Set-Cookie": await destroySession(session) },
});
}destroySession returns a Set-Cookie header with an expired date, clearing the cookie in the browser. Logout MUST live in an action, not a loader — a <Link to="/logout"> pointing at a loader is CSRF-able via <img src="/logout">. Use <Form method="post" action="/logout">.
Flash Messages
session.flash(key, value) writes a value that is automatically cleared the first time it's read with session.get(key). Useful for one-shot status messages (form errors, success toasts) that survive a single redirect.
// login action, failure case
const session = await getSession(request.headers.get("Cookie"));
session.flash("error", "Invalid email or password");
return redirect("/login", {
headers: { "Set-Cookie": await commitSession(session) },
});
// login loader, reading the flash
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get("Cookie"));
const error = session.get("error");
return json(
{ error },
{ headers: { "Set-Cookie": await commitSession(session) } },
);
}The loader must call commitSession after reading the flash — that's what writes the cleared state back to the cookie. Skipping the commit either keeps the message forever (if not read) or never clears it (depending on which side of the bug you hit).
The typed second parameter on createCookieSessionStorage<Data, Flash> distinguishes flash keys from regular session keys at the type level, but both forms write to the same cookie.
Database-Backed Sessions
When cookies aren't enough (large payloads, server-side revocation), use createSessionStorage:
import { createCookie, createSessionStorage } from "@remix-run/node";
const sessionCookie = createCookie("__session", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET!],
});
export const { getSession, commitSession, destroySession } = createSessionStorage({
cookie: sessionCookie,
async createData(data, expires) {
const id = await db.session.insert({ data, expires });
return id;
},
async readData(id) {
return db.session.find(id);
},
async updateData(id, data, expires) {
await db.session.update(id, { data, expires });
},
async deleteData(id) {
await db.session.delete(id);
},
});The cookie holds only the session id; payload lives in your database. Public API is identical to createCookieSessionStorage.