Full-Stack Questions

Popular Libraries Interviewers Ask About

TanStack Query, Zustand, Zod, React Hook Form, auth libraries, Redis, BullMQ, Stripe, testing tools and more.

Lesson 1339 min read#libraries

On this page

  1. How to answer "why this library?"
  2. TanStack Query (server state)
  3. Client state: Zustand, Redux Toolkit, Jotai
  4. Zod 4 (validation and types)
  5. React Hook Form + resolvers
  6. Authentication libraries: Auth.js, Better Auth, Clerk
  7. shadcn/ui, Radix and Motion
  8. Dates: date-fns, Day.js, Luxon, Temporal
  9. HTTP clients: fetch, Axios, ky
  10. Real-time: WebSocket, Socket.IO, SSE
  11. Redis and BullMQ
  12. Stripe: payments and webhooks
  13. Testing tools: Vitest, Jest, Testing Library, MSW, Playwright
  14. Docker basics for web developers
  15. GitHub Actions CI
  16. Sentry and OpenTelemetry
  17. ESLint, Prettier, Biome
  18. Monorepos: pnpm workspaces and Turborepo
  19. Vite vs Next.js bundling: Turbopack and webpack
  20. Storybook and i18n
  21. Interview questions
  22. Key takeaways
  23. What's next

In one line: for every library, an interviewer wants four things: why it exists, when you pick it, what it costs (the trade-off), and one gotcha you only learn by using it. This lesson gives you those four things for the libraries a full-stack team uses, with the sharp questions that come with each.

A library is code that someone else wrote and you install. At 4+ years you are not asked "what is Zod?". You are asked "why Zod and not a hand-written check?", "what breaks if you skip it?" and "how did you test it?". So each section below follows the same pattern: what problem it solves, when to use it, trade-off, gotchas.

Versions were checked in October 2026 and the code samples were run against them: TanStack Query 5, Zod 4, React Hook Form 7, Jotai 3, Redux Toolkit 2, Vitest 5, MSW 3, Express 5, BullMQ 6, Stripe SDK 23, Next.js 16. If your project uses an older major version, the ideas stay the same but a few names differ. I point those out.

How to answer "why this library?"

Use this order. It works for any library.

  1. The problem. "Without it, I would write X by hand, and X is easy to get wrong."
  2. The alternatives. "I also looked at A and B."
  3. The cost. Bundle size, learning curve, lock-in, how often it ships breaking changes.
  4. The exit. "If it dies, how hard is it to replace?" A library behind your own small wrapper is easy to replace.

"I would not add a library here" can be the best answer. A useState and a fetch are enough for a page that loads one thing once.

TanStack Query (server state)

Server state is data that lives on a server and that you only borrow in the browser: a list of orders, a user profile. It can become out of date without you doing anything, because another user can change it. Client state is data that only the browser owns, such as "is the menu open?".

TanStack Query (also called React Query) is a library that fetches server state, caches it (keeps a copy to reuse), and keeps that copy fresh. It gives you loading and error states, retries, de-duplication of the same request, and refetching when the user returns to the tab.

When to use it: any app with many screens that show server data. When not to: a small page with one fetch, or data that a Server Component can load for you (see Next.js, Lesson 5). Trade-off: one more library to learn, and its cache is a second place where data lives, so you must think about when it becomes out of date.

Query keys: the address of a cache entry

A query key is an array that names one cache entry. Two components that use the same key share one request and one copy.

JavaScript
import { queryOptions, useQuery } from '@tanstack/react-query'type Todo = { id: number; text: string }// One place that owns the key AND the fetch function.export const todosQuery = (filters: { status: 'open' | 'done'; page: number }) =>  queryOptions({    queryKey: ['todos', filters],    queryFn: async (): Promise<Todo[]> => {      const res = await fetch(`/api/todos?status=${filters.status}&page=${filters.page}`)      if (!res.ok) throw new Error(`HTTP ${res.status}`) // fetch does NOT throw on 404/500      return res.json()    },    staleTime: 30_000,  })export function TodoList() {  const { data, isPending, error } = useQuery(todosQuery({ status: 'open', page: 1 }))  if (isPending) return <p>Loading…</p>  if (error) return <p>Something went wrong.</p>  return <ul>{data.map(t => <li key={t.id}>{t.text}</li>)}</ul>}

Rules I checked by running code:

  • Keys are hashed in a stable way. ['todos', { page: 1, status: 'done' }] and ['todos', { status: 'done', page: 1 }] are the same entry. The order inside an array does matter. [{ page: 1 }, 'todos'] is a different entry.
  • Put every value that changes the result into the key (filters, page, user id), and order keys from general to specific, so invalidateQueries({ queryKey: ['todos'] }) hits all of them.

staleTime and gcTime: two different clocks

SettingQuestion it answersDefault
staleTime"For how long do I trust this data without asking the server again?"0 (data is stale at once)
gcTime"After nobody uses this entry, how long do I keep it in memory?"5 minutes in the browser

Stale means "might be out of date". A stale entry is still shown at once; it is refetched in the background when something triggers it (a component mounts, the window gets focus, the network comes back). gc means garbage collection: removing the unused entry from memory.

Difference in one line: staleTime decides whether to refetch; gcTime decides whether the copy still exists.

