Lesson 04 of 04
The parts that only hurt after deploy
Environment variables, error boundaries, streaming, and the handful of things that behave differently the moment real traffic arrives.
OutcomesAfter this you will be able to
- Validate environment configuration at boot instead of at 3am
- Place error and loading boundaries where they actually help
- Know what changes between `next dev` and `next build`
01Validate env at boot
A missing environment variable should stop the process immediately with a message naming the variable. The alternative is `undefined` travelling silently into a database URL and surfacing as a connection error in a request log four hours later.
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
RESEND_API_KEY: z.string().min(1),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment:', parsed.error.flatten().fieldErrors);
throw new Error('Bad environment configuration');
}
export const env = parsed.data;02Error and loading boundaries are per segment
An `error.tsx` catches errors from the segment below it, not from itself or its own layout. A single root error boundary means any failure blanks the entire page. Placing one per meaningful section keeps the navigation and shell alive while the broken part shows a retry.
- `error.tsx` must be a client component — it needs `reset`.
- It does not catch errors thrown in the layout at the same level; put one a level up for those.
- `not-found.tsx` handles `notFound()`; a thrown error is a different path entirely.
- `loading.tsx` is sugar for wrapping the segment in Suspense — use it, but know that is all it is.
03Stream the slow part, not the page
If one query on a page takes 800ms and the rest take 20ms, awaiting all of them at the top makes the whole page cost 800ms. Wrapping just the slow component in Suspense lets the rest paint immediately and the slow part arrive when it is ready.
export default function Dashboard() {
return (
<>
<Header /> {/* fast, renders now */}
<Suspense fallback={<StatsSkeleton />}>
<SlowStats /> {/* streams in when ready */}
</Suspense>
</>
);
}The trade is that streamed content arrives after the initial HTML, so it cannot contribute to the page's metadata and it will shift layout unless the fallback reserves the same space. Give skeletons real dimensions.
04Dev and production are different programs
- Dev never uses the full route cache. A page that is stale in production may look perfect locally.
- Dev renders components twice under Strict Mode. Impure render logic hides in production and breaks in dev — or the reverse.
- Production minifies error messages into digests. Wire up real error reporting or you are debugging hashes.
- Build-time prerendering runs your code with no request context. Anything reading headers or cookies opts the route into dynamic rendering, sometimes without you noticing.
Exercise
Read your own build output
Run `next build` and read the route table it prints. Every route is marked static or dynamic. Find one you expected to be static that is not, and trace the cause — it is almost always a `cookies()`, `headers()`, or uncached fetch somewhere deeper than you remember putting it.