arch-atlas
SaaS Atlas·Pillar 06 / 12

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.

8Nodes0Vendors0Decisions0Checklist1Flows
01
RoutingServer Components default

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
  1. 01

    Routes under app/(marketing)/, app/(app)/, app/(admin)/ as route groups.

    step
  2. 02

    Parallel routes for slots (e.g. @modal); intercepting routes for modal-on-deeplink.

    step
  3. 03

    Server Components fetch data directly (await db…) — no useEffect.

    step
  4. 04

    Client Components: 'use client' at the top; they hydrate, they have state, they read tRPC.

    step

Cases & edge cases

watch for these

Server 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
References
  • app/layout.tsx // root layout
  • app/(app)/layout.tsx // authenticated shell
02
RoutingMutations without an API route

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
  1. 01

    Define: 'use server'; export async function createTask(formData) { ... }.

    step
  2. 02

    Use from a <form action={createTask}> — works without JS.

    step
  3. 03

    Or call from a client component: const result = await createTask(input).

    step
  4. 04

    Server-side: validate with Zod → call DB via withTenant → return typed result.

    step
  5. 05

    Revalidate: revalidatePath('/tasks') refreshes the server cache.

    step

Cases & edge cases

watch for these

Server 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
References
  • app/(app)/tasks/_actions.ts
  • lib/actions/with-action.ts // shared error / auth wrapper

Notes

margin

Server Actions don't replace tRPC for reads — they're a write-side primitive. Use both, they complement.

03
StateReads · Cache · Optimism

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
  1. 01

    Server Component fetches initial data → passes as prop to a Client tree.

    step
  2. 02

    Client uses trpc.X.useQuery() with initialData = the prop → no double-fetch on mount.

    step
  3. 03

    User mutates → useMutation with onMutate (optimistic) + onError (rollback) + onSettled (refetch).

    step
  4. 04

    Background refetch on window focus + interval keeps the cache fresh.

    step

Cases & edge cases

watch for these

Invalidate 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
References
  • lib/trpc/provider.tsx
  • components/... // useSuspenseQuery / useMutation patterns
04
UIOwned components

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
  1. 01

    pnpm dlx shadcn@latest add button card dialog ...

    step
  2. 02

    Files land in components/ui/. You edit them like any other file.

    step
  3. 03

    Tailwind v4: CSS-first config; design tokens under @theme; OKLCH colors for perceptual uniformity.

    step
  4. 04

    Variants via class-variance-authority (cva) — typed variant API for components.

    step

Cases & edge cases

watch for these

Stay 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
References
  • components/ui/
  • app/globals.css // tokens + dark mode

Notes

margin

Shadcn's superpower is that there's no upgrade migration — code changes are diffs you review like any code.

05
UIreact-hook-form + Zod

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
  1. 01

    Schema declared in shared module: export const createOrgSchema = z.object({...}).

    step
  2. 02

    Client: useForm({ resolver: zodResolver(createOrgSchema) }) → typed handleSubmit.

    step
  3. 03

    Server Action: createOrgSchema.parse(formData) before any DB work.

    step
  4. 04

    Errors map back via formState.errors → rendered next to fields.

    step

Cases & edge cases

watch for these

Async 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
References
  • schemas/ // shared Zod schemas
  • components/forms/
06
PerformanceServer cache · tag invalidation

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
  1. 01

    Fetch with options: fetch(url, { next: { tags: ['tasks-' + orgId] } }).

    step
  2. 02

    Mutation completes → revalidateTag('tasks-' + orgId) inside the Server Action.

    step
  3. 03

    Next request re-hits origin for that tag; everything else stays cached.

    step
  4. 04

    Time-based: { next: { revalidate: 60 } } for slowly-changing data (pricing, marketing copy).

    step

Cases & edge cases

watch for these

Multi-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.

07
UIerror.tsx · loading.tsx · Suspense

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
  1. 01

    loading.tsx sibling to page.tsx → React Suspense fallback while RSC streams.

    step
  2. 02

    error.tsx → boundary that gets reset() to retry; logs to Sentry on mount.

    step
  3. 03

    Top-level global-error.tsx for layout errors that bypass route errors.

    step
  4. 04

    Granular Suspense around heavy components: <Suspense fallback={<Skeleton />}><Heavy /></Suspense>.

    step

Cases & edge cases

watch for these

Don'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
References
  • app/error.tsx
  • app/(app)/loading.tsx
08
UIOKLCH · Dark mode · Brand

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
  1. 01

    @theme in app/globals.css defines --primary, --bg, --fg etc. in light mode.

    step
  2. 02

    @media (prefers-color-scheme: dark) overrides those vars.

    step
  3. 03

    Tailwind v4 reads CSS vars natively — class='bg-primary' works in both modes.

    step
  4. 04

    Brand changes: edit one file (globals.css) → entire app re-themes.

    step

Cases & edge cases

watch for these

Don'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
References
  • app/globals.css // tokens
Annotate0 strokes

Apple Pencil draws with pressure. Fingers still scroll. Saved per pillar.