Useful facts:

  • With staleTime: 0, every mount triggers a background refetch: safe but chatty. A staleTime of 30 to 60 seconds is the main way to reduce requests.
  • staleTime: Infinity means "never stale by time", but invalidateQueries can still mark it stale. staleTime: 'static' is stricter: even invalidateQueries does not refetch it. Use 'static' for things that cannot change while the app runs, like feature flags loaded at start.
  • The old name of gcTime was cacheTime (renamed in version 5). Old tutorials still say cacheTime.
  • Failed queries retry 3 times by default in the browser. On the server there are no retries, and in Node the default gcTime is Infinity. That is why you create a new QueryClient for each server request (below).
  • In version 5, isPending means "no data yet". isLoading means isPending && isFetching (first load in progress). isFetching is true for any request, including a background refetch. The onSuccess/onError callbacks on useQuery were removed in version 5.

Mutations, invalidation and optimistic updates

A mutation is a request that changes data on the server; you use useMutation for it. Afterwards the cached lists are out of date. invalidateQueries marks matching entries stale and refetches the ones on screen (simple, one extra request). setQueryData writes into the cache yourself (fast, but your guess must match the server).

An optimistic update shows the expected result before the server confirms, so the UI feels instant, and rolls back on failure:

JavaScript
import { useMutation, useQueryClient } from '@tanstack/react-query'export function useAddTodo() {  const qc = useQueryClient()  return useMutation({    mutationFn: async (text: string) => {      const res = await fetch('/api/todos', { method: 'POST', body: JSON.stringify({ text }) })      if (!res.ok) throw new Error('Could not save')      return res.json() as Promise<Todo>    },    // 1. Runs before the request. Save a snapshot and change the cache.    onMutate: async (text) => {      await qc.cancelQueries({ queryKey: ['todos'] }) // stop a refetch from overwriting us      const previous = qc.getQueryData<Todo[]>(['todos'])      qc.setQueryData<Todo[]>(['todos'], (old = []) => [...old, { id: -1, text }])      return { previous } // becomes "context" in onError    },    // 2. On failure, put the snapshot back.    onError: (_err, _text, context) => qc.setQueryData(['todos'], context?.previous),    // 3. Success or failure, ask the server for the truth.    onSettled: () => qc.invalidateQueries({ queryKey: ['todos'] }),  })}

I ran this against a mutation that always fails, and the cache returned to its old value. A simpler way, when only one place shows the data: do not touch the cache, and render mutation.variables as a temporary row while mutation.isPending.

Always cancelQueries in onMutate. If a background refetch finishes after your optimistic write, it overwrites your change with old data and the UI flickers.

SSR and hydration

SSR (server-side rendering) means the server builds the HTML. Hydration means the browser then attaches React to that HTML. If the server already fetched the data, hand it to the browser's cache so it does not fetch again. In the Next.js App Router: make a new QueryClient for each request in a Server Component (a shared one leaks data between users), await queryClient.prefetchQuery(...), wrap client components in <HydrationBoundary state={dehydrate(queryClient)}>, and useQuery with the same key finds the data at once.

I ran dehydrate on one client and hydrate on another with staleTime: 60_000: the browser made 0 requests. With staleTime: 0 it refetches straight away. Gotchas: dehydrated data goes through JSON, so a Date arrives as a string; and the browser needs exactly one QueryClient that survives re-renders.

Client state: Zustand, Redux Toolkit, Jotai

This is the "global store" question. First rule: do not put server data here (use TanStack Query) and do not put everything in global state. See State Management Decisions for the full sorting of state.

ZustandRedux Toolkit (RTK)Jotai
IdeaOne small store; you read pieces with a selectorOne store, reducers, actions, strict flowMany tiny pieces of state called atoms that can depend on each other
Best forApp-wide UI state: cart, filters, modalBig teams, complex flows, need for devtools time travel, strict rulesFine-grained state: many independent values, derived values, form-like screens
Watch out forA global store on the server in Next.jsToo many actions for simple stateAtom sprawl; hard to see the full picture
  • Zustand (version 5): a selector (s => s.items.length) is the function that picks one piece. The component re-renders only when that piece changes. If your selector returns a new object each time, use useShallow to compare it shallowly.
  • Redux Toolkit is the official, modern way to write Redux. createSlice writes reducers with Immer, a helper that lets you "mutate" a draft while it creates an immutable copy for you. configureStore sets up the store and devtools. RTK Query is its data-fetching part, similar in idea to TanStack Query.
  • Jotai (version 3): atom(0) makes a value, atom(get => get(base) * 2) makes a derived one. In version 3, atomFamily moved to a separate package (jotai-family) and the package is ESM only.

In Next.js a module-level Zustand store is shared by all requests on the server. The Zustand docs say to create the store per request and pass it with context, and not to read or write a global store from Server Components.

Zod 4 (validation and types)

Validation means checking that data has the right shape before you trust it. Zod is a TypeScript library that lets you write a schema (a description of the shape) once. From that one schema you get both the runtime check and the TypeScript type.

Why: TypeScript types disappear at runtime. A request body, a query string, an environment variable or an LLM answer can be anything. See Validation at the Edge for where to place checks.

JavaScript
import { z } from 'zod'const Signup = z  .object({    email: z.email(),    age: z.coerce.number().int().min(18),    password: z.string().min(8),    confirm: z.string(),  })  .refine(d => d.password === d.confirm, { error: 'Passwords do not match', path: ['confirm'] })type SignupInput = z.input<typeof Signup>   // what goes in: age can be a stringtype SignupOutput = z.output<typeof Signup> // what comes out: age is a number (same as z.infer)const result = Signup.safeParse({ email: 'a@b.co', age: '20', password: '12345678', confirm: '12345678' })if (result.success) result.data.age // number 20else result.error.issues // [{ path, code, message }, ...]

Key points (all run on Zod 4.6):

  • parse throws a ZodError on bad data. safeParse returns { success: true, data } or { success: false, error }. Use safeParse at boundaries where bad input is normal.
  • z.input and z.output differ when you use coerce, transform or default. z.infer equals the output.
  • refine adds a custom rule; path says which field gets the error. superRefine can add many issues. An async refine needs parseAsync. transform changes the value after it is checked.
  • Zod removes unknown keys by default, which helps against mass assignment (a role: 'admin' in the body is dropped if the schema has no role). z.strictObject makes it an error instead.
  • Errors: z.flattenError(error) gives { formErrors, fieldErrors } for forms; z.treeifyError(error) a nested tree. In Zod 4 the custom message option is error (the old message still works but is deprecated).
  • Standard Schema is a small shared interface that validation libraries implement, so other tools can accept any compatible schema. Every Zod schema has a ~standard property (version, vendor, validate). Valibot and ArkType follow it too.
  • Codecs (z.codec) are two-way transforms such as ISO string to Date. z.toJSONSchema makes JSON Schema.

z.coerce.number() turns an empty string '' and null into 0, so an empty input passes as zero. Also z.coerce.boolean() turns the string 'false' into true, because any non-empty string is true. Use z.stringbool() for 'true'/'false' environment variables, and check empty strings before coercing numbers.

React Hook Form + resolvers

React Hook Form (RHF) is a library that manages form state. Its main idea is that inputs are uncontrolled: the browser keeps each input's value, and RHF reads it through a ref when needed. That means typing in a field does not re-render the whole form. A controlled input stores its value in React state and re-renders on every key press.

A resolver is a small adapter that lets RHF use an outside validator, like a Zod schema, instead of its own rules. The package is @hookform/resolvers.

JavaScript
import { useForm } from 'react-hook-form'import { zodResolver } from '@hookform/resolvers/zod'import { z } from 'zod'const Schema = z.object({  email: z.email('Enter a valid email'),  age: z.coerce.number().min(18, 'Must be 18+'),})type In = z.input<typeof Schema>type Out = z.output<typeof Schema>export function SignupForm({ onValid }: { onValid: (v: Out) => void }) {  const {    register,    handleSubmit,    formState: { errors, isSubmitting },  } = useForm<In, unknown, Out>({ resolver: zodResolver(Schema), defaultValues: { email: '', age: '' } })  return (    <form onSubmit={handleSubmit(onValid)}>      <input aria-label="email" {...register('email')} />      {errors.email && <p role="alert">{errors.email.message}</p>}      <input aria-label="age" {...register('age')} />      {errors.age && <p role="alert">{errors.age.message}</p>}      <button disabled={isSubmitting}>Go</button>    </form>  )}

I tested this in a browser-like test environment with a user typing: bad values show both messages, and good values reach onValid as { email, age: 30 } where age is already a number.

  • Three type arguments: useForm<Input, Context, Output>. The first is what inputs hold, the third is what handleSubmit receives after Zod runs.
  • Controller is for controlled components without a ref (a custom select). useFieldArray manages lists of fields.
  • Reuse the same schema on the server; anyone can call your API without your form.
  • Trade-off: great for large forms; for one field, plain state is simpler. In React 19 and Next.js you can also use <form action={...}> with Server Actions and useActionState (React, Lesson 4).

Authentication libraries: Auth.js, Better Auth, Clerk

Pick only well-established tools. Here, Auth.js is the old and common one, Clerk is a long-running paid service, and Better Auth is newer (about two years old) but is now the one the Auth.js team recommends. Say that honestly in an interview.

Authentication means proving who you are. Authorization means deciding what you may do. The server side of both is in Backend, Lesson 6 and the browser side in Sessions vs JWT.

Auth.js (NextAuth)Better AuthClerk
What it isOpen-source library; you run it in your appOpen-source library; you run it in your appA hosted service with ready-made UI
Your databaseOptional, through adaptersYes, stores users and sessions in your databaseTheir database
CostFreeFreePaid after a free tier
Pick it whenAn existing app already uses itNew project, you want email/password, OAuth, 2FA, passkeys in your own DBYou want to ship fast and do not want to own auth
Trade-offYou build the UI; version 5 is still marked beta on npm (next-auth@latest is 4.24)Younger project; fast-changingVendor lock-in and cost

Facts from the Auth.js source and README: the Auth.js README now says it is part of Better Auth and recommends Better Auth for new projects, except where you need stateless sessions with no database. Auth.js has a migration guide to Better Auth.

Sessions vs JWT inside these libraries

  • A database session stores a random session id in a cookie and the real data in your database. You can delete the row to log someone out at once.
  • A JWT (JSON Web Token) session puts the signed data in the cookie itself. No database read is needed. But you cannot take it back before it expires, unless you keep a block list (which makes it stateful again).
  • In Auth.js the default is: database strategy if you pass an adapter, JWT if you do not. The Credentials (email and password) provider works only with the JWT strategy. The default session lifetime is 30 days.
  • Better Auth uses cookie sessions stored in the database. The default session lasts 7 days and is refreshed once a day when used. It can also add a short-lived cookie cache so it does not hit the database on every call.

An adapter is a small package that teaches Auth.js how to read and write users, accounts and sessions in a specific database (Prisma, MongoDB, Drizzle and more).

CSRF

CSRF (cross-site request forgery) is when a bad site makes the user's browser send a request to your site, and the browser attaches the user's cookies. The defences are:

  • SameSite=Lax cookies (the Auth.js session cookie is httpOnly and SameSite=Lax), so cookies are not sent on most cross-site POST requests.
  • A CSRF token: Auth.js sets a csrf-token cookie and checks it on its POST routes (sign in, sign out).
  • Origin checking: Better Auth checks the Origin header against trustedOrigins, prefers JSON requests (which browsers do not send cross-site without a CORS check), and uses Sec-Fetch-* headers for first-login attacks.

See Security and Auth Scenario Bank for the attack stories.

shadcn/ui, Radix and Motion

Radix UI is a set of headless components: they give you behaviour and accessibility (focus trap in a dialog, keyboard keys, ARIA attributes) but no styling. shadcn/ui is not a package you install as a library. It is a command line tool that copies component source code into your project, built on a headless base (Radix, and now also Base UI) and styled with Tailwind. You own the code and edit it freely.

Why: accessible dialogs, menus and comboboxes have many edge cases. Trade-off: you own the code, so upstream fixes do not arrive by npm update; you pull changes by hand.

Motion (the new name of Framer Motion; import from motion/react) is an animation library for React. <motion.div animate={{ opacity: 1 }} /> animates a value. AnimatePresence lets a component animate out before it is removed. layout animates changes of size and position. I type-checked a Radix Dialog wrapped in AnimatePresence with forceMount and asChild.

Interview points: animate transform and opacity (cheap for the browser), not width or top (they force layout). Respect prefers-reduced-motion with useReducedMotion(). asChild passes Radix behaviour to your own element instead of adding an extra one. A Radix Dialog needs a Title for screen readers.

Dates: date-fns, Day.js, Luxon, Temporal

The built-in Date has traps. I ran these:

  • new Date('2026-03-08') (date only) is parsed as UTC midnight. Local time may show the previous day.

  • Months count from 0 (January is 0).

  • Date overflows: January 31 plus one month becomes March 3. Day.js clamps to February 28.

  • A "day" is not always 24 hours. In New York on 2026-03-08 the clock jumps forward (daylight saving time, DST). Adding 1 day to noon gives noon the next day. Adding 24 hours gives 1 pm. Luxon, date-fns with its timezone package and Temporal all behave this way.

  • date-fns: small functions on native Date; tree-shakes well; time zones need @date-fns/tz.

  • Day.js: a tiny chained object, like the old Moment.js; zones come from plugins.

  • Luxon: immutable objects with strong time-zone support built on Intl; larger, but the most correct for zones.

  • Temporal: the new built-in standard (PlainDate, ZonedDateTime). Support is still rolling out (in Node 22 it is not defined), so check your targets or use the polyfill.

  • Moment.js: in maintenance mode; do not start new code with it.

Rules I use: store and send instants as ISO 8601 in UTC (2026-03-08T12:00:00Z). Convert to a time zone only for display. Store a user's zone name (America/New_York), not a fixed offset like -05:00, because the offset changes with DST. For "date only" things like birthdays, use a date without time.

HTTP clients: fetch, Axios, ky

I ran each against a local server. The main differences:

  • fetch is built in. It does not throw on 404 or 500 (res.ok is false). Timeout: AbortSignal.timeout(ms) gives a TimeoutError. Retries and JSON parsing you write yourself.
  • Axios throws an AxiosError on non-2xx (with response.status), has a timeout option (error code ECONNABORTED), interceptors and upload progress.
  • ky is a tiny fetch wrapper that throws HTTPError, has timeout, hooks and built-in retries (a 503 was retried and then succeeded).

Choice: I use fetch behind one small wrapper that sets base URL, auth header, res.ok check, timeout and retries for safe methods. The library matters less than having one place for timeouts, retries and error mapping.

fetch rejects only on network failures, not on HTTP errors. Forgetting if (!res.ok) is one of the most common bugs in code review.

Real-time: WebSocket, Socket.IO, SSE

  • WebSocket is a protocol that keeps one two-way connection open between browser and server (ws is the usual Node server).
  • SSE (Server-Sent Events) is a one-way stream from server to browser over normal HTTP. The browser's EventSource reconnects by itself and sends Last-Event-ID, so you can continue where it stopped.
  • Socket.IO is a library on top of WebSocket with rooms, acknowledgements, reconnection and an HTTP long-polling fallback. It is not plain WebSocket: a plain WebSocket client cannot talk to a Socket.IO server.

I ran all three in one Express server and each worked as described. Pick: SSE when the server pushes and the client rarely sends (notifications, progress); WebSocket (or Socket.IO) for two-way, low-delay traffic (chat, collaboration). With many server instances you need a shared bus such as Redis pub/sub. See Design a Notification System.

Redis and BullMQ

Redis is an in-memory database that stores data as keys and values, with a rich set of types. It is very fast because it keeps data in RAM. BullMQ is a job queue library that uses Redis to hold jobs, so slow work (sending email, resizing images) runs in a separate worker process instead of inside the HTTP request.

I ran each pattern against a real Redis server (version 7):

1. Cache-aside. Read from Redis first; on a miss, read the database and store the result with a TTL (time to live, an automatic expiry). Two reads caused one database call.

JavaScript
import Redis from 'ioredis'const redis = new Redis()async function getUser(id: string) {  const key = `user:${id}`  const hit = await redis.get(key)  if (hit) return JSON.parse(hit)  const user = await db.user.findUnique({ where: { id } }) // your database call  await redis.set(key, JSON.stringify(user), 'EX', 60)    // expire in 60 s  return user}

Trade-off: the hard part is invalidation (removing stale data). Delete the key when the user changes, and keep a short TTL as a safety net. A cache stampede is when a hot key expires and 1,000 requests hit the database together; reduce it with a lock, jittered TTLs or serving stale data for a moment.

2. Rate limit (fixed window). INCR a key such as rl:{ip} and call EXPIRE on the first hit. With a limit of 3, the first three calls passed and the next two were blocked.

Gotcha: if the process crashes between INCR and EXPIRE, the key never expires. Fix it with a Lua script (Redis runs it as one atomic step). More designs are in Design a Rate Limiter.

3. Lock. SET lock:job a EX 5 NX returned OK for the first caller and null for the second (NX = only if the key does not exist). Fine for simple locks; money-critical locks need more care.

4. Pub/sub. PUBLISH sends a message to every subscriber. Messages are not stored: an offline subscriber misses them. Use a queue or Redis Streams when you need delivery guarantees.

5. BullMQ.

JavaScript
import { Queue, Worker } from 'bullmq'const connection = { host: '127.0.0.1', port: 6379 }const emails = new Queue('emails', { connection })await emails.add(  'welcome',  { to: 'a@b.co' },  { attempts: 5, backoff: { type: 'exponential', delay: 1000 }, jobId: 'welcome-a@b.co', removeOnComplete: true },)new Worker('emails', async job => sendEmail(job.data.to), { connection, concurrency: 5 })

My test job failed twice and succeeded on the third try. Adding another job with the same jobId returned the existing job, so a jobId is a simple way to avoid duplicates. Rules to remember: jobs are delivered at least once, so your worker must be idempotent (safe to run twice). Remove finished jobs (removeOnComplete) or Redis fills up. If you pass your own ioredis connection to a Worker, set maxRetriesPerRequest: null. And the Redis server used for queues should have maxmemory-policy noeviction, otherwise Redis may delete job data when memory is full.

Stripe: payments and webhooks

Stripe is a payment service. The golden rule: your server decides what is paid, based on Stripe's webhook, not on what the browser says. A webhook is an HTTP request that Stripe sends to your server when something happens (for example checkout.session.completed).

Three things interviewers always ask:

  1. Verify the signature with stripe.webhooks.constructEvent(rawBody, signatureHeader, secret). Anyone can post to your URL. It needs the raw bytes; if express.json() already parsed the body, the check fails.
  2. Be idempotent. The same event can arrive twice. Save event.id (unique constraint) and skip repeats. For your own calls, pass an idempotencyKey so a retry does not charge twice.
  3. Respond fast with 2xx, then do slow work in a queue. Order is not guaranteed.
JavaScript
import express from 'express'import Stripe from 'stripe'const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)const app = express()// Raw body ONLY on this route, and BEFORE express.json().app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {  let event: Stripe.Event  try {    event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'] as string, process.env.STRIPE_WEBHOOK_SECRET!)  } catch {    return res.status(400).send('bad signature')  }  if (await alreadyHandled(event.id)) return res.json({ duplicate: true }) // your DB check  await queue.add(event.type, event)                                        // slow work elsewhere  res.json({ received: true })})app.use(express.json()) // all other routes

I tested this with generated signatures. The right body returned 200, a repeat of the same event was treated as a duplicate, a changed body gave 400, and a timestamp older than 5 minutes (the default tolerance, 300 seconds) gave 400. The SDK also pins an API version, and recent versions use names like 2026-09-30.endive, so upgrading the SDK can change API behaviour.

Other points: use test-mode keys and the Stripe CLI; store money as integer cents; let Checkout or the Payment Element handle card data so card numbers never touch your server.

Testing tools: Vitest, Jest, Testing Library, MSW, Playwright

ToolWhat it does
Vitest (version 5)Test runner built on Vite. Fast, same config as Vite, Jest-like API.
Jest (version 30)The older, very common test runner. Fine for existing projects.
Testing LibraryRenders React components and lets you find things the way a user does (by role and label), not by class name.
MSW (Mock Service Worker)Fakes the network. Your real fetch code runs, and MSW answers the request.
PlaywrightDrives a real browser for end-to-end (E2E) tests.

The principle: test behaviour a user can see. Mock the network (MSW with onUnhandledRequest: 'error'), not your own modules. See Frontend Testing Strategy and Testing an API.

I ran a test with Vitest, a jsdom environment, Testing Library, user-event, MSW and TanStack Query together. The form test typed bad values and saw two errors. The data test loaded a list from the fake network, then showed the message from a failing POST.

  • Make a new QueryClient per test and turn off retries, or tests are slow and leak into each other.
  • Use findBy... (waits) for things that appear later, not getBy....
  • Vitest does not support async Server Components, and the Next.js docs recommend E2E tests for them. Unit tests work for synchronous Server and Client Components.
  • Playwright uses role-based locators (page.getByRole('button', { name: 'Sign in' })) and web-first assertions (await expect(page).toHaveURL(/dashboard/)) that wait and retry. Avoid fixed sleeps. (I type-checked a Playwright test; no browser could be installed in my sandbox.)

Docker basics for web developers

Docker is a tool that packs your app and everything it needs (Node, packages, files) into an image, which you run as a container (an isolated process). Everyday comparison: a shipping container: the same box works on any ship.

A multi-stage build uses one stage to build and a smaller final stage to run, so build tools do not end up in production.

Shell
FROM node:24-alpine AS buildWORKDIR /appCOPY package.json package-lock.json ./RUN npm ciCOPY . .RUN npm run buildFROM node:24-alpineWORKDIR /appENV NODE_ENV=productionCOPY package.json package-lock.json ./RUN npm ci --omit=devCOPY --from=build /app/dist ./distUSER nodeEXPOSE 3000CMD ["node", "dist/server.js"]

Why each line matters:

  • Copy package*.json first. Docker caches each step as a layer, so if only source changed, the slow npm ci layer is reused.
  • .dockerignore must list node_modules, .git and .env.
  • USER node: do not run as root. Never bake secrets into an image; pass them at runtime.
  • Handle SIGTERM: containers are stopped with a signal, so finish current requests and close connections (Node.js & Express, Lesson 6).
  • Next.js can create a small standalone output (output: 'standalone') for Docker. I could not build images here (no Docker daemon), so I only checked that the base image tags exist.

GitHub Actions CI

CI (continuous integration) means every change is built and tested by a machine before it is merged. GitHub Actions is GitHub's CI. A workflow is a YAML file in .github/workflows/ with jobs, and each job has steps.

YAML
name: CIon:  pull_request:  push:    branches: [main]concurrency:  group: ci-${{ github.ref }}  cancel-in-progress: true   # a new push cancels the older runpermissions:  contents: read             # least privilege for the tokenjobs:  check:    runs-on: ubuntu-latest    services:      redis:        image: redis:7        ports: ['6379:6379']    steps:      - uses: actions/checkout@v7      - uses: pnpm/action-setup@v6   # reads the packageManager field in package.json      - uses: actions/setup-node@v7        with:          node-version: 24          cache: pnpm      - run: pnpm install --frozen-lockfile      - run: pnpm lint && pnpm typecheck && pnpm test && pnpm build

I parsed this YAML and checked the current major version of each action. --frozen-lockfile fails if the lock file is out of date. Pin action versions (a full commit SHA is safest). For slow CI: cache dependencies, run jobs in parallel, run only affected packages, and shard E2E tests. See Debugging, Lesson 14 for the "CI takes 40 minutes" decision.

Sentry and OpenTelemetry

Observability means understanding what your system is doing from the outside. The signals are logs (events), metrics (numbers over time) and traces (the path of one request). See Logging and Observability.

  • Sentry is a service that catches errors and shows the stack trace, the browser, the release and what happened just before. For frontend code, upload source maps at build time so minified code is readable, and tag errors with a release. Set a sample rate for traces (tracesSampleRate: 0.1) so you do not pay for every request.
  • OpenTelemetry (OTel) is a vendor-neutral standard and set of libraries for traces, metrics and logs. A span is one timed step inside a trace. Context passes between services in the traceparent header. In code you call tracer.startActiveSpan('pay', async span => { ... span.end() }); forgetting span.end() leaks the span. I type-checked this API. Next.js supports OTel through an instrumentation.ts file.

Rules: scrub passwords, tokens and personal data before they reach an error tracker. Do not use an orderId as a metric label: too many different values make metrics expensive (high cardinality). Alert on user pain (error rate, latency), not on every exception.

ESLint, Prettier, Biome

  • ESLint is a linter: it finds likely bugs and rule breaks. Current ESLint uses a flat config file (eslint.config.mjs) and typescript-eslint for TypeScript rules.
  • Prettier is a formatter: it only changes layout, so nobody argues about style.
  • Biome is one fast tool that lints and formats. Trade-off: one config and speed, but fewer plugins than ESLint.

I ran all three on a three-line file. ESLint (flat config with typescript-eslint) reported no-explicit-any, eqeqeq, prefer-const and an unused variable. Prettier said the file needed formatting. biome check found the any and the let.

Rule: a linter finds problems, a formatter fixes style, and they must not fight. Run both in CI and in a pre-commit hook on staged files. Start with the recommended rule set, not a hundred rules.

Monorepos: pnpm workspaces and Turborepo

A monorepo is one Git repository with many packages (apps/web, apps/api, packages/schemas). Why: share code such as Zod schemas with one version, and change app and library in one pull request. Trade-off: you need tooling or CI gets slow, and one broken package can block everyone.

  • pnpm workspaces: pnpm-workspace.yaml lists the package folders. Packages depend on each other with workspace:*. pnpm stores each version once and links it, and a package cannot import something it did not list (no "phantom dependencies").
  • Turborepo: a task runner. In turbo.json, "build": { "dependsOn": ["^build"] } means "build my dependencies first". It hashes the inputs of each task and caches the outputs, so unchanged packages skip work; a remote cache shares this with CI. (The top-level key is tasks; older versions called it pipeline.)

Vite vs Next.js bundling: Turbopack and webpack

A bundler joins your many source files into files the browser can load, and optimizes them (minify, split, remove unused code).

  • Vite is a dev server and build tool, mostly for single-page apps. In development it serves native browser modules, so starts are fast. Vite 8 builds with Rolldown, a Rust bundler.
  • Next.js is a full framework. In version 16, Turbopack is the default bundler for next dev and next build; --webpack opts out. Next 16 also renamed middleware.ts to proxy.ts.

Choice: a client-side app or library: Vite. An app that needs SEO, server rendering and API routes in one project: Next.js.

Storybook and i18n

Storybook shows each UI component alone in a catalogue, in all its states (empty, loading, error). You build and review components without running the whole app, and can run accessibility and visual tests on stories. Trade-off: stories can fall out of date. Worth it for a shared design system, less so for a small app.

i18n (internationalization) means preparing an app for many languages and regions; l10n (localization) is the translation and formatting for one locale. next-intl is a popular library for the Next.js App Router: defineRouting lists locales, proxy.ts picks the locale from the URL, and messages use ICU format. I ran {count, plural, =0 {Your cart is empty} one {# item} other {# items}} for 0, 1 and 1234: it printed "Your cart is empty", "1 item" and "1,234 items" (German formats the number as "1.234").

Gotchas: never build sentences by joining strings (word order differs). Use plural rules from the message format, not count === 1. Format dates and numbers with Intl. German text is often longer, and Arabic and Hebrew need right-to-left layout (use CSS logical properties like margin-inline-start).

Interview questions

Why use TanStack Query instead of useEffect plus useState? (Must know)

useEffect plus useState gives me only the first fetch. I would have to write caching, de-duplication, retries, race-condition handling, loading and error states and refetch on focus myself, and each has bugs. TanStack Query does all of this and shares one cache across components. The cost is a second source of truth that I keep fresh with staleTime, invalidation and good keys. If a page loads one thing once, or a Server Component can load it, I skip the library.

What is the difference between staleTime and gcTime? (Must know)

staleTime is how long I trust fetched data before a refetch is allowed; the default is 0. gcTime is how long an unused entry stays in memory; the default is 5 minutes in the browser. So staleTime controls requests and gcTime controls memory. A common mistake is raising gcTime to avoid refetches. It does not help. cacheTime is the old name. On the server I create a client per request, because there the default gcTime is Infinity and there are no retries.

How do you build an optimistic update with rollback? (Senior signal)

In onMutate I cancelQueries for that key, take a snapshot with getQueryData, write the guess with setQueryData, and return the snapshot. In onError I restore it. In onSettled I invalidateQueries so the server has the last word. If only one component shows the data, I just render mutation.variables as a temporary row while isPending. If the server often rejects the change, optimistic UI misleads the user, so I show a clear error and keep their input.

How do you prefetch on the server and hand the data to the browser? (Senior signal)

In the Server Component I make a new QueryClient per request, await prefetchQuery with the same key the client uses, and wrap the client part in HydrationBoundary with dehydrate(queryClient). In the browser I use one long-lived client with a staleTime above 0, or it refetches right after hydration (I tested: with a minute of staleTime the browser made no request). Gotchas: a shared server client leaks data between users, and a Date becomes a string after JSON.

When do you use Zustand, Redux Toolkit or Jotai? (Must know)

First I ask if it is really global client state. Server data goes to a query cache, form state to the form library. For shared UI state in a medium app (cart, filters) I use Zustand: tiny, and selectors limit re-renders. For a big team that wants strict rules, traceable actions and devtools, Redux Toolkit. For many small linked values with derived state, such as an editor, Jotai. In Next.js I create stores per request, not as a module-level global.

What is the difference between parse and safeParse in Zod? (Must know)

parse returns typed data or throws a ZodError. safeParse returns { success, data } or { success: false, error }. I use safeParse for user input and answer 400 or 422 with z.flattenError(error). I use parse where a crash is right, such as environment variables at startup. With an async refine I must use parseAsync; plain parse throws an error that says so.

How do z.input, z.output and z.infer differ? (Senior signal)

z.input is the type before the schema runs, z.output after it (and z.infer equals output). They differ with coerce, transform or default. For z.coerce.number() the input type is unknown and the output is number. In forms React Hook Form needs the input type for field values and the output type for handleSubmit, so I write useForm<In, unknown, Out>. Standard Schema is the shared interface (~standard) that lets other tools accept any compatible schema; it is a contract, not a library.

React Hook Form: controlled or uncontrolled? Why does it matter? (Must know)

RHF registers inputs as uncontrolled: the DOM keeps the value and RHF reads it through a ref. Typing does not re-render the whole form, which helps large forms. For components without a ref, such as a custom select, I use Controller. A resolver lets the same Zod schema validate on client and server. Trade-off: for live previews I need useWatch on just the fields I need.

Sessions or JWT: which do you choose, and how do you log a user out? (Senior signal)

For a normal web app I choose a server-side session with an httpOnly, Secure, SameSite=Lax cookie. I can end it at once by deleting the row. A JWT helps when many services must verify a user without calling one auth service, but it cannot be recalled before it expires. So I use a short life (minutes) plus a revocable refresh token. "Stateless logout" is a myth: I either keep a block list (state) or accept the token until expiry. In Auth.js the default is database sessions with an adapter and JWT without one.

Auth.js, Better Auth or Clerk for a new Next.js project? (Trap)

Clerk if the team wants speed and does not want to own user tables; the cost is money and lock-in. For a library in my own code and database, Better Auth, because the Auth.js repo says it is part of Better Auth and recommends it for new projects. Caveat: Better Auth is about two years old, so I pin versions and read changelogs. Auth.js is fine for existing apps, but next-auth@latest is still 4.x and version 5 is a beta tag. The trap is saying "NextAuth" without knowing this.

A Stripe webhook handler fails verification or charges twice. What do you check? (Must know)

For failed verification: am I using the raw body? If express.json() runs first, the check fails. Is the secret for this endpoint and mode? Is the server clock right (tolerance is 5 minutes)? For duplicates: Stripe retries events, so I store event.id under a unique constraint and return 200 for repeats, make the work idempotent, and do slow work in a queue. For my own calls I pass an idempotency key. I never trust the browser's success page; the webhook is the truth.

How would you add rate limiting with Redis, and what can go wrong? (Senior signal)

Fixed window: INCR a key such as rl:{ip}, set EXPIRE on the first hit, block above the limit. It allows a burst at the window edge. If the process dies between INCR and EXPIRE the key lives forever, so I use a Lua script to make it atomic. For smoother limits I use a sliding window or token bucket. Behind a proxy I must read the real client IP (trust proxy), I decide to fail open or closed if Redis is down, and I return 429 with Retry-After.

BullMQ jobs sometimes run twice. Is that a bug? (Trap)

No. Queues give "at least once" delivery: a worker can crash after the work but before the job is marked done. So the worker must be idempotent (check "was this email already sent?" with a unique key), and jobId stops duplicate adds. I set attempts with exponential backoff, limit removeOnComplete and removeOnFail, set noeviction on the queue's Redis, and watch failures and queue depth.

WebSocket, Socket.IO or SSE for live notifications? (Must know)

For notifications the server sends and the client listens, so I start with SSE: plain HTTP, easy to debug, and EventSource reconnects with Last-Event-ID so I can replay missed events. WebSocket for real two-way traffic like chat. Socket.IO if I want rooms, acknowledgements and reconnection built in, accepting its own protocol (a plain WebSocket client cannot connect to it). With many instances I need a shared bus like Redis pub/sub, and I must authenticate the connection.

How do you test a component that calls an API, and why is fetch a gotcha? (Senior signal)

Testing Library to act like a user, MSW to fake the network so my real fetch and TanStack Query code runs, onUnhandledRequest: 'error', a fresh QueryClient per test with retry: false, and findBy queries for later content. I test the 500 path too. A few Playwright tests cover the key flows with role-based locators. The gotcha: fetch does not throw on 404 or 500 (Axios and ky do), so I check res.ok in one small wrapper that also sets a timeout with AbortSignal.timeout.

Vite or Next.js? Walk me through a Dockerfile and CI for a Node API. (Must know)

Vite for a client-side app or a library. Next.js when I need server rendering, SEO and route handlers in one project. In Next.js 16 Turbopack is the default bundler and middleware.ts is now proxy.ts. Dockerfile: multi-stage, copy package*.json and run npm ci first for layer caching, production dependencies only in the final stage, .dockerignore, USER node, no secrets in the image, handle SIGTERM. CI: frozen lock file with cache, lint, type-check, tests with Redis or the database as a service, build, concurrency to cancel old runs, least-privilege permissions, pinned actions, deploy only from main.

Key takeaways

  • For any library give problem, alternatives, cost, exit. "I would not add one here" can be the best answer.
  • TanStack Query: server state only. staleTime is about refetching, gcTime about memory. Put every changing input in the key. New client per server request.
  • Zustand for small shared UI state, Redux Toolkit for big strict apps, Jotai for linked small values. Never put server data in them.
  • Zod: safeParse at boundaries, z.input vs z.output, careful with coerce, parseAsync for async rules.
  • Auth: sessions can be revoked, JWTs cannot. Auth.js v5 is still beta on npm, and the project now recommends Better Auth.
  • Stripe: raw body, idempotent handlers, answer fast, trust the webhook. Redis/BullMQ: invalidate caches, make rate limits atomic, expect jobs to run at least once.
  • fetch does not throw on HTTP errors. Store dates as UTC instants plus a zone name.
  • Tooling: Vitest + Testing Library + MSW, Playwright for key flows, multi-stage Docker, cached CI, Sentry and OpenTelemetry, ESLint + Prettier or Biome, pnpm + Turborepo, Turbopack default in Next.

What's next

Lesson 14: The Experience-Level Round. Libraries show what you know. The last lesson shows how you think and work: ownership, trade-offs, debugging stories, code review, and what to ask in return.

Published by Jainik · Last updated · How these lessons are made

Keep learning

Related series