Web Frontend
Next.js 16 · React 19 · Tailwind v4 · Shadcn · Server Actions
Server Components run on Vercel's compute and stream HTML to the user; Server Actions handle mutations without a separate API layer; tRPC adds caching where the read pattern is interactive. The shape of a 2026 Next.js SaaS is: server-rendered shell, islands of interactivity, optimistic mutations, and a design system built on Shadcn primitives. Less JavaScript, faster first paint, fewer moving parts.
App Router
Next.js 16 App Router. Folder = route; layout.tsx wraps children; loading.tsx + error.tsx are siblings. Default is Server Component — only opt into 'use client' when you need state/effects/events.
How it flows
step by step- 01step
Routes under app/(marketing)/, app/(app)/, app/(admin)/ as route groups.
- 02step
Parallel routes for slots (e.g. @modal); intercepting routes for modal-on-deeplink.
- 03step
Server Components fetch data directly (await db…) — no useEffect.
- 04step
Client Components: 'use client' at the top; they hydrate, they have state, they read tRPC.
Cases & edge cases
watch for theseServer Component logging is opaque in vercel logs — use structured logs with request IDs.
Don't pass Server Component results as props to Client Components if the prop is huge — serialization cost.
Layout re-rendering: only the changed segment re-renders; parents don't.
Where it lives
code references- app/layout.tsx // root layout
- app/(app)/layout.tsx // authenticated shell
Server Actions
Async functions marked 'use server' that run on the server when called from the client. They're the default mutation primitive in 2026 — form submit, button click, optimistic update, all flow through them.
How it flows
step by step- 01step
Define: 'use server'; export async function createTask(formData) { ... }.
- 02step
Use from a <form action={createTask}> — works without JS.
- 03step
Or call from a client component: const result = await createTask(input).
- 04step
Server-side: validate with Zod → call DB via withTenant → return typed result.
- 05step
Revalidate: revalidatePath('/tasks') refreshes the server cache.
Cases & edge cases
watch for theseServer Actions are POST requests — same CSRF protection as any mutation.
Don't reuse Server Actions for read-after-write — call revalidatePath + let RSC re-render.
Error handling: throw a typed AppError; catch in client to show inline form errors.
Where it lives
code references- app/(app)/tasks/_actions.ts
- lib/actions/with-action.ts // shared error / auth wrapper
Notes
marginServer Actions don't replace tRPC for reads — they're a write-side primitive. Use both, they complement.
Server Cache & tRPC
tRPC + React Query (TanStack Query) for interactive reads: cache keys, background refetch, optimistic mutations, infinite scroll, stale-while-revalidate. Server Components handle the initial paint; tRPC handles every interaction after.
How it flows
step by step- 01step
Server Component fetches initial data → passes as prop to a Client tree.
- 02step
Client uses trpc.X.useQuery() with initialData = the prop → no double-fetch on mount.
- 03step
User mutates → useMutation with onMutate (optimistic) + onError (rollback) + onSettled (refetch).
- 04step
Background refetch on window focus + interval keeps the cache fresh.
Cases & edge cases
watch for theseInvalidate by tag, not by path — queries keyed by inputs need precise invalidation.
Suspense boundaries around queries → tRPC.useSuspenseQuery() lets the boundary handle the spinner.
Persisted query cache (localStorage) is dangerous in multi-tenant — keys must include orgId.
Where it lives
code references- lib/trpc/provider.tsx
- components/... // useSuspenseQuery / useMutation patterns
Shadcn UI + Tailwind
Shadcn is copy-pasted unstyled-primitive components (Radix under the hood) — they live in your repo, you own them, you style them with Tailwind v4 + design tokens. Not a dependency, not a theme — a starting point.
How it flows
step by step- 01step
pnpm dlx shadcn@latest add button card dialog ...
- 02step
Files land in components/ui/. You edit them like any other file.
- 03step
Tailwind v4: CSS-first config; design tokens under @theme; OKLCH colors for perceptual uniformity.
- 04step
Variants via class-variance-authority (cva) — typed variant API for components.
Cases & edge cases
watch for theseStay disciplined: don't fork a Shadcn component into 3 variations. Either extend with variants or build a wrapper.
Dark mode via @media (prefers-color-scheme) or a class — pick one, stick to it.
Don't import a heavyweight design system on top of Shadcn — pick one approach.
Where it lives
code references- components/ui/
- app/globals.css // tokens + dark mode
Notes
marginShadcn's superpower is that there's no upgrade migration — code changes are diffs you review like any code.
Forms & Validation
react-hook-form for state + Zod for the schema, used on both client and server. The same schema validates form inputs in-browser and validates server-action payloads — single source of truth.
How it flows
step by step- 01step
Schema declared in shared module: export const createOrgSchema = z.object({...}).
- 02step
Client: useForm({ resolver: zodResolver(createOrgSchema) }) → typed handleSubmit.
- 03step
Server Action: createOrgSchema.parse(formData) before any DB work.
- 04step
Errors map back via formState.errors → rendered next to fields.
Cases & edge cases
watch for theseAsync validation (e.g. 'slug taken') — debounce, treat as soft error before submit.
Server Actions + non-JS form submit need the formData parser; client useForm uses the same schema.
Don't show every field's error on first blur — wait for submit attempt for the first batch.
Where it lives
code references- schemas/ // shared Zod schemas
- components/forms/
Caching & Revalidation
Next.js caches at multiple layers: request memoization, data cache, full route cache, CDN. Use tags for granular invalidation; revalidate by tag from Server Actions after mutations.
How it flows
step by step- 01step
Fetch with options: fetch(url, { next: { tags: ['tasks-' + orgId] } }).
- 02step
Mutation completes → revalidateTag('tasks-' + orgId) inside the Server Action.
- 03step
Next request re-hits origin for that tag; everything else stays cached.
- 04step
Time-based: { next: { revalidate: 60 } } for slowly-changing data (pricing, marketing copy).
Cases & edge cases
watch for theseMulti-tenant cache MUST include orgId in tags — otherwise revalidation crosses tenants.
Personalized pages: opt out of full-route cache with dynamic = 'force-dynamic' or `cookies()`.
Edge runtime has stricter cache rules — test both runtimes in CI.
Error & Loading States
Every route segment can have a loading.tsx (Suspense fallback) and an error.tsx (error boundary). Streamed HTML shows the loading state instantly while the data resolves; uncaught throws hit the boundary.
How it flows
step by step- 01step
loading.tsx sibling to page.tsx → React Suspense fallback while RSC streams.
- 02step
error.tsx → boundary that gets reset() to retry; logs to Sentry on mount.
- 03step
Top-level global-error.tsx for layout errors that bypass route errors.
- 04step
Granular Suspense around heavy components: <Suspense fallback={<Skeleton />}><Heavy /></Suspense>.
Cases & edge cases
watch for theseDon't hide errors from users without a way to recover — always show the retry CTA.
Empty states are not error states — handle 'no rows' separately from 'failed to fetch'.
Skeletons should match final layout dimensions to prevent layout shift.
Where it lives
code references- app/error.tsx
- app/(app)/loading.tsx
Theme & Design Tokens
Design tokens live in CSS under @theme. Colors in OKLCH for perceptual uniformity (a 'darker red' really is darker). Dark mode swaps token values; components don't know.
How it flows
step by step- 01step
@theme in app/globals.css defines --primary, --bg, --fg etc. in light mode.
- 02step
@media (prefers-color-scheme: dark) overrides those vars.
- 03step
Tailwind v4 reads CSS vars natively — class='bg-primary' works in both modes.
- 04step
Brand changes: edit one file (globals.css) → entire app re-themes.
Cases & edge cases
watch for theseDon't put brand colors in component code — only in tokens. Reskin = one file edit.
OKLCH lets you specify perceived lightness instead of fighting hex — 'red-500' and 'blue-500' look equally dark.
Reduced motion: respect @media (prefers-reduced-motion) — disable animations.
Where it lives
code references- app/globals.css // tokens
Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.