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.
2. Team code-along (recommended)
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
| Topic | Link |
|---|---|
| App Router fundamentals | https://nextjs.org/docs/app |
| Routing (segments, layouts) | https://nextjs.org/docs/app/building-your-application/routing |
| Data fetching & caching | https://nextjs.org/docs/app/building-your-application/data-fetching |
| Server / Client Components | https://nextjs.org/docs/app/building-your-application/rendering |
| Loading / error / not-found | https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming |
| Route handlers | https://nextjs.org/docs/app/building-your-application/routing/route-handlers |
| Deployment | https://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 productiondevDependencies— 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
| Starter | When | Command |
|---|---|---|
| create-next-app | Minimal baseline; add Tailwind/tests/tRPC yourself | pnpm create next-app@latest |
| create-t3-app | Batteries-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:
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
| Trigger | Use when |
|---|---|
| onSubmit | Final gate — always validate full schema |
| onBlur | Good default for field-level feedback |
| onChange | Fast 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 Component | Client Component |
|---|---|
| Fetch DB/services, secrets | useState, useEffect, browser APIs |
| Caching / streaming | Modals, complex forms, drag/drop |
| Non-interactive UI | Highly 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.
| Why | Detail |
|---|---|
| Security | Secrets stay on server |
| Control | Auth, rate limits, validation enforced once |
| Stability | Internal API changes don’t break clients |
| Observability | Consistent 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)
| Layer | Target |
|---|---|
| Unit | Pure logic — formatters, validators, mappers |
| Integration | Small 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.