Making my Next.js 16 portfolio fast: 764 ms to 17 ms TTFB
My portfolio is a Next.js 16 App Router site with React 19, Tailwind v4 and a MySQL database on TiDB Cloud. It worked, but it felt heavy: the home page took a moment to appear, the navigation dock lagged, and project pages downloaded far more than they showed. So I measured it, fixed what the numbers pointed at, and measured again.
Every number here comes from a local production build (next start) with a cold cache.
Results
| Metric | Before | After |
|---|---|---|
| Home page time to first byte | ~764 ms | ~17 ms |
| First contentful paint | ~964 ms | ~300 ms |
| Total home page download (full scroll) | ~3.3 MB | ~0.5 MB |
| Images on the home page | ~2.6 MB | ~122 KB |
| Fonts preloaded | 14 files, 179 KB | 2 files, 50 KB |
| zod shipped on every page | ~58 KB gzipped | none |
| Mermaid on every project page | ~436 KB gzipped | none |
Most of the cost came from work nobody needed: database queries on every visit, full-size images, unused fonts, and a library pulled in by one import.
Measure the production build, not the dev server
My first measurements were against next dev, and they were misleading. The dev server compiles pages on demand, so the same navigation was roughly 10× slower than in production. It also hides what only exists in a real build, like which libraries land in which JavaScript chunk. Run next build and next start before you trust a number.
1. The home page queried the database on every visit
Time to first byte was ~764 ms because the home page was force-dynamic. Every request rendered it from scratch, running about nine database queries at roughly 130 ms per round trip.
That content changes when I edit it in the admin, not per visitor, so the page is now rendered statically and refreshed in the background:
// app/page.tsx export const revalidate = 600; // re-render at most every 10 minutes
So that edits still appear immediately, every admin write calls revalidatePath for each page that shows the changed data. Updating a project, for example:
revalidatePath("/"); revalidatePath("/projects"); revalidatePath(`/project/${slug}`);
The ten-minute interval is now only a safety net. I keep the shared revalidation helper out of the "use server" actions file on purpose: everything that file exports becomes a server action that anyone can call with a POST request.
The subtle part: don't cache an outage
Once the page was cached, an old habit became a bug. My data getters caught database errors and returned an empty array. On a page rendered per request that is harmless, because the next request tries again. On a cached page, the empty result gets cached: a database blip during revalidation would leave the home page without projects until the next one.
The getters used by cached pages now rethrow:
export async function getProjects(): Promise<Project[]> { try { const rows = await query("SELECT * FROM `Project` ORDER BY `created_at` DESC"); return rows.map(normalizeProject); } catch (err) { console.error("Error fetching projects:", err); throw err; // not `return []` } }
When a revalidation throws, Next.js keeps serving the last good page, which is exactly what you want during an outage.
2. Images: 2.6 MB down to 122 KB
Project thumbnails were plain <img> tags pointing at full-size PNGs, some as large as 782 KB. React preloaded them eagerly, so they downloaded whether or not you scrolled to them, and a prefetch of another page pulled in three more.
Switching to next/image fixed most of it. The optimizer serves resized copies, sizes tells the browser how wide the image actually renders so it picks the smallest file that covers it, and offscreen images load lazily:
<Image src={project.thumbnail} alt={project.title} fill sizes="(min-width: 1024px) 640px, 100vw" className="object-contain" />
Each card also has a blurred copy of its thumbnail as a backdrop. Under a heavy blur nobody can tell a 64 px image from the original, so that copy uses sizes="64px".
3. Fonts: 14 files down to 2
A shared fonts.ts module had collected next/font declarations over time. Only two were still used, but every declaration was preloaded on every page: 14 files and 179 KB competing with the content for bandwidth.
I deleted the unused ones and let the variable fonts be variable. JetBrains Mono is a variable font, so leaving weight unset loads one file for the whole weight range instead of one per weight:
// Everything declared here is preloaded on every page: declare only what is used. export const JetBrainsMono = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains-mono", });
The home page now preloads 2 files and 50 KB.
4. JavaScript: zod on every page because of one import
Every page shipped zod plus all of its error-message locales, about 58 KB gzipped, though nothing validated on load. The cause was a client component importing one tiny helper from a module that also built zod schemas. Bundlers drop unused exports, but they keep top-level code they can't prove is side-effect free, so the helper brought the schemas, and zod, along with it.
Moving the helper into a zod-free module removed zod from those pages. The contact form also validated in the browser through a zod resolver, so I rewrote its checks as react-hook-form rules, reusing zod's own email regex so both sides agree:
const RULES = { name: { required: "Name is required" }, email: { required: "Invalid email address", pattern: { value: EMAIL_PATTERN, message: "Invalid email address" }, }, };
The API route still validates every submission with the zod schema, so the server remains the source of truth.
5. Rendering: animate only transform and opacity
Some of the slowness had nothing to do with the network. About 25 elements animated blur filters, the custom cursor moved by animating left and top, and full-screen blend modes sat over an animated background. Changing left or top triggers layout, an animated blur forces repaints, and a full-screen blend mode must be recomposited whenever anything beneath it moves.
I reworked all of it to animate only transform and opacity, which browsers can usually handle on the compositor without layout or repaint.
The hero had a related problem: its entrance animation was driven by JavaScript, so the headline stayed hidden until React hydrated. It now uses a CSS animation that runs on first paint:
@keyframes rise-in { from { opacity: 0; transform: translateY(-6px); } } .rise-in { animation: rise-in 0.45s ease-out both; }
6. Navigation: prefetch, and don't wait for side effects
The dock used buttons and router.push rather than <Link>, so nothing was prefetched and every click waited for the next page's code and data. Prefetching its routes once the browser is idle brought dock navigation to about 0.1 s:
useEffect(() => { const id = window.requestIdleCallback(() => { router.prefetch("/"); router.prefetch("/blog"); }); return () => window.cancelIdleCallback(id); // setTimeout fallback omitted }, [router]);
Blog posts had a different delay. Before responding, the post page incremented its view counter with an UPDATE, then read the total back with a SELECT. Next's after() runs work once the response has finished:
const ip = clientIp(await headers()); after(() => recordView(post.slug, ip));
That cut the wait to a single query. In a Server Component headers() can't be called inside the after() callback, so the IP is read first. Post pages still render per request, so a loading.tsx skeleton that mirrors the post layout now appears within about 0.1 s of a click.
7. Diagrams: render once on save, not on every visit
Project pages show architecture diagrams written in Mermaid. Rendering them in the browser shipped a ~436 KB gzipped chunk to every project page, for diagrams that only change when I edit a project.
Now the admin renders the Mermaid source to SVG once, on save, and stores it. The public page shows the stored SVG in a small pan-and-zoom viewer, which is just a CSS transform over static markup. Visitors download no diagram library at all. If something only changes when you edit it, do the work at edit time.
Conclusion
No single change here was large. Together they took the home page from ~3.3 MB to ~0.5 MB, time to first byte from ~764 ms to ~17 ms, and first contentful paint from ~964 ms to ~300 ms. Nearly every fix came from asking one question of each request: does this work need to happen now, for this visitor? When the answer was no, it moved to edit time, to after the response, or out of the page entirely.
On your own site, start with force-dynamic pages that don't need it, images without sizes, unused fonts, and heavy libraries behind small imports, and measure with a production build.