---
title: "Next.js 15 on Vercel: Performance Patterns That Matter"
description: "Performance patterns for Next.js 15 on Vercel: Server Components, static generation, image and font budgets, edge redirects and build-time computation."
date: 2025-11-09
category: Engineering
readingTime: "4 min read"
---


This site runs on Next.js 15 deployed to Vercel. The combination is powerful, but getting the best performance requires understanding the patterns that actually matter, and ignoring the ones that do not.

## React Server Components: The Default That Changes Everything

Next.js 15 defaults to Server Components. This is not just a rendering strategy. It is a fundamental shift in how you think about data and UI.

Server Components render on the server and send HTML to the client. No JavaScript bundle for the component itself, and crucially, none of the libraries it imports. A date formatter, a markdown renderer, a syntax highlighter: all of that stays on the server and never reaches the browser. No hydration cost either. For a content-heavy site like this one, that means:

- **Blog posts** render entirely on the server. The markdown parsing, syntax highlighting, and HTML generation happen once, at build time. The client receives pure HTML.
- **Navigation** is a Server Component that reads the route list from a constants file. No client-side state needed.
- **The page shell** (header, footer, layout) is all server-rendered. Zero client JavaScript for the structure.

The client components on this site are minimal: theme toggling, mobile navigation state, and Alpine.js interactions. Everything else is server-only.

## Static Generation for Content

For a personal site with blog posts, Static Site Generation (SSG) is the obvious choice. Every page is pre-rendered at build time:

```typescript
export async function generateStaticParams() {
  const posts = getAllPosts();
  return posts.map((post) => ({ slug: post.slug }));
}
```

Build time generates HTML for every blog post. Vercel caches the output at the edge. First-byte time approaches the theoretical minimum: it is just serving a cached file from the nearest CDN node.

There is no server computation on page load. No database queries. No API calls. Just cached HTML.

The trade-off is the build itself. A page rendered at build time is only as fresh as the last deploy, so static generation suits content that changes on a deploy cadence, like blog posts, and not data that changes by the minute. When you do need both, Next.js lets you opt individual routes into Incremental Static Regeneration; for a personal site, a rebuild on publish is simpler and entirely sufficient.

## Image Optimisation

Vercel's image optimisation pipeline (`next/image`) handles responsive images, format conversion (WebP/AVIF), and lazy loading. But the biggest performance win is simpler: **do not use large images**.

For this site, I follow a strict image budget:

- **Hero images:** Max 200KB, served as WebP
- **Blog post images:** Max 100KB, always below the fold with `loading="lazy"`
- **Author photos:** Max 30KB, heavily compressed

The `next/image` component handles the technical optimisation, but the discipline of keeping source images small matters more than any automatic optimisation.

## Edge Middleware for Redirects

This site has gone through several URL structure changes. Old links still exist in the wild: bookmarks, search engine indexes, other sites linking to me. Edge middleware handles redirects without hitting the origin:

```typescript
// middleware.ts
export function middleware(request: NextRequest) {
  const redirects: Record<string, string> = {
    '/old-path': '/new-path',
    '/legacy/about': '/',
  };

  const redirect = redirects[request.nextUrl.pathname];
  if (redirect) {
    return NextResponse.redirect(new URL(redirect, request.url), 301);
  }
}
```

Redirects execute at the edge, the nearest Vercel CDN node to the user. Latency is negligible because the request never reaches the origin server. The `301` status matters for SEO: a permanent redirect tells search engines to transfer ranking signals to the new URL and update their index, whereas a `302` is treated as temporary and leaves the old URL in place. For a finite, known set of moved paths, a static map like this also beats config-file redirects, which are matched in order and grow linear as the list does.

## Font Loading Strategy

Fonts are a common performance bottleneck. A badly loaded custom font can cause layout shifts and delays. My approach:

1. **`next/font`** for self-hosted fonts. No external requests to Google Fonts.
2. **`display: swap`** to prevent invisible text during font loading.
3. **Subset fonts.** I only include the character sets I actually use. For a site with English content, that means dropping CJK characters and reducing the font file from ~200KB to ~30KB.

```typescript
import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
});
```

`next/font` also computes a size-adjusted fallback font automatically, so the swap from system font to web font lands without shifting the layout. That keeps Cumulative Layout Shift near zero, which is the part of font loading that usually hurts Core Web Vitals rather than raw download time.

## CSS Performance

This site uses vanilla CSS with custom properties: no CSS-in-JS, no Tailwind, no runtime styling. The entire stylesheet is about 15KB (minified, before gzip).

CSS custom properties enable theming without JavaScript:

```css
:root {
  --color-bg-primary: #ffffff;
  --color-text-primary: #1d1d1f;
}

[data-theme="dark"] {
  --color-bg-primary: #000000;
  --color-text-primary: #f5f5f7;
}
```

Theme switching changes one `data-theme` attribute. Every component that references custom properties updates instantly. No style recalculation, no rerender, no flash.

## Build-Time Computation

Everything that can be computed at build time should be. For this blog:

- **Reading time calculation**: computed when parsing markdown, not at render time
- **Related posts**: cross-referenced by tag overlap at build time
- **Table of contents**: extracted from headings during markdown parsing
- **RSS feed**: generated as a static route
- **Sitemap**: generated from all known routes

The build runs once. Users never wait for computation.

## What I Do Not Optimise

Performance optimisation has diminishing returns. I deliberately skip:

- **Code splitting per blog post**: they are statically generated. There is no JavaScript to split.
- **Service worker caching**: Vercel's CDN handles caching better than a service worker would.
- **Preloading every link**: I use `next/link` for route prefetching, which handles this automatically on hover.

The best performance optimisation is often removing complexity, not adding it.

## The Numbers

Lighthouse scores for this site:

- **Performance:** 99-100
- **Accessibility:** 100
- **Best Practices:** 100
- **SEO:** 100

These scores are achievable because the architecture is simple. Server Components + SSG + edge caching + minimal client JavaScript. No framework fighting needed.
