Skip to content

Next.js — Pitfalls

OWNER: floris, floor
ALSO_USED_BY: urszula, maxim, alexander
LAST_VERIFIED: 2026-03-26
GE_STACK_VERSION: next@15.5.x


Overview

Known failure modes with Next.js App Router. Every entry is a real-world issue.
Format: ANTI_PATTERN describes the mistake, FIX describes the solution.
New entries added by agents when pitfalls are discovered during development.


Hydration Mismatches

ANTI_PATTERN: rendering Date/time values that differ between server and client
FIX: always format dates deterministically — pass ISO strings from server, format on client

// WRONG — server renders UTC, client renders local timezone
<p>{new Date().toLocaleString()}</p>

// RIGHT — render ISO on server, format on client
// Server Component passes ISO string
<TimeDisplay iso={createdAt.toISOString()} />

// Client Component formats it
"use client";
export function TimeDisplay({ iso }: { iso: string }) {
  const [formatted, setFormatted] = useState(iso);
  useEffect(() => {
    setFormatted(new Date(iso).toLocaleString());
  }, [iso]);
  return <time dateTime={iso}>{formatted}</time>;
}

ANTI_PATTERN: conditional rendering based on typeof window !== "undefined"
FIX: use useEffect for client-only rendering — the check runs during SSR too

// WRONG — still causes mismatch because condition differs server vs client
{typeof window !== "undefined" && <ClientWidget />}

// RIGHT — two-pass render with useEffect
"use client";
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <Skeleton />;
return <ClientWidget />;

ANTI_PATTERN: using Math.random() or crypto.randomUUID() in render output
FIX: use React.useId() for deterministic IDs across server and client

ANTI_PATTERN: third-party scripts injecting DOM elements that break React's hydration tree
FIX: wrap third-party widgets in <div suppressHydrationWarning> or load after hydration with useEffect

ANTI_PATTERN: browser extensions modifying DOM before hydration completes
FIX: nothing — document in known issues, suppressHydrationWarning on <html> tag only


"use client" Boundary Mistakes

ANTI_PATTERN: adding "use client" to page.tsx or layout.tsx
FIX: keep route files as Server Components — extract interactive pieces to leaf components

ANTI_PATTERN: "use client" not on the first line (comment or blank line above it)
FIX: "use client" must be the absolute first line of the file, before all imports

ANTI_PATTERN: importing server-only modules in Client Components
FIX: add import "server-only" to database/secret modules — fails at build time

// src/lib/db/index.ts
import "server-only"; // Build error if any Client Component imports this

ANTI_PATTERN: large component tree under one "use client" boundary
FIX: split — Server Component parent + multiple small Client Component leaves

ANTI_PATTERN: thinking "use client" prevents server rendering
FIX: Client Components still SSR — they render on server AND hydrate on client
The directive only means: "this component is part of the client bundle"


Caching Gotchas

ANTI_PATTERN: assuming fetch is cached (Next.js 14 behavior)
FIX: Next.js 15 fetches are UNCACHED by default — explicitly add next: { revalidate: N } or cache: "force-cache"

ANTI_PATTERN: caching user-specific data
FIX: never cache authenticated/personalized responses — use dynamic = "force-dynamic" or no-store

ANTI_PATTERN: not understanding Router Cache (client-side)
FIX: Router Cache persists for 30s (dynamic) / 5min (static) — call router.refresh() after mutations

// After a Server Action mutation, refresh the current route
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
// After mutation:
router.refresh(); // Fetches fresh server data for current route

ANTI_PATTERN: revalidatePath("/") expecting it to clear all caches
FIX: revalidatePath("/") only revalidates the root page — use revalidatePath("/", "layout") for all routes under root

ANTI_PATTERN: mixing revalidate values on the same page
FIX: the LOWEST revalidate value wins for the entire route — be consistent

ANTI_PATTERN: ISR pages serving stale content indefinitely
FIX: verify revalidate value is set — without it, static pages never refresh


Server Action Pitfalls

ANTI_PATTERN: calling redirect() inside try/catch
FIX: redirect() throws an error internally — it gets caught by the catch block

// WRONG
"use server";
try {
  await createItem(data);
  redirect("/items"); // throws NEXT_REDIRECT, caught below
} catch (e) {
  return { error: "Something went wrong" }; // redirect swallowed!
}

// RIGHT
"use server";
let success = false;
try {
  await createItem(data);
  success = true;
} catch (e) {
  return { error: "Failed to create item" };
}
if (success) redirect("/items");

ANTI_PATTERN: Server Actions without input validation
FIX: validate ALL inputs with Zod — Server Actions are public endpoints

// Server Actions are callable by anyone who can reach the endpoint.
// Treat them like API routes — validate everything.
const parsed = Schema.safeParse(input);
if (!parsed.success) return { error: parsed.issues[0].message };

