
Remix V2 Perf Ssr Review
- 27 installs
- 74 repo stars
- Updated July 21, 2026
- existential-birds/beagle
Helps with ai & agent building tasks.
About
remix-v2-perf-ssr-review is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- remix-v2-perf-ssr-review
- AI & Agent Building
- AI-coding skill
Remix V2 Perf Ssr Review by the numbers
- 27 all-time installs (skills.sh)
- Ranked #9,560 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-perf-ssr-reviewAdd 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 Performance / SSR Code Review
Targets TypeScript route modules importing from @remix-run/*. See remix-v2-perf-ssr 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 |
Server libs imported without .server.ts, process.env.SECRET_* leaks, typeof window substituted for .server.ts | 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 |
prefetch="render" on every link, defer for fast data, missing <Suspense> around <Await>, prefetch to side-effect routes | references/prefetch-streaming.md |
dangerouslySetInnerHTML with untrusted data, missing loading="lazy", missing links preload, stylesheet injected in body | 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 `headers` export when caching is intentionally off — auth'd dashboards, account pages, and routes wrapped in a layout that already returns
no-storemay legitimately omitheaders. Flag only if the route serves cacheable public content with noheaders. - `new Date()` inside `useEffect` — runs after hydration on the client only; no SSR mismatch possible. Same for
Date.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.
- `suppressHydrationWarning` on 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.ts` files for client-only libraries — Stripe.js, map widgets, chart libs that read
windowbelong 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.
- `headers` export returning `{}` or `no-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.
- `loaderHeaders` forwarded to the document via `headers` export — 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.
1. 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 .server claims without a concrete file path are not reportable.
2. Exemption check — Pass: For each issue, you can state in one line why it is not covered by Valid Patterns (Do NOT Flag). In particular: confirm a missing headers export is not on an intentionally-uncacheable route, confirm .client.ts is not a legitimate client-only library, confirm suppressHydrationWarning is not scoped + commented.
3. 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 inside useEffect, useLayoutEffect, event handlers, callbacks passed to setTimeout/requestAnimationFrame, or inside <ClientOnly>{() => ...}</ClientOnly> are post-hydration and must not be flagged.
4. Parent/child headers chain check — Pass: Before flagging "missing headers on a child" as silent cache inheritance, confirm an ancestor route in the matched chain actually exports headers (search the route file tree for export const headers or export function headers) AND that the inherited policy is wider than the child's cacheability profile. If no ancestor exports headers, the issue is just "no caching configured," not "child silently inherits parent's cache."
5. Server/client boundary check — Pass: Before flagging a server-lib import as a leak, confirm the importing file is reachable from the client graph — i.e., it's a route module, a non-.server utility transitively imported by a route's default export, or a .client.ts file. Imports inside loader, action, headers, or other .server.ts modules are not leaks.
6. Protocol — Pass: You completed the Pre-Report Verification Checklist in review-verification-protocol for this review.
When to Load References
- Reviewing route
headersexports,Cache-Controlstrings, CDN policy → references/caching-headers.md - Reviewing
.server.ts/.client.tsfiles, imports ofprisma/bcrypt/fs, orprocess.envaccess → references/server-client-split.md - Reviewing any component that renders dates, IDs, locale-formatted values, or browser globals → references/hydration.md
- Reviewing
<Link prefetch>,<PrefetchPageLinks>,defer,<Await>,<Suspense>, or<RemixServer abortDelay>→ references/prefetch-streaming.md - Reviewing
<img>,<link>,dangerouslySetInnerHTML, font/CSS loading, orlinksexport → references/assets.md
Review Questions
1. Does every route that serves cacheable data declare a Cache-Control policy, even if "no cache"? 2. Are personalized routes free of public caching, with Vary: Cookie where session influences the response? 3. Do server libs (prisma, bcrypt, fs, secret env access) live in *.server.ts modules that the build will reject if leaked? 4. Are public env vars whitelisted into window.ENV rather than spread from process.env? 5. Are new Date() / Math.random() / locale formatting calls limited to effects, handlers, or <ClientOnly> — not render? 6. Do components needing IDs use useId()? 7. Are <Link prefetch> modes matched to context (render only above the fold, intent for nav, viewport/intent in lists)? 8. Is defer() used only for genuinely slow data, with <Await> always paired with <Suspense> AND errorElement? 9. Are images sized, lazy-loaded below the fold, and critical fonts/CSS preloaded via the links export? 10. Is dangerouslySetInnerHTML used only with sanitized HTML or safely serialized JS?
Before Submitting Findings
Complete Hard gates (especially gate 3 — hydration-context check, and gate 5 — server/client boundary check), then report only issues that still pass the review-verification-protocol pre-report checks. Finding format: [FILE:LINE] ISSUE_TITLE with a verbatim quote of the offending code and a one-line rationale tied to the specific Remix v2 contract being violated.
Additional Documentation
- Reviewing
headersexports, CDN cache policy,Vary, parent/child merge,Set-Cookieinteractions → references/caching-headers.md - Reviewing
.server.ts/.client.tsboundaries,process.envaccess,window.ENVpattern → references/server-client-split.md - Reviewing render-time
Date/Math.random/locale issues,useId,suppressHydrationWarningscope → references/hydration.md - Reviewing
<Link prefetch>modes,<PrefetchPageLinks>targets,defer/<Await>/<Suspense>structure → references/prefetch-streaming.md - Reviewing
dangerouslySetInnerHTML, imageloading/width/height, font/CSS preload, stylesheet placement → references/assets.md - Canonical patterns and decision gates → remix-v2-perf-ssr
Assets, Images, Fonts & CSS Review
Anti-patterns in dangerouslySetInnerHTML, image attributes, links preload, and stylesheet placement.
What to flag
1. dangerouslySetInnerHTML with untrusted content
The literal definition of an XSS sink. Even "trusted" data fails: JSON.stringify does not escape </script> or U+2028 / U+2029 line separators, so an object containing the literal substring </script> breaks out of the script context.
Bad (untrusted):
<div dangerouslySetInnerHTML={{ __html: post.body }} /> // user-authored contentBad (trusted but unsafe encoding):
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(data.ENV)}`, // </script> in data breaks out
}}
/>Good (sanitize untrusted HTML):
import sanitize from "isomorphic-dompurify";
<div dangerouslySetInnerHTML={{ __html: sanitize(post.body) }} />Good (serialize JS safely):
import serialize from "serialize-javascript";
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${serialize(data.ENV, { isJSON: true })}`,
}}
/>Report as: [FILE:LINE] UNSAFE_INNER_HTML — dangerouslySetInnerHTML with untrusted input or JSON.stringify for script injection.
2. Below-the-fold images missing loading="lazy"
Without loading="lazy", every <img> is fetched immediately on parse, bloating the critical path. For images below the fold (testimonials, footer logos, gallery items beyond initial viewport), this is wasted bandwidth and delayed LCP.
Bad:
<img src="/screenshots/feature.png" alt="Feature screenshot" />
{/* in a section below the hero */}Good:
<img
src="/screenshots/feature.png"
alt="Feature screenshot"
loading="lazy"
decoding="async"
width="1200"
height="630"
/>Report as: [FILE:LINE] MISSING_LOADING_LAZY — <img> rendered below the fold without loading="lazy".
Do not flag:
- Above-the-fold images (hero, logo in header) — they should NOT be lazy
- Images marked
fetchpriority="high" <img>inside<picture>where the<picture>source set is intentionally eager
3. Images missing width and height attributes
Without explicit dimensions, the browser reserves zero space until the image loads, causing layout shift (CLS hit). Set the intrinsic dimensions (the file's actual width/height in pixels), even when the CSS resizes the image.
Bad:
<img src="/avatar.png" alt="" className="w-12 h-12 rounded-full" />Good:
<img
src="/avatar.png"
alt=""
width="48"
height="48"
className="w-12 h-12 rounded-full"
/>Report as: [FILE:LINE] MISSING_IMG_DIMENSIONS — <img> rendered without width and height attributes.
Do not flag: images sized via CSS aspect-ratio with explicit style={{ aspectRatio: "16/9" }} and width="100%" — that's an alternative CLS-safe pattern.
4. Critical fonts/CSS not preloaded via links export
<link rel="preload"> for the document font and the route-critical CSS gets them on the wire during the HTML parse phase, ahead of when the layout engine discovers them. Without preload, fonts arrive late and the page either FOUTs (flash of unstyled text) or FOITs (flash of invisible text).
Bad (no preload):
// app/root.tsx
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: appStyles },
];Good:
import interVar from "~/fonts/InterVariable.woff2";
import appStyles from "~/styles/app.css?url";
export const links: LinksFunction = () => [
// Preload critical font BEFORE the stylesheet so the browser knows it's needed
{
rel: "preload",
as: "font",
type: "font/woff2",
href: interVar,
crossOrigin: "anonymous", // required for cross-origin / signed-URL fonts
},
{ rel: "stylesheet", href: appStyles },
];Report as: [FILE:LINE] MISSING_CRITICAL_PRELOAD — root or route-critical font/CSS loaded without an accompanying rel="preload" link.
Do not flag: preload omission on a route that uses only system fonts (no custom font file).
5. <link rel="stylesheet"> injected in body instead of links export
Stylesheets injected inside the document body (typically via <link> rendered in a component) ship after parse begins, causing FOUC and re-layout. Remix routes a stylesheet through the links export so it lands in the document <head> during SSR.
Bad:
export default function Page() {
return (
<>
<link rel="stylesheet" href="/styles/page.css" />
<Content />
</>
);
}Good:
import pageStyles from "~/styles/page.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: pageStyles },
];
export default function Page() {
return <Content />;
}Report as: [FILE:LINE] STYLESHEET_IN_BODY — <link rel="stylesheet"> rendered inside a component body instead of via the links export.
6. Missing crossOrigin on font preload
Fonts are always fetched in CORS mode (per the Fetch spec). A preload without crossOrigin="anonymous" is treated as a separate request from the font's actual load — so the preload is wasted and the font is fetched twice.
Bad:
{ rel: "preload", as: "font", type: "font/woff2", href: interVar }Good:
{
rel: "preload",
as: "font",
type: "font/woff2",
href: interVar,
crossOrigin: "anonymous",
}Report as: [FILE:LINE] FONT_PRELOAD_MISSING_CROSSORIGIN — font preload without crossOrigin="anonymous".
7. Missing decoding="async" on large images
decoding="async" lets the browser decode the image off the main thread, keeping the interaction-ready threshold (FID/INP) tighter on image-heavy pages.
Suggested pattern:
<img
src="/hero.jpg"
alt="Hero"
width="1600"
height="900"
decoding="async"
fetchPriority="high"
/>Report as: [FILE:LINE] MISSING_DECODING_ASYNC — large image (>500px on either axis) without decoding="async".
Not a high-severity issue; flag only on hero / gallery / above-the-fold images.
Verify before flagging
- For "below-the-fold image without
loading=lazy," confirm the image is actually below the fold. Hero images, logos, anything in the header should NOT be lazy. When unsure, check the JSX hierarchy and CSS — an image inside a<header>or above any<main>content is likely above the fold. - For "missing
width/height," confirm the project does NOT use a build-time image processor (unpic,remix-image,sharp-via-loader) that injects dimensions. If the project has a wrapper component, defer to that. - For "missing critical preload," confirm the project uses a custom font file (look in
~/fonts/orapp/fonts/). System-font-only projects don't need font preload. - For "stylesheet in body," confirm the
<link>is inside a default-exported component (rendered into body), not inside<head>via aLayoutcomponent or<Meta>/<Links>placeholders. - For "unsafe innerHTML," confirm the data flowing in is genuinely untrusted (user input, CMS body, markdown) or genuinely unsafe-encoded (
JSON.stringifyin a<script>). HTML built from compile-time constants is fine.
Verbatim quote requirements
Findings on this surface require a verbatim quote of:
- the
<img>/<link>/dangerouslySetInnerHTMLJSX being flagged, AND - the surrounding context (above/below fold, inside
linksexport vs component body, trusted vs untrusted data source).
A finding like "images here need lazy loading" with no JSX or fold context is not reportable.
Caching Headers Review
Anti-patterns in the headers route export and Cache-Control policy.
What to flag
1. Route serves data but exports no headers function
The default Remix response carries no Cache-Control — CDN cache is effectively off and every navigation hits origin. Make caching a deliberate decision per route, even when the answer is "do not cache."
Bad:
// app/routes/blog.$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
return json(await cms.getPost(params.slug));
}
// no headers export — silent uncached documentGood:
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});Report as: [FILE:LINE] MISSING_HEADERS_EXPORT — route serves cacheable content but does not declare a cache policy.
2. Child route omits headers, silently inherits parent's cache policy
In Remix v2 only the deepest matched route's headers function runs by default. If the leaf route does not export one, Remix walks UP to the nearest ancestor's headers and uses it. The bug is the opposite of "dropped": a personalized child without its own headers silently inherits the parent's aggressive cache policy, leaking per-user HTML at the CDN.
Bad:
// app/routes/_layout.tsx
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=300, s-maxage=3600",
});
// app/routes/_layout.dashboard.tsx
export async function loader() { return json({ user: await getUser() }); }
// no headers export — Remix walks up to _layout.tsx and serves
// personalized dashboard HTML with public, s-maxage=3600 at the CDN.Good (define on leaf):
// app/routes/_layout.dashboard.tsx
export const headers: HeadersFunction = () => ({
"Cache-Control": "private, no-store",
});Report as: [FILE:LINE] CHILD_INHERITS_AGGRESSIVE_PARENT_CACHE — child route serves personalized data but has no headers export; falls back to parent's permissive policy.
Verify before flagging: confirm an ancestor exports headers AND the inherited policy is wider than the child's actual cacheability profile (e.g., parent returns public, s-maxage=... while the child reads session/user state).
3. Cache-Control: public on an auth'd or personalized response
public allows shared caches (CDNs, corporate proxies) to store and serve the response to other users. On any response that varies by user — dashboards, account pages, anything reading session — this leaks one user's HTML to others.
Bad:
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=300", // dashboard data!
});Good:
export const headers: HeadersFunction = () => ({
"Cache-Control": "private, max-age=0, must-revalidate",
});Report as: [FILE:LINE] PUBLIC_CACHE_ON_AUTH_ROUTE — public directive on a route that reads session/user data.
Verify before flagging: check that the loader actually reads session/user state (look for getSession, requireUserId, authenticator.isAuthenticated, cookie-keyed reads).
4. Missing Vary: Cookie when caching cookie-dependent responses
If the response body changes based on a cookie (e.g. theme preference, session, feature flag) and the CDN caches it, every visitor sees the first cached variant. Vary: Cookie tells the cache to key on the cookie header.
Bad:
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=60, s-maxage=3600",
// no Vary — CDN caches one variant for everyone
});Good:
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=60, s-maxage=3600",
"Vary": "Cookie",
});Report as: [FILE:LINE] MISSING_VARY_COOKIE — cacheable response varies by cookie but Vary: Cookie is not set.
Note: Vary: Cookie is coarse — most CDNs treat any cookie change as a cache miss. Behavior is CDN-specific: Cloudflare ignores Vary: Cookie by default unless Cache Rules are configured; Fastly honors it; Akamai treats it as a poor-quality directive.
5. Set-Cookie returned alongside Cache-Control: public
Most CDNs refuse to cache responses that carry a Set-Cookie header. Fastly and Cloudflare strip it silently; some CDNs cache the cookie itself, which is worse — every visitor gets the first user's session.
Bad:
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request);
session.set("lastVisit", Date.now());
return json(data, {
headers: {
"Cache-Control": "public, max-age=300",
"Set-Cookie": await commitSession(session),
},
});
}Good: keep cookie-setting in actions; loaders should be cookie-free:
// loader: no cookie
export async function loader({ request }: LoaderFunctionArgs) {
return json(await getData(request), {
headers: { "Cache-Control": "public, max-age=300" },
});
}Report as: [FILE:LINE] SET_COOKIE_WITH_PUBLIC_CACHE — loader sets a cookie and declares public caching; CDN will either drop the cache or leak the cookie.
6. Document headers not forwarded from loader
The headers export controls the document response. The loader's Cache-Control controls the data response (the ?_data= JSON fetched on client navigation). These are two separate caches. If the headers export does not forward loaderHeaders, the document is uncached even when the loader said public, s-maxage=3600.
Bad:
export async function loader() {
return json(data, {
headers: { "Cache-Control": "public, s-maxage=3600" },
});
}
export const headers: HeadersFunction = () => ({
// forgot to forward — document is uncached
});Good:
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});Report as: [FILE:LINE] DOCUMENT_HEADERS_NOT_FORWARDED — loader sets Cache-Control but document headers export does not forward it.
7. Child route widens parent's cache policy
When merging in a child, pick the smaller max-age / s-maxage. A child that widens the parent's policy can cause stale data to be served beyond what the parent expected.
Bad:
// parent: max-age=60
// child:
export const headers: HeadersFunction = () => ({
"Cache-Control": "public, max-age=3600", // wider than parent
});Good: take the minimum:
export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
const loader = parseCacheControl(loaderHeaders.get("Cache-Control"));
const parent = parseCacheControl(parentHeaders.get("Cache-Control"));
const maxAge = Math.min(loader["max-age"] ?? 0, parent["max-age"] ?? 0);
return { "Cache-Control": `private, max-age=${maxAge}` };
};Report as: [FILE:LINE] CHILD_WIDENS_PARENT_CACHE — child route's max-age exceeds parent's.
8. Missing Save-Data consideration on heavy responses
Clients on metered connections send Save-Data: on. Responses that bundle large payloads (images, video poster frames, analytics scripts) should branch on this header to return lighter variants. Not strictly a bug; flag only on routes that ship measurably heavy payloads.
Suggested pattern:
export async function loader({ request }: LoaderFunctionArgs) {
const saveData = request.headers.get("Save-Data") === "on";
const data = saveData ? await getLightVariant() : await getFullVariant();
return json(data, { headers: { "Vary": "Save-Data" } });
}Report as: [FILE:LINE] MISSING_SAVE_DATA_BRANCH — heavy response does not honor Save-Data hint.
Verify before flagging
- For "missing
headersexport," confirm the route is not in a path explicitly marked uncacheable (auth area, admin area). Look for an enclosing layout that returnsno-store. - For "child inherits aggressive parent cache," walk the route file tree and confirm an ancestor actually exports
headersAND that the inherited policy is wider than the child's cacheability profile. If no ancestor exports headers, the issue is "no caching configured" — a softer finding. - For "
publicon auth'd route," confirm the loader reads session state. A route that happens to be under an auth layout but reads only public data may legitimately usepublic. - For "missing
Vary: Cookie," confirm the response body branches on a cookie. If the loader is cookie-independent (or short-circuits to a redirect when unauth'd),Vary: Cookieis not required. - For "
Set-Cookie+public," confirm both are set on the same response. A loader that conditionally sets the cookie only on first visit, with a redirect, is fine.
Verbatim quote requirements
Findings on this surface require a verbatim quote of:
- the
headersexport (or its absence — quote the route module's exports and note the omission), AND - the
Cache-Controlstring (orSet-Cookie,Vary) being flagged.
A finding like "this route is missing a headers export" with no path or quote is not reportable.
Hydration Safety Review
Anti-patterns that produce SSR/client divergence.
What to flag
1. new Date() / Date.now() in render path
Server and client compute different values microseconds apart — different HTML, hydration mismatch, React 18 logs a warning and re-renders the entire subtree on the client. Common offenders: rendering "last updated X ago," "current time," default-form values, copyright years (sometimes).
Bad:
export default function Page() {
return <p>Rendered at {new Date().toLocaleString()}</p>;
}Bad (same problem, different shape):
const NOW = Date.now(); // module-level — evaluated at first import
export default function Page() {
return <p>Now: {NOW}</p>;
}Good (render a stable value, format client-side after hydration):
import { useEffect, useState } from "react";
export default function Page() {
const iso = useLoaderData<typeof loader>().renderedAt;
const [pretty, setPretty] = useState<string | null>(null);
useEffect(() => setPretty(new Date(iso).toLocaleString()), [iso]);
return <p>Rendered at {pretty ?? iso}</p>;
}Report as: [FILE:LINE] DATE_IN_RENDER — new Date() / Date.now() evaluated in JSX render path.
Do not flag when the call site is:
- inside
useEffect/useLayoutEffect - inside an event handler (
onClick,onChange, etc.) - inside a
setTimeout/requestAnimationFramecallback - inside
<ClientOnly>{() => ...}</ClientOnly> - inside a render branch gated by
useHydrated()returningtrue
2. Math.random() / crypto.randomUUID() in render or as React keys
Server picks one value, client picks another — different HTML, different keys, hydration mismatch and remount of subtree.
Bad:
export default function List({ items }: { items: string[] }) {
return (
<ul>
{items.map((item) => (
<li key={Math.random()}>{item}</li> // different key every render
))}
</ul>
);
}Bad (same problem):
const id = `widget-${crypto.randomUUID()}`; // module-level random
export function Widget() {
return <div id={id}>...</div>;
}Good (stable identity from data, or `useId` for ID generation):
{items.map((item) => <li key={item.id}>{item.label}</li>)}import { useId } from "react";
export function Widget() {
const id = useId();
return <div id={id}>...</div>;
}Report as: [FILE:LINE] RANDOM_IN_RENDER_OR_KEY — Math.random() / crypto.randomUUID() used in render path or as a key.
Do not flag when: the call is inside an effect, event handler, or <ClientOnly> body.
3. Locale formatting without explicit locale
new Intl.DateTimeFormat() and date.toLocaleDateString() without arguments use the runtime's default locale. The server's default locale (typically en-US from ICU_DATA or system) almost never matches a visitor's locale → divergent strings → hydration mismatch.
Bad:
<time>{new Date(iso).toLocaleDateString()}</time> // implicit locale
<time>{new Intl.DateTimeFormat().format(new Date(iso))}</time>
<span>{n.toLocaleString()}</span> // implicit localeGood (explicit locale — pass via loader or hardcode if appropriate):
<time>{new Date(iso).toLocaleDateString("en-US")}</time>
<time>{new Intl.DateTimeFormat("en-US", { dateStyle: "medium" }).format(new Date(iso))}</time>Better (render ISO on server, reformat in `useEffect` using the visitor's locale):
import { useEffect, useState } from "react";
export function Date({ iso }: { iso: string }) {
const [formatted, setFormatted] = useState<string | null>(null);
useEffect(() => {
setFormatted(new Date(iso).toLocaleDateString());
}, [iso]);
return <time dateTime={iso}>{formatted ?? iso}</time>;
}Report as: [FILE:LINE] LOCALE_FORMAT_WITHOUT_LOCALE — toLocaleString/toLocaleDateString/toLocaleTimeString/Intl.* called without an explicit locale argument.
Do not flag when: the call is inside useEffect / event handler / <ClientOnly>.
4. Hand-rolled IDs in components that should use useId()
Counters, timestamps, random IDs in render path all break SSR. React's useId returns a stable ID across server and client.
Bad:
let counter = 0;
export function LabeledInput({ label }: { label: string }) {
const id = `input-${++counter}`; // increments per render; ordering differs SSR vs client
return (<><label htmlFor={id}>{label}</label><input id={id} /></>);
}Bad:
export function LabeledInput({ label }: { label: string }) {
const id = `input-${Math.random()}`;
return (<><label htmlFor={id}>{label}</label><input id={id} /></>);
}Good:
import { useId } from "react";
export function LabeledInput({ label }: { label: string }) {
const id = useId();
return (<><label htmlFor={id}>{label}</label><input id={id} /></>);
}Report as: [FILE:LINE] MISSING_USE_ID — component generates ID from counter/random/timestamp instead of useId().
Do not flag: ` ${id}-input `-style append for multi-element components — that's the documented pattern.
5. Blanket suppressHydrationWarning on a parent that wraps a large subtree
suppressHydrationWarning silences React's mismatch warning for ONE element and its immediate text children. Applying it to a <div> or <body> that wraps a large subtree hides every bug under it, including critical ones (XSS, broken interactivity, leaked secrets). It's also misleading — the prop only suppresses the warning; the underlying mismatch still causes a client re-render.
Bad:
<body suppressHydrationWarning>
{/* everything below silenced */}
</body>Bad:
<div suppressHydrationWarning>
<SomeComponent />
<AnotherComponent />
</div>Good (narrow scope, comment why):
{/* suppress: server returns ISO, client reformats post-hydration */}
<time dateTime={iso} suppressHydrationWarning>
{formatted}
</time>Report as: [FILE:LINE] BLANKET_SUPPRESS_HYDRATION_WARNING — suppressHydrationWarning applied to a wrapper element or without an explanatory comment.
Do not flag: the prop on a single leaf element (<time>, <span>, <input value>) with a comment explaining why divergence is expected.
6. window. / document. / localStorage / navigator. in render path
These globals are undefined on the server. Reading them in render crashes SSR or — when wrapped in typeof guards — produces different output on server and client, causing mismatch.
Bad:
export default function Page() {
const width = window.innerWidth; // crashes on SSR
return <p>Width: {width}</p>;
}Bad (guarded but still mismatched):
export default function Page() {
const dark = typeof window !== "undefined" && localStorage.getItem("theme") === "dark";
return <div className={dark ? "dark" : "light"}>...</div>; // SSR says "light", client says "dark"
}Good (initialize state from a stable default; sync in `useEffect`):
import { useEffect, useState } from "react";
export default function Page() {
const [dark, setDark] = useState(false);
useEffect(() => setDark(localStorage.getItem("theme") === "dark"), []);
return <div className={dark ? "dark" : "light"}>...</div>;
}Or: wrap the component in <ClientOnly> if there's no acceptable SSR fallback.
Report as: [FILE:LINE] BROWSER_API_IN_RENDER — window.*/document.*/localStorage/navigator.* read in render path.
7. typeof window ternaries that produce different JSX
A ternary on typeof window in render produces a different element tree on server vs client. This is the most common deliberate hydration mismatch — and it's always wrong; use useHydrated for the two-pass pattern.
Bad:
export function Widget() {
return typeof window !== "undefined"
? <ClientWidget />
: <Skeleton />;
}Good:
import { useHydrated } from "remix-utils/use-hydrated";
export function Widget() {
const isHydrated = useHydrated();
return isHydrated ? <ClientWidget /> : <Skeleton />;
}useHydrated is false on SSR and the first client render, then flips to true — the two-pass keeps HTML matched.
Report as: [FILE:LINE] TYPEOF_WINDOW_TERNARY — render returns different JSX based on typeof window.
8. Render-time Date() for the year (the "current year" footer)
This is the most-shipped hydration bug. new Date().getFullYear() in a footer renders one year on server, possibly another on client during a year boundary, but more importantly tools and linters often miss it.
Acceptable (build-time):
const YEAR = 2026; // hardcoded or injected via build env
<p>© {YEAR} Company</p>Acceptable (loader):
export async function loader() {
return json({ year: new Date().getFullYear() });
}
// component reads from useLoaderDataReport as: [FILE:LINE] DATE_GETFULLYEAR_IN_RENDER — new Date().getFullYear() in JSX (a special case of DATE_IN_RENDER, but distinct enough to call out).
Verify before flagging
- Confirm the call site is in render. Walk up from the offending line. If the nearest enclosing function is a
useEffect/useLayoutEffectcallback, an event handler (onClick,onChange,onSubmit, etc.), asetTimeout/setInterval/requestAnimationFramecallback, or a<ClientOnly>{() => ...}</ClientOnly>child function — do not flag. - For locale formatting, confirm no
localearg is passed.toLocaleDateString("en-US")is fine;toLocaleDateString()is the bug. - For
suppressHydrationWarning, confirm it's NOT on a single leaf element with a code comment. If it is, the narrow escape is acceptable. - For browser-API reads, confirm the read is in render.
window.scrollTo()inuseEffectis fine. - For
useIdissues, confirm the component generates IDs that need to match between SSR and CSR (label-for, aria-controls, aria-labelledby). Adata-test-idthat doesn't need to match is not the same thing.
Verbatim quote requirements
Findings on this surface require a verbatim quote of:
- the offending expression (e.g.
new Date().toLocaleString()), AND - the enclosing function signature (so the reviewer can verify it's render, not effect/handler).
A finding like "this component has hydration issues" with no expression or call site is not reportable.
Prefetch & Streaming Review
Anti-patterns in <Link prefetch>, <PrefetchPageLinks>, defer(), <Await>, and <Suspense>.
What to flag
1. <Link prefetch="render"> on every link in a long list
prefetch="render" fires <link rel="prefetch"> tags immediately on mount — one data request per link, plus JS and CSS for each target route. On a 200-row table this is 200 simultaneous prefetches: network thrash, wasted bandwidth, possible rate-limit hits.
Bad:
{rows.map((row) => (
<Link key={row.id} to={`/products/${row.id}`} prefetch="render">
{row.name}
</Link>
))}Good: use intent (fires on hover) or viewport (fires when scrolled in):
{rows.map((row) => (
<Link key={row.id} to={`/products/${row.id}`} prefetch="intent">
{row.name}
</Link>
))}Report as: [FILE:LINE] PREFETCH_RENDER_ON_LIST — prefetch="render" applied to a link rendered inside a list / .map().
2. <Link prefetch="intent"> on a link whose loader has side effects
Hover-prefetch fires the target route's loader. If the loader logs an analytics event, increments a counter, or has any side effect, hovering over the link inflates those metrics — and may even trigger work that should only happen on actual navigation.
Bad:
// app/routes/article.$id.tsx
export async function loader({ params }: LoaderFunctionArgs) {
await analytics.trackView(params.id); // side effect — fires on hover-prefetch
return json(await getArticle(params.id));
}
// somewhere else:
<Link to={`/article/${id}`} prefetch="intent">...</Link>Good options:
- Move the side effect to an
actiontriggered by a beacon - Detect
Purpose: prefetchin the loader and skip the side effect:
export async function loader({ request, params }: LoaderFunctionArgs) {
const purpose = request.headers.get("Purpose")
?? request.headers.get("Sec-Purpose")
?? request.headers.get("X-Purpose");
if (purpose !== "prefetch") await analytics.trackView(params.id);
return json(await getArticle(params.id));
}- Set
prefetch="none"on the link
Report as: [FILE:LINE] PREFETCH_INTENT_TO_SIDE_EFFECT_LOADER — link with prefetch="intent"/"render"/"viewport" points to a route whose loader has side effects.
3. <PrefetchPageLinks> to a mutation-triggering route
<PrefetchPageLinks> programmatically prefetches data, JS, and CSS for a target route. If that route's loader has any side effect (or worse, if it actually triggers a mutation via redirect / cookie set), the prefetch fires the side effect on render — possibly N times if the component remounts.
Bad:
<PrefetchPageLinks page="/api/track/view" /> // POST-like loader semanticsBad:
<PrefetchPageLinks page="/logout" /> // loader signs out, sets cookieGood: reserve <PrefetchPageLinks> for cacheable read-only routes that the user is highly likely to navigate to next.
Report as: [FILE:LINE] PREFETCH_PAGE_LINKS_TO_SIDE_EFFECT_ROUTE — <PrefetchPageLinks> targets a route with side-effecting loader.
4. defer() for data that resolves in under ~50ms
Streaming adds protocol overhead: <script> tags appended to the HTML stream, an extra render pass for the suspense boundary, a brief flash of the skeleton. For data from in-memory cache or a fast local DB, this is net-negative — TTI worsens.
Bad:
export async function loader() {
const cached = inMemoryCache.get("hot-data"); // <1ms
return defer({ data: Promise.resolve(cached) });
}Good: await fast data:
export async function loader() {
const data = inMemoryCache.get("hot-data");
return json({ data });
}Report as: [FILE:LINE] DEFER_ON_FAST_DATA — defer() wraps a promise that resolves synchronously / from in-memory cache / from a sub-50ms call.
Verify before flagging: the data has to actually be fast. A defer on a cross-region DB call is correct. Look for await chains, network requests, or external API calls before flagging this — and prefer a softer "consider awaiting" note when unsure.
5. <Await> without an enclosing <Suspense>
<Await> requires a <Suspense> boundary to render its fallback. Without one, the throw bubbles up to the route's ErrorBoundary (which is not the streaming behavior anyone wants) or, in some configurations, crashes the entire route on the server before streaming begins.
Bad:
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>Good:
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>Report as: [FILE:LINE] AWAIT_WITHOUT_SUSPENSE — <Await> rendered without an enclosing <Suspense>.
6. <Await> without errorElement
A rejected deferred promise inside <Await> without errorElement bubbles to the route's ErrorBoundary — tearing down the entire route, including the parts that already rendered successfully. That defeats the streaming benefit (graceful degradation of slow secondary data).
Bad:
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>Good:
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>Report as: [FILE:LINE] AWAIT_WITHOUT_ERROR_ELEMENT — <Await> has no errorElement prop; rejection tears down the entire route.
7. defer() where the promise is created AFTER an await
The point of defer is that the loader returns before slow data resolves. If the slow promise is created after an await, the loader still blocks on the prior await — streaming gains nothing.
Bad:
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.getProduct(params.id);
const reviews = await db.getReviews(params.id); // awaited!
return defer({ product, reviews }); // not actually deferred
}Bad (subtle — promise created after await):
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.getProduct(params.id);
const reviews = db.getReviews(params.id); // created AFTER await — sequential
return defer({ product, reviews });
}Good (kick off slow promise before any await):
export async function loader({ params }: LoaderFunctionArgs) {
const reviewsPromise = db.getReviews(params.id); // BEFORE any await
const product = await db.getProduct(params.id);
return defer({ product, reviews: reviewsPromise });
}Report as: [FILE:LINE] DEFER_PROMISE_AFTER_AWAIT — deferred promise created after an await; loader still blocks.
8. <RemixServer abortDelay> set to a very high value
abortDelay (default 5000ms) caps how long the server holds the connection open waiting for deferred promises before aborting and sending what's resolved. Setting it to 30s "to be safe" holds slow upstream calls open, exhausts server worker pools, and pushes latency p99 sky-high.
Bad:
<RemixServer context={remixContext} url={request.url} abortDelay={30_000} />Good: keep near the default; set per-call timeouts inside loaders instead:
<RemixServer context={remixContext} url={request.url} />Report as: [FILE:LINE] HIGH_ABORT_DELAY — abortDelay >10s on <RemixServer>.
Verify before flagging
- For "prefetch render on list," confirm the link is inside an iterator (
.map(), a loop). A single prefetch="render" on a high-priority above-the-fold nav link is fine. - For "prefetch to side-effect loader," confirm the loader actually has side effects. Read the loader body. A pure read loader with
prefetch="intent"is the correct pattern. - For "defer on fast data," confirm the deferred promise resolves synchronously / from cache / from a sub-50ms call. When unsure, prefer a softer "consider awaiting" note.
- For "Await without Suspense / errorElement," confirm both wrappers are missing from the same
<Await>instance, not from a different one in the same file. - For "defer after await," confirm the promise is genuinely created after the await — not just used in JSX after an await.
Verbatim quote requirements
Findings on this surface require a verbatim quote of:
- the
<Link prefetch="...">/<PrefetchPageLinks page="...">/defer({ ... })/<Await ...>call being flagged, AND - for loader-side-effect claims: the loader body that contains the side effect.
A finding like "prefetch is misconfigured here" with no JSX or loader quote is not reportable.
Server/Client Split Review
Anti-patterns in .server.ts / .client.ts boundaries, env var access, and module hygiene.
What to flag
1. Server library imported in a route file without .server.ts
Remix's compiler strips loader, action, and headers from the client bundle along with the dependencies used inside them — but only if those dependencies have no module side effects. A top-level new PrismaClient(), an initializeApp() call, or even a console.log defeats tree-shaking. The dep leaks into the client bundle, breaks the build at runtime, or worse, ships secrets.
Bad:
// app/lib/db.ts -- no .server suffix
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient(); // top-level side effect
// app/routes/users.tsx
import { prisma } from "~/lib/db"; // leaks into client graphGood:
// app/lib/db.server.ts -- never reaches client bundle
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
// app/routes/users.tsx
import { prisma } from "~/lib/db.server";Report as: [FILE:LINE] SERVER_LIB_WITHOUT_SERVER_SUFFIX — <lib> imported from a non-.server module reachable from the client graph.
Common server-only imports to scan for: @prisma/client, prisma, bcrypt, bcryptjs, argon2, jsonwebtoken, node:fs, node:path, node:crypto, fs, path, redis, ioredis, mongodb, pg, mysql2, aws-sdk, @aws-sdk/*, nodemailer, stripe (server SDK — @stripe/stripe-js is the client version).
2. Secret env vars referenced in a component body or non-.server utility
process.env does not exist in the browser. References from a component body either crash at runtime, get inlined as undefined, or — worst case — get inlined as the actual secret string by some bundlers / Vite plugins.
Bad:
// app/components/Checkout.tsx
export function Checkout() {
const key = process.env.STRIPE_SECRET_KEY; // leaks to client
return <CheckoutForm secretKey={key} />;
}Bad (transitive leak):
// app/lib/stripe.ts -- no .server suffix
export const stripeKey = process.env.STRIPE_SECRET_KEY;Good: read secrets only inside loaders/actions, or inside a .server.ts module:
// app/lib/stripe.server.ts
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);Report as: [FILE:LINE] SECRET_ENV_LEAK — process.env.<NAME> read from a non-.server module reachable from the client graph.
Scan for env names containing: SECRET, PRIVATE_KEY, TOKEN, PASSWORD, DATABASE_URL, API_KEY (any provider), JWT_*, WEBHOOK_SECRET, SESSION_SECRET.
3. Raw process.env returned from root loader to client
Returning process.env (or even a large subset) from the root loader exposes every environment variable to every visitor as part of window.ENV. Common variant: spreading ...process.env into a returned object.
Bad:
// app/root.tsx
export async function loader() {
return json({ ENV: process.env }); // every secret to every browser
}Also bad:
return json({ ENV: { ...process.env, EXTRA: "x" } });Good: explicit whitelist of public keys only:
return json({
ENV: {
STRIPE_PUBLIC_KEY: process.env.STRIPE_PUBLIC_KEY,
POSTHOG_KEY: process.env.POSTHOG_KEY,
PUBLIC_API_URL: process.env.PUBLIC_API_URL,
},
});Report as: [FILE:LINE] PROCESS_ENV_RETURNED_FROM_LOADER — entire process.env (or a spread of it) returned from a loader.
4. typeof window === "undefined" used as a substitute for .server.ts
Treeshaking is unreliable. A common pattern is to gate server code with typeof window === "undefined" at module top level — but the dead branch still pulls server deps into the client graph because the static import is hoisted regardless. The bundle either grows by megabytes, breaks at build time, or surfaces secrets.
Bad:
// app/lib/logger.ts
import { createLogger } from "pino"; // hoisted import — always in client bundle
const logger = typeof window === "undefined"
? createLogger({ level: "info" })
: { info: console.log, error: console.error };
export { logger };Good (split files):
// app/lib/logger.server.ts
import { createLogger } from "pino";
export const logger = createLogger({ level: "info" });
// app/lib/logger.client.ts
export const logger = { info: console.log, error: console.error };
// Import the right one from a route based on where it runs:
// loader: import { logger } from "~/lib/logger.server";
// component effect: import { logger } from "~/lib/logger.client";Good (function-body branch — acceptable for isomorphic helpers that don't import server libs):
export function getEnvLabel(): "server" | "client" {
return typeof window === "undefined" ? "server" : "client";
}Report as: [FILE:LINE] TYPEOF_WINDOW_INSTEAD_OF_SERVER_SUFFIX — module top-level branches on typeof window but still imports server-only deps.
5. Higher-order function wrapping a loader
export const loader = withAuth(async (args) => { ... }) evaluates withAuth at module load — a module-level side effect that pins server-only deps into the client graph. The compiler's loader stripping can't see through the wrapper.
Bad:
import { withAuth } from "~/lib/auth"; // wrapper imports prisma at top
export const loader = withAuth(async ({ params }) => { /* ... */ });Good (call the helper inside the loader body):
import type { LoaderFunctionArgs } from "@remix-run/node";
import { requireAuth } from "~/lib/auth.server";
export async function loader(args: LoaderFunctionArgs) {
await requireAuth(args);
// ...
}Report as: [FILE:LINE] HOF_WRAPPING_LOADER — loader (or action) is wrapped in a higher-order function; helpers should be called inside the body.
6. .client.ts module imported into a server-only path
.client.ts modules are stripped from the server bundle — their exports are undefined during SSR. If a loader, action, or .server module imports from .client.ts, calls into it will throw Cannot read properties of undefined.
Bad:
// app/lib/analytics.client.ts
export function track(event: string) { window.posthog.capture(event); }
// app/routes/checkout.tsx
import { track } from "~/lib/analytics.client";
export async function loader() {
track("checkout_loaded"); // undefined.call during SSR — crash
return null;
}Good: call .client code only inside useEffect/event handlers:
import { useEffect } from "react";
import { track } from "~/lib/analytics.client";
export default function Checkout() {
useEffect(() => track("checkout_loaded"), []);
return <CheckoutForm />;
}Report as: [FILE:LINE] CLIENT_MODULE_USED_ON_SERVER — .client.ts import called from loader/action/.server module.
7. Secret env in a links or meta export
links and meta exports run on both server and client. References to process.env.SECRET_* inside them will be inlined into the client document.
Bad:
export const meta: MetaFunction = () => [
{ name: "x-admin-token", content: process.env.ADMIN_TOKEN }, // leaked
];Good: never read secrets in links/meta. If a route's meta legitimately depends on server-side config, return the value from the loader and read it via useLoaderData() in the component (not in meta, which receives only loader data anyway):
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{ name: "x-feature", content: data?.featureFlag ?? "" },
];Report as: [FILE:LINE] SECRET_ENV_IN_META_OR_LINKS — secret env value referenced in meta or links export.
Verify before flagging
- For "server lib without
.server.ts," confirm the importing file is reachable from the client graph (route module, non-.serverutility transitively imported by a component). Imports from inside another.server.tsare fine. - For "secret env leak," confirm the env name actually looks secret.
STRIPE_PUBLIC_KEY,POSTHOG_KEY,PUBLIC_API_URLare conventionally public.STRIPE_SECRET_KEY,JWT_SECRET,DATABASE_URLare conventionally private. If the project uses a different convention (NEXT_PUBLIC_*,PUBLIC_*,VITE_*), note that. - For "
typeof windowsubstitute," confirm the file actually imports server deps. A pure isomorphic helper that branches ontypeof windowis fine — the issue is the static import at the top. - For "HOF wrapping loader," confirm the wrapper actually imports server deps. A trivial type-only wrapper is fine.
- For "client module used on server," confirm the call site is in loader/action/
.servercode, not inuseEffect/handler/<ClientOnly>.
Verbatim quote requirements
Findings on this surface require a verbatim quote of:
- the
importstatement being flagged, OR - the
process.env.<NAME>reference being flagged, AND - the surrounding context (file path + whether the call site is loader/action/component body/effect/handler).
A finding like "this file leaks prisma to the client" with no import path or call site is not reportable.