Skip to main content
CodeOath
← All posts

Next.js75 min total · 12 parts

Next.js Fundamentals: The App Router, Server Components, and the Caching Rules That Just Flipped

Part 12 of 12 · ~5 min

Common Gotchas

When the server's HTML doesn't match what the browser renders

Next.js pre-builds HTML on the server, then React "hydrates" it in the browser — attaching behavior to markup that's already sitting on the page rather than building it from nothing. If what React would have produced on its own doesn't line up with what the server actually sent, that's a hydration mismatch, and React either patches the gap quietly with a console warning or, in worse cases, throws outright. Here's exactly how that sneaks into OpenRoles's job cards:

// WRONG — "now" gets computed once on the server, while the HTML is being
// built, and again a moment later in the browser during hydration; those
// two instants are never actually the same one
function PostedBadge({ postedAt }: { postedAt: string }) {
  const minutesAgo = Math.floor((Date.now() - new Date(postedAt).getTime()) / 60000);
  return <span>Posted {minutesAgo}m ago</span>;
}

Push anything time-dependent into a useEffect — which only ever fires client-side, once hydration has already settled — and the server's markup and the browser's first render finally agree, with the real, current value filling in an instant later:

"use client";
import { useState, useEffect } from "react";

function PostedBadge({ postedAt }: { postedAt: string }) {
  const [label, setLabel] = useState<string | null>(null);

  useEffect(() => {
    const minutesAgo = Math.floor((Date.now() - new Date(postedAt).getTime()) / 60000);
    setLabel(`Posted ${minutesAgo}m ago`);
  }, [postedAt]);

  return <span>{label ?? "Posted recently"}</span>; // identical on the server and the first client render
}

A second common trigger for the exact same symptom is a straight-up browser global — window, document, localStorage — read inside a component that also has to run server-side, somewhere none of those exist. That version tends to throw a hard build or render error rather than a quiet mismatch, but the remedy is the same one: move the read into useEffect, or guard it behind a check for whether window exists at all.

Reaching for a browser API where there's no browser

OpenRoles wants "Save for later" to survive across visits without forcing an account, so localStorage is the obvious first reach — and the obvious first mistake is reaching for it straight inside the card itself:

// WRONG — this is a Server Component with no "use client", and
// localStorage doesn't exist anywhere on the machine running it
export default function JobCard({ job }: { job: Job }) {
  const isSaved = localStorage.getItem(`saved-${job.id}`) !== null; // throws on the server
  return <article>{/* ... */}</article>;
}

Next.js would rather fail the build than let this slip through quietly in production. The useful takeaway isn't a list of forbidden APIs to memorize — it's why this one's forbidden here: localStorage only makes sense inside one specific visitor's browser tab, and a Server Component runs exactly once, server-side, on behalf of everyone who requests it — there's no "this particular visitor's browser" for it to reach into. SaveJobButton from earlier already got this right by living in its own small file with "use client"; the general fix is exactly that move — split the client-only piece out on its own, and leave everything else where it was.

The NEXT_PUBLIC_ prefix

# .env.local
STRIPE_SECRET_KEY=sk_live_...              # server-only — never reaches the browser
NEXT_PUBLIC_MAPBOX_TOKEN=pk.eyJ1Ijoi...    # bundled into client code, visible to anyone who looks
"use client";
export default function OfficeMap() {
  console.log(process.env.STRIPE_SECRET_KEY);       // undefined in the browser — quietly
  console.log(process.env.NEXT_PUBLIC_MAPBOX_TOKEN); // the real token, available as intended
}

Only a variable whose name starts with NEXT_PUBLIC_ gets baked into the JavaScript that ships to browsers. Everything else stays readable anywhere your code executes on the server — inside a Server Component's render, inside a Route Handler, inside one of the Server Actions from a few chapters back — and simply comes back undefined the moment something running in the browser tries to read it, with no error, no console warning, just a value that's gone missing in a way that's easy to blame on the wrong thing.

Nobody forgot to smooth this over — it's built this way on purpose. STRIPE_SECRET_KEY should never once be visible in a browser's dev tools, full stop, and tying exposure to one specific eleven-character prefix means someone has to type it deliberately for a secret to escape into the bundle; it can't happen just because a variable happens to exist somewhere in .env.local. Treat every unprefixed variable as something you're promising will stay off the client permanently — and when something reads back undefined in browser code where it obviously shouldn't, a missing prefix is the first thing worth ruling out, before you go looking anywhere stranger.

The same gap, in an app that shares nothing with OpenRoles

A risk worth naming about building a whole reference around one running example: it's possible to come away having learned "the job board's version of this bug" rather than the actual underlying mechanism. So here's that same hydration gap, once more, in a completely different app — a recipe site showing how long ago something was added to a reader's saved collection:

function SavedLabel({ savedAt }: { savedAt: string }) {
  const daysAgo = Math.floor((Date.now() - new Date(savedAt).getTime()) / 86400000);
  return <p>Saved {daysAgo} days ago</p>; // the exact same bug, wearing different clothes
}

No jobs, no applicants, no dashboard in sight — and it's the identical failure, for the identical reason: Date.now() evaluated once on a server and once again a moment later inside a browser can never be trusted to land on the same value twice. If that read as obviously familiar before you'd finished the code block, the underlying idea transferred cleanly, which is the entire reason to build one real thing instead of a dozen disposable ones.


Server Components, the fetch-caching model, and Server Actions are what make the App Router feel like a genuinely different framework rather than "React with a router taped on." None of it replaces the component model underneath — React Fundamentals is the place for that half of the picture. The best way to make any of this stick is to build a version of OpenRoles yourself: one route, one layout, one Server Action that mutates something and revalidates it back — the code lab is a fine place to start typing.