← All posts

Next.js onboarding (our stack)

6 Jun 2026

Onboarding path for our Next.js stack: App Router, tRPC, Zod, Drizzle, Vitest, Playwright. Learn by doing first; use the sections below as reference.

Start here — code-alongs & official courses

Do these before reading the rest of this post.

1. Official interactive course (required)

Next.js Learn — work through the full App Router track.

Then keep Next.js App Router docs open as your reference.

OrcDev — Fullstack Modern Admin Panel in Next.js
Shadcn UI, Drizzle, Neon, tRPC — matches our stack closely.

YouTube: Fullstack Modern Admin Panel Tutorial

Build along; pause to read linked docs when concepts are new.

3. App Router sections to bookmark

TopicLink
App Router fundamentalshttps://nextjs.org/docs/app
Routing (segments, layouts)https://nextjs.org/docs/app/building-your-application/routing
Data fetching & cachinghttps://nextjs.org/docs/app/building-your-application/data-fetching
Server / Client Componentshttps://nextjs.org/docs/app/building-your-application/rendering
Loading / error / not-foundhttps://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming
Route handlershttps://nextjs.org/docs/app/building-your-application/routing/route-handlers
Deploymenthttps://nextjs.org/docs/app/building-your-application/deploying

Non-negotiables

  • Default to Server Components; opt into Client Components only when needed.
  • Internal microservice calls server-side only (typically behind tRPC procedures).
  • Validate external input at the boundary (Zod).
flowchart TD
  ROUTE["Route segment (app/...)"] --> RSC["Server Components (default)"]
  RSC --> DATA["Server-side data access"]
  RSC --> UI["Render HTML/stream"]
  UI --> CLIENT["Client Components (interactivity)"]

Packages, dependencies, and package.json

package.json describes project metadata, scripts (dev, build, test), and dependencies:

  • dependencies — runtime, needed in production
  • devDependencies — tooling (lint, test, build)
  • peerDependencies — host must provide (common for plugins)

References: package.json · npm registry

pnpm

We use pnpm: fast installs, disk-efficient content-addressed store, strict resolution.

Docs: https://pnpm.io/

Lockfiles & SemVer

pnpm-lock.yaml pins exact versions — treat it as source of truth.

SemVer MAJOR.MINOR.PATCH: ^1.2.3 allows non-major updates; ~1.2.3 allows patches.

pnpm install
pnpm add <pkg>
pnpm add -D <pkg>
pnpm dev   # or test, build, etc.

Starters: create-next-app vs create-t3-app

StarterWhenCommand
create-next-appMinimal baseline; add Tailwind/tests/tRPC yourselfpnpm create next-app@latest
create-t3-appBatteries-included (TS, Tailwind, tRPC, auth options)pnpm create t3-app@latest

Docs: Next.js installation · create.t3.gg

Either generator works. What matters are the standards in this post: App Router boundaries, Zod, protected tRPC procedures, Vitest + Playwright.


TypeScript essentials

TypeScript models data shapes and makes refactors safe. It does not replace runtime validation (Zod).

Read in order:

  1. Handbook intro
  2. Everyday types
  3. Narrowing
  4. Functions
  5. Object types
  6. Generics

Good habits: model domain data; prefer unknown over any at boundaries; narrow with typeof / in / predicates; avoid over-abstracted generics.

In our stack: React props/state · z.infer<typeof schema> · tRPC procedure I/O · typed test factories.


React fundamentals

Sources: React Learn · Thinking in React · Escape Hatches

Good habits: small components with clear props; state in the smallest common owner; forms handle loading + errors; stable list keys (not index).

flowchart TD
  UI["User interacts"] --> EVT["Event handler (onSubmit/onClick)"]
  EVT --> STATE["setState / update state"]
  STATE --> RENDER["React re-renders affected components"]
  RENDER --> UI

Debouncing

Delays work until the user stops typing/clicking for a short window. New input resets the timer.

Reduces noisy feedback, expensive validation/filtering, and per-keystroke network churn.


Tailwind fundamentals

Sources: Tailwind docs · Responsive · States · Preflight

