mcpField
R
remix-v2-meta-sessions

remix-v2-meta-sessions

github.com/existential-birdsDevelopment8540 installsUpdated 11w ago
Repository

About remix-v2-meta-sessions

A reference skill for reviewing Remix v2 metadata, sessions, authentication, and CSRF handling. It explains version-specific APIs and provides decision sequences for route metadata and protected requests.

Key features

  1. 01Remix v2 meta descriptor guidance
  2. 02Parent metadata merging patterns
  3. 03Session commit and flash handling
  4. 04Loader and action authentication patterns
  5. 05CSRF threat and token guidance

Use cases

  1. 01Reviewing route metadata behavior
  2. 02Checking session persistence and flash messages
  3. 03Auditing protected loaders and actions
  4. 04Assessing CSRF handling for mutations

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/existential-birds/beagle --skill remix-v2-meta-sessions

After installing, type/remix-v2-meta-sessionsin Claude Code to confirm it loaded.

Bundled files

References and scripts installed along with SKILL.md.

references/
auth-csrf.md
links.md
meta-v2.md
sessions.md

Details

From the project README

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

Concernv1v2
meta return shapeObject { title, description }Array [{ title }, { name, content }]
Parent meta mergeAuto-merged (last-write-wins per key)No merge; last matching route only
meta argument for parent dataparentsDatamatches (flatMap manually)
OG tags{ "og:title": "..." } shorthand{ property: "og:title", content: "..." }
Migration flagv2_meta: true future flagN/A (v2 default)

Comments

Markdown supported