Remix v2 Performance / SSR Code Review
Targets TypeScript route modules importing from @remix-run/*. See [beagle-react:remix-v2-perf-ssr](../remix-v2-perf-ssr/SKILL.md) for canonical patterns.
Quick Reference
| Issue Type | Reference | |------------|-----------| | Missing headers export, unsafe public cache, child-drops-parent headers, missing Vary: Cookie, Set-Cookie + public | [references/caching-headers.md](references/caching-headers.md) | | Server libs imported without .server.ts, process.env.SECRET_* leaks, typeof window substituted for .server.ts | [references/server-client-split.md](references/server-client-split.md) | | new Date() in render, Math.random() in keys, locale formatting without explicit locale, missing useId(), blanket suppressHydrationWarning | [references/hydration.md](references/hydration.md) | | prefetch="render" on every link, defer for fast data, missing <Suspense> around <Await>, prefetch to side-effect routes | [references/prefetch-streaming.md](references/prefetch-streaming.md) | | dangerouslySetInnerHTML with untrusted data, missing loading="lazy", missing links preload, stylesheet injected in body | [references/assets.md](references/assets.md) |
Review Checklist
- [ ] Routes serving data export
headers(even if the answer isno-store) - [ ] Child routes serving personalized data export their own
headers(otherwise they silently inherit the parent's policy) - [ ]
Cache-Control: publicis never set on auth'd or cookie-bearing responses - [ ]
Vary: Cookieis set when cache decision depends on session - [ ] Server-only libs (
prisma,bcrypt,node:fs,jsonwebtoken) live in*.server.tsorapp/.server/ - [ ] Secret env (
process.env.STRIPE_SECRET_KEY, etc.) is read only inside loaders/actions or.servermodules - [ ] Client-exposed env is whitelisted into
window.ENV, never rawprocess.env - [ ]
typeof window === "undefined"is not used as a substitute for.server.ts(treeshaking is unreliable) - [ ] No
new Date(),Math.random(),Date.now(),crypto.randomUUID()in JSX render path - [ ] Locale formatting (
toLocaleDateString,Intl.DateTimeFormat) passes an explicit locale - [ ] Components generating IDs use
useId(), notMath.random()or counters - [ ]
suppressHydrationWarningis scoped to a single element with a code comment explaining why - [ ]
<Link prefetch="render">is reserved for above-the-fold critical nav, not lists - [ ]
<PrefetchPageLinks>does not target routes whose loaders have side effects (analytics, mutations) - [ ] Every
<Await>is wrapped in<Suspense>and has anerrorElement - [ ]
defer()is used only for genuinely slow data (>~50ms); fast data is awaited - [ ] Below-the-fold images use
loading="lazy"and havewidth/height - [ ] Critical fonts/CSS are preloaded via the
linksexport, not injected in body
Valid Patterns (Do NOT Flag)
These are correct Remix v2 usage and must not be reported as issues:
- Route without
headersexport when caching is intentionally off — auth'd dashboards, account pages, and routes wrapped in a layout that already returnsno-storemay legitimately omitheaders. Flag only if the route serves cacheable public content with noheaders. new Date()insideuseEffect— runs after hydration on the client only; no SSR mismatch possible. Same forDate.now(),Math.random(),crypto.randomUUID()inside effects.Math.random()/new Date()inside event handlers — handlers run after hydration. Only flag when the value is used during render.suppressHydrationWarningon a single<time>(or similar) element with a clear comment — accepted narrow escape for known-divergent values like absolute timestamps formatted client-side. Flag only when applied at a parent that wraps a large subtree or with no explanation..client.tsfiles for client-only libraries — Stripe.js, map widgets, chart libs that readwindowbelong in*.client.tsby convention; do not flag the file extension.useId()with extra characters appended — `${id}-input` is the documented pattern for multi-element components; do not flag as "non-stable id."- Raw ISO string rendered in SSR + reformatted in
useEffect— the canonical hydration-safe time pattern; flag only if the reformat happens in render. headersexport returning{}orno-store— explicit "do not cache" is a deliberate decision and should not be flagged as misuse.<Link prefetch="intent">on standard nav — the recommended default; flag only when the loader has side effects.loaderHeadersforwarded to the document viaheadersexport — co-locating data and document policy is the documented pattern, not duplication.
Context-Sensitive Rules
Apply these only when the specific context applies:
| Issue | Flag ONLY IF | |-------|--------------| | Missing headers export | Route serves cacheable public content (not auth'd, not personalized, not intentionally no-store) | | Child route missing headers | An ancestor exports headers AND its policy is broader than the child's cacheability (e.g., parent caches public + s-maxage, child serves personalized data) | | Cache-Control: public | Loader actually reads session / user state (or response carries Set-Cookie) | | Vary: Cookie missing | Loader branches response shape on a cookie (theme, locale, session) AND the cache is public/s-maxage | | new Date() / Math.random() / Date.now() | Call site is in render path — NOT in useEffect, event handler, <ClientOnly>, or post-hydration code | | Locale formatting without locale | Result is rendered into JSX (not used only inside an effect / handler) | | <Link prefetch="render"> | Link is inside a list / .map() iterator (not above-the-fold critical nav) | | <Link prefetch="intent"> to side-effect loader | Loader has observable side effects (analytics write, counter increment, log emit) AND doesn't branch on the Purpose: prefetch header | | Server lib import without .server.ts | Importing file is reachable from the client graph (route module, non-.server util reached from a component) | | process.env.SECRET_* reference | Reference is in a component body or in a non-.server module reached from the client graph | | Missing loading="lazy" on image | Image is rendered below the fold (not in <header>, hero section, or above any <main> content) | | Missing width/height on image | Project does NOT use a build-time image processor that injects dimensions |
Hard gates (before writing findings)
Run these in order. Do not draft user-facing findings until every gate passes for the batch you are about to report.
- Location evidence — Pass: Each issue lists the repo path and either a line range or a short verbatim quote from the file you read (not memory or diff-only guesswork). Cache, hydration, and
.serverclaims without a concrete file path are not reportable.
- Exemption check — Pass: For each issue, you can state in one line why it is not covered by [Valid Patterns (Do NOT Flag)](#valid-patterns-do-not-flag). In particular: confirm a missing
headersexport is not on an intentionally-uncacheable route, confirm.client.tsis not a legitimate client-only library, confirmsuppressHydrationWarningis not scoped + commented.
- Hydration-context check — Pass: Before flagging
new Date(),Math.random(),Date.now(),crypto.randomUUID(), or locale formatting, confirm the call site is in the render path of a component. Calls insideuseEffect,useLayoutEffect, event handlers, callbacks passed tosetTimeout/requestAnimationFrame, or inside<ClientOnly>{() => ...}</ClientOnly>are post-hydration and must not be flagged.
- Parent/child headers chain check — Pass: Before flagging "missing
headerson a child" as silent cache inheritance, confirm an ancestor