Good habits: compose small components instead of giant class strings; consistent spacing/typography; encode loading/disabled/error/empty states; avoid one-off magic values.


Zod: validation contracts

Runtime validation for forms and tRPC. TypeScript types disappear at runtime; Zod rejects bad browser/script input.

  • Readable errors for users
  • z.infer<> for shared TS types

Forms

TriggerUse when
onSubmitFinal gate — always validate full schema
onBlurGood default for field-level feedback
onChangeFast feedback; debounce (150–300ms sync, 300–600ms async) to avoid flashing
flowchart TD
  INPUT["User fills form"] --> SUBMIT["onSubmit"]
  SUBMIT --> PARSE["schema.safeParse(values)"]
  PARSE -->|success| CALL["call tRPC mutation"]
  PARSE -->|error| MAP["map issues to fields"]
  MAP --> SHOW["render field errors"]
  CALL --> UI["success / server error UI"]

Client-side Zod is UX, not security — validate again on the server.

tRPC boundary

sequenceDiagram
  participant B as Browser (Client)
  participant N as Next.js Server
  participant T as tRPC Procedure
  participant M as Microservice

  B->>N: call tRPC mutation/query
  N->>T: invoke procedure (server)
  T->>T: validate input (Zod)
  T->>T: authz/authn middleware
  T->>M: server-side call
  M-->>T: response
  T-->>N: result (typed)
  N-->>B: response (typed)

Pattern: schemas in schemas.ts or co-located with the router; reuse client + server schemas when coupling stays sane.

Docs: https://zod.dev/ · https://zod.dev/ERROR_HANDLING


Next.js App Router: server vs client boundaries

Default: files are Server Components. Client features (useState, useEffect, DOM events) need "use client".

Server ComponentClient Component
Fetch DB/services, secretsuseState, useEffect, browser APIs
Caching / streamingModals, complex forms, drag/drop
Non-interactive UIHighly interactive widgets

Boundary pattern: Server Component loads data → passes serializable props → Client Component handles interactivity.

flowchart LR
  S["Server Component\n(load data)"] --> C["Client Component\n(interactive UI)"]
  C -->|event| C
  C -->|mutation| API["tRPC call (to server)"]
  API --> SVC["Microservice (server-side)"]

Avoid: microservice calls from the browser; "use client" on entire pages; duplicated state (fieldValue + isValid as separate sources of truth).

Docs: Rendering · Data fetching · Route handlers


tRPC overview

Internal typed API between UI and server logic.

  • End-to-end typing (client ↔ server)
  • Zod validation + auth middleware in one place
  • Clean boundary for microservice calls (server-only)
flowchart LR
  UI["UI (Client Components)"] --> TRPC["tRPC client call"]
  TRPC --> PROC["Procedure (server)"]
  PROC --> VAL["Validate input (Zod)"]
  VAL --> AUTH["Authn/Authz middleware"]
  AUTH --> SVC["Microservice call (server-only)"]
  SVC --> PROC
  PROC --> UI

Every procedure: input schema (Zod), clear auth (public vs protected), consistent error mapping.

Docs: https://trpc.io/docs · Routers · Validators · Middleware


Server-only integration

Never call internal microservices from the browser.

WhyDetail
SecuritySecrets stay on server
ControlAuth, rate limits, validation enforced once
StabilityInternal API changes don’t break clients
ObservabilityConsistent server logs/traces
sequenceDiagram
  participant B as Browser
  participant N as Next.js Server
  participant T as tRPC
  participant M as Microservice

  B->>N: tRPC request (typed)
  N->>T: procedure invoked
  T->>T: validate input (Zod)
  T->>T: authn/authz (middleware)
  T->>M: server-side request
  M-->>T: response
  T-->>B: typed result / mapped error

Browser: UI + display errors. Server: validation, auth, integration, safe logging.


Drizzle ORM

Typed schema, queries, migrations (Drizzle Kit). DB access server-only — inside tRPC procedures or server modules they call.

flowchart LR
  UI["UI (Client)"] --> TRPC["tRPC"]
  TRPC --> PROC["Procedure (server)"]
  PROC --> DAL["Server-only data layer"]
  DAL --> DRZ["Drizzle ORM"]
  DRZ --> DB["Database"]

Learn: table definitions · CRUD · migration workflow.

Docs: https://orm.drizzle.team/ · https://orm.drizzle.team/kit-docs/overview


Vitest (unit + integration)

LayerTarget
UnitPure logic — formatters, validators, mappers
IntegrationSmall component slices; mock boundaries, not internals

Rules: test behavior, not implementation; mock at boundary (tRPC client in UI tests, data layer in procedure tests); typed factories.

Docs: https://vitest.dev/ · RTL


Playwright (E2E)

Real-browser tests for critical journeys — login, create/update, happy paths across systems.

Avoid: every edge case in E2E; duplicating unit coverage.

Rules: resilient locators (role/text/testid); independent tests; auth via fixtures/storage state when applicable.

Docs: https://playwright.dev/docs/intro · Locators · Fixtures


Interview Q&A

General / mindset

Q: What’s the difference between unit, integration, and E2E tests? When would you use each?

Unit: pure logic, no DOM/network — fastest. Integration: one component + mocked boundaries. E2E: real browser, full stack — slow; reserve for critical journeys. Pick the narrowest layer that proves the behavior; don’t duplicate assertions across layers.

Q: How do you decide what runs on the server vs the client in a Next.js app?

Server by default (App Router). Client only when you need state, effects, DOM events, or browser APIs. Data fetching, secrets, and microservice calls stay server-side — typically behind tRPC.

TypeScript

Q: Explain unknown vs any. When should you use unknown?

any disables checking. unknown accepts any value but forces narrowing before use. Prefer unknown at boundaries (API input, JSON.parse, third-party payloads).

Q: What is type narrowing? Give an example.

Refining a type after a check: if (typeof x === "string") then x is string inside the block. Also in checks, discriminated unions, custom type predicates.

Next.js (App Router)

Q: What’s the difference between Server Components and Client Components?

Server Components render on the server — no client JS for interactivity, can access secrets and data directly. Client Components ship JS to the browser for hooks and events. Mark client files with "use client".

Q: How do loading.tsx and error.tsx work in App Router?

Colocated route files. loading.tsx shows while the segment suspends (streaming). error.tsx is an error boundary for that segment — catches render/data errors in children, not event-handler errors.

tRPC

Q: What problem does tRPC solve compared to REST?

End-to-end types without OpenAPI/codegen; procedures as the API unit; input validation and middleware in one layer. Great for monorepo full-stack TS.

Q: Where should authentication/authorization checks live in a tRPC app?

In tRPC middleware on protected procedures — after input validation, before business logic or microservice calls. Never rely on client-only checks.

Tailwind

Q: How do you keep Tailwind-based UIs maintainable as the app grows?

Extract repeated patterns into components; use design tokens (spacing, type scale); encode states explicitly; avoid sprawling one-off class strings.

Vitest

Q: What makes a test brittle? How do you avoid it?

Brittle: asserting implementation details, mocking internals, coupling to DOM structure. Fix: test user-visible behavior; mock at network/tRPC boundary; prefer role/name locators.

Playwright

Q: What makes E2E tests flaky? How do you reduce flakiness in CI?

Timing races, shared state, unstable selectors, env drift. Fix: await on locators not arbitrary sleeps; isolated test data; role/testid locators; auth fixtures; retry only at CI config level when justified.

Debouncing (React / Zod / data fetching)

Q: What is debouncing? What problems does it solve in UI development?

Delays work until input settles — prevents noisy UX and excessive computation/requests.

Q: Zod forms: when would you validate onChange vs onBlur vs onSubmit? Where does debouncing fit?

onSubmit: always validate full schema (final gate). onBlur: good default for field-level feedback. onChange: use when it improves UX; debounce to avoid errors flashing per keystroke.

Q: tRPC: how do you debounce an “availability check” query and ensure stale responses can’t win?

Debounce the query trigger; gate calls (valid email + min length); prevent stale wins via AbortController, request id, or ignoring older results.

Q: useEffect + fetch: how do you debounce a search request and cancel/ignore stale requests?

Debounce the effect (timeout). Use AbortController (or request id) in cleanup to cancel/ignore stale requests.