ANTI_PATTERN: using useFormState (deprecated)
FIX: use useActionState from React 19

ANTI_PATTERN: returning sensitive data from Server Actions (full user objects, tokens)
FIX: return only what the client needs — IDs, display names, error messages

ANTI_PATTERN: Server Actions that don't revalidate after mutation
FIX: always call revalidateTag() or revalidatePath() after data changes

ANTI_PATTERN: creating Server Actions inline in Client Components
FIX: define in separate "use server" files under lib/actions/


Environment Variables

ANTI_PATTERN: expecting process.env.SECRET_KEY to be available in Client Components
FIX: only NEXT_PUBLIC_* vars are available in client code — secrets stay server-only

ANTI_PATTERN: using runtime env vars in static pages
FIX: process.env values are inlined at BUILD TIME for static pages
IF: env var must change without rebuild
THEN: use dynamic = "force-dynamic" or read from a runtime config endpoint

ANTI_PATTERN: putting secrets in NEXT_PUBLIC_ variables
FIX: NEXT_PUBLIC_ is embedded in client JS bundle — anyone can read it

ANTI_PATTERN: .env.local not in .gitignore
FIX: ensure .env*.local is in .gitignore — NEVER commit secrets


Docker / k3s Deployment Issues

ANTI_PATTERN: not using output: "standalone" for Docker
FIX: standalone mode creates self-contained server — required for k3s deployment

ANTI_PATTERN: Docker image includes node_modules (500MB+)
FIX: use multi-stage build — standalone output is ~30-50MB

# Multi-stage build for Next.js (GE standard)
FROM node:20-alpine AS base

FROM base AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN corepack enable pnpm && pnpm build

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:1001 /app/.next/standalone ./
COPY --from=builder --chown=nextjs:1001 /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

ANTI_PATTERN: running as root in Docker container
FIX: create non-root user (nextjs:nodejs) — required for ISO 27001 compliance

ANTI_PATTERN: COPY . . without .dockerignore
FIX: create .dockerignore excluding node_modules, .next, .env.local, .git

ANTI_PATTERN: not setting HOSTNAME env var in k3s deployment
FIX: set HOSTNAME=0.0.0.0 — Next.js standalone binds to localhost by default

# k3s deployment snippet
env:
  - name: HOSTNAME
    value: "0.0.0.0"
  - name: PORT
    value: "3000"

ANTI_PATTERN: k3s health checks on wrong path
FIX: use /api/health or default Next.js health endpoint

ANTI_PATTERN: no resource limits in k3s deployment
FIX: set CPU/memory requests and limits

resources:
  requests:
    cpu: "100m"
    memory: "128Mi"
  limits:
    cpu: "500m"
    memory: "512Mi"

Dynamic Route Params (Next.js 15 Breaking Change)

ANTI_PATTERN: accessing params.slug synchronously
FIX: params is now a Promise in Next.js 15 — must await

// WRONG (Next.js 14 pattern — breaks in 15)
export default function Page({ params }: { params: { slug: string } }) {
  return <h1>{params.slug}</h1>;
}

// RIGHT (Next.js 15)
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return <h1>{slug}</h1>;
}

ALSO: searchParams is now a Promise too.


useSearchParams Without Suspense

ANTI_PATTERN: using useSearchParams() without Suspense boundary
FIX: wrap component using useSearchParams in <Suspense>

// WRONG — causes build error or runtime crash
"use client";
export function Search() {
  const searchParams = useSearchParams(); // Error!
  return <input defaultValue={searchParams.get("q") ?? ""} />;
}

// RIGHT — wrapped in Suspense
"use client";
function SearchInner() {
  const searchParams = useSearchParams();
  return <input defaultValue={searchParams.get("q") ?? ""} />;
}
export function Search() {
  return (
    <Suspense fallback={<input disabled />}>
      <SearchInner />
    </Suspense>
  );
}

Import Path Mistakes

ANTI_PATTERN: importing from next/router (Pages Router)
FIX: use next/navigation for App Router

ANTI_PATTERN: importing useRouter in Server Components
FIX: useRouter is client-only — use redirect() from next/navigation in Server Components

ANTI_PATTERN: importing from next/head (Pages Router)
FIX: use metadata export or generateMetadata() in App Router


Parallel Routes

ANTI_PATTERN: missing default.tsx in parallel route slots
FIX: every @slot directory needs default.tsx — returns fallback when no sub-route matches

IF: navigating to a sub-route and back causes 404 in a slot
THEN: add default.tsx to that slot (can return null if no fallback needed)


Cross-References

READ_ALSO: wiki/docs/stack/nextjs/index.md
READ_ALSO: wiki/docs/stack/nextjs/server-components.md
READ_ALSO: wiki/docs/stack/nextjs/data-fetching.md
READ_ALSO: wiki/docs/stack/nextjs/middleware.md
READ_ALSO: wiki/docs/stack/nextjs/checklist.md