Skip to content
All articles

Software Architecture

Static First: A Fast, Cost-Efficient and Resilient Site with Next.js

While building this site I set myself one rule: nothing that doesn't need a server gets rendered on one. Simple on paper; in practice I tripped over a few things. This post also explains why my privacy page weighed 108 KB.

Fahrettin Baştürk6 min read

While building this site I set myself one rule: nothing that doesn't need a server gets rendered on one. The content already lives in the code; whenever I add a project or write a post, the site is rebuilt from scratch. So re-rendering pages for every visitor helps nobody. Pages, social images, the RSS feed: all of it is generated as files at build time and served from a CDN.

Simple on paper. In practice I tripped over a few things, and I wanted to write them down.

In the Next.js App Router, calling cookies() or headers() anywhere in the page tree quietly switches that route to dynamic rendering. No error, no warning; just a small symbol changing in the build output. In my first draft I planned to read the language preference from a cookie in the root layout. Had I done that, the whole site would have re-rendered on every request, and I probably wouldn't have noticed for months.

The fix was to move every visitor-specific decision out of the page and into middleware. Language detection and redirects happen there; the page only knows the language in the URL:

// The page only knows the language from the URL; no cookies, so it stays static.
export async function generateStaticParams() {
  return locales.map((lang) => ({ lang }));
}

export default async function Page(props: { params: Promise<{ lang: string }> }) {
  const lang = resolveLocale((await props.params).lang);
  const dict = await getDictionary(lang);
  // ...
}

A hundred kilobytes nobody sees

This is where the real surprise was. The privacy policy, a single page of text, weighed 108 KB gzipped. When I opened the source, the long descriptions of all 68 projects were sitting inside it.

Here is why: every prop you pass from a Server Component to a Client Component is embedded in the HTML as data, so the browser can bring that component to life. The navigation was a client component on every page, and for convenience I had handed it the entire dictionary. Nothing changed on screen, but every page was carrying almost all of the site's text.

After giving client components only the fields they actually use, the privacy page dropped to 12 KB, the contact page from 110 KB to 15 KB, and the home page from 136 KB to 41 KB. The lesson is simple but easy to forget: give a client component the part it uses, not the whole object.

Animation shouldn't hide content

A pattern I see a lot hides content with opacity: 0 until JavaScript loads, then fades it in. It looks nice, but if JavaScript is slow or never runs, the page stays empty; search engines and LCP don't like that either. Scroll-triggered effects can now be done in pure CSS. Browsers without support simply show the content without animation:

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .reveal {
      animation: reveal linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 55%;
    }
  }
}

No tests? Let the build be the test

This project has no test suite, and honestly, on a content-heavy site I haven't missed it much. Instead I use the build as a checkpoint. Do both languages have the same projects and posts? Do the service plans match one to one? Are there collisions in the Turkish URL table? If anything is inconsistent, the deploy stops and I see the error in my own terminal, not in production.

I see static generation less as an optimization trick and more as a discipline: everything personal goes to the edge, everything shared goes to the build. The rest is catching the small habits that quietly break that balance.

Fahrettin Baştürk

Systems Architect & Software Consultant

Share
  • Next.js
  • React
  • Performance
  • SEO