Next.js (App Router)
18 of 26 projects. Versions in play span 14 → 16, and the APIs differ in ways that
break silently. Check package.json before applying any pattern, and ask the
context7 MCP server rather than answering from memory on version-specific APIs.
| Version | Where |
|---|---|
| 14 | one legacy marketing site |
| 15 | most projects |
| 16 | the newest storefronts |
Layout
app/
layout.tsx # root: fonts, providers, metadata
page.tsx
(marketing)/ # route group — no URL segment
dashboard/
layout.tsx
page.tsx
loading.tsx # instant Suspense fallback
error.tsx # 'use client' — error boundary
api/
webhooks/stripe/route.ts
components/
ui/ # primitives
lib/
supabase/{client,server,admin}.ts
utils.ts # cn()
Server by default
Every component is a Server Component unless it says "use client". Push the boundary
down, not up: a page that needs one interactive button should not become a client
component wholesale.
// app/dashboard/page.tsx — server
export default async function Page() {
const supabase = await createClient();
const { data } = await supabase.from("bookings").select("*");
return <BookingTable rows={data ?? []} />; // table can be a client component
}
Rules that bite:
- Server Components can't use hooks,
onClick, or browser APIs. - Client Components can't be
async. - Props crossing the boundary must be serialisable — no functions, no
bigint, no class instances.bigintis the one that catches us, coming back from viem. Convert with.toString()before passing down. - A
"use client"file makes everything it imports client too. Importing your Supabase admin client there ships the service-role key to the browser — henceimport "server-only"in../ekx-supabase/SKILL.md.
Server actions vs route handlers
| Use | For |
|---|---|
| Server action | Form submits and mutations from our own UI. Less boilerplate, typed end to end. |
| Route handler | Webhooks, anything a third party calls, anything needing a raw body or custom status. |
// server action
"use server";
export async function createBooking(formData: FormData) {
const user = await requireUser(); // ALWAYS re-authorise here
await admin.from("bookings").insert({ ... });
revalidatePath("/dashboard");
}
A server action is a public HTTP endpoint. It is not protected by being called from a protected page — anyone can invoke it. Authorise inside every one.
Caching — the version trap
Next 14/15 cache fetch aggressively by default; Next 16 with Cache Components
changes the model again. Be explicit rather than relying on defaults:
export const dynamic = "force-dynamic"; // never cache this route
export const revalidate = 60; // ISR: 60s
const res = await fetch(url, { cache: "no-store" });
Supabase queries are not fetch and are never cached by Next — but the page they
render can be statically rendered at build time and go stale. On any page showing
per-user or live data, set dynamic = "force-dynamic" or read cookies() (which
opts the route into dynamic rendering automatically).
Tailwind v4
Nine projects are on v4, which drops tailwind.config.js in favour of CSS:
/* app/globals.css */
@import "tailwindcss";
@theme {
--color-brand: #ff7a45;
--font-display: "Chakra Petch", sans-serif;
}
// postcss.config.mjs
export default { plugins: { "@tailwindcss/postcss": {} } };
v3 projects keep the JS config. Do not mix — the @tailwindcss/postcss plugin and a
tailwind.config.js in the same repo produce styles that work in dev and vanish in
the production build.
cn() helper (in every repo, should be a package):
export const cn = (...i: ClassValue[]) => twMerge(clsx(i));
Environment variables
NEXT_PUBLIC_* is inlined into the client bundle at build time. Two consequences:
- Changing one in Vercel requires a redeploy, not just a restart.
- Anything secret must not carry the prefix.
Gotchas
cookies()/headers()are async in 15+.awaitthem.paramsandsearchParamsare Promises in 15+.bigintacross the client boundary throws at runtime, not build.- Server actions are public endpoints. Authorise inside.
- Hydration mismatch from
Date,Math.random, orlocalStorageduring render — move touseEffect. - Stale
NEXT_PUBLIC_after an env change without redeploy. - Mixing Tailwind v3 config with v4 plugin.