Skip to main content
Published
Next.jsSSRArchitecture

Rendering strategies in real product apps

SSG, ISR, SSR, streaming, and client islands with examples from content platforms, compliance products, and AI SaaS. Benefits and trade-offs per route.

Rendering strategy is not a framework fashion choice. It is a product decision about who waits, what can be cached, and what must stay private.

On Next.js App Router work I pick a default per route, not per repo. The same product often mixes static marketing, server-rendered account pages, and client islands for interactive tools. Below is how that shows up on real apps I have shipped or led on.

Static generation for content that changes on a schedule

Use static generation when the page is mostly content, SEO matters, and the data can be built ahead of the request. Multi-region location and content sites are the clean example. Hundreds of location pages share UI packages but not content. Build once, serve from the edge, rebuild when the CMS publishes.

  • Benefit: fast first paint and strong Core Web Vitals on public pages.
  • Benefit: CDN caching without inventing your own HTML cache.
  • Trade-off: stale until the next build or revalidation.
app/locations/[slug]/page.tsxtsx
export const revalidate = 3600; // ISR: refresh HTML on a schedule
 
export async function generateStaticParams() {
  const locations = await cms.getLocationSlugs();
  return locations.map(slug => ({ slug }));
}
 
export default async function LocationPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const location = await cms.getLocation(slug);
 
  return (
    <main>
      <h1>{location.name}</h1>
      <LocationMap coords={location.coords} />
      {/* Client island only where interaction needs the browser */}
      <StoreFinder initialRegion={location.region} />
    </main>
  );
}
Pattern used on large content-driven location sites: static params from CMS, revalidate on publish.

Server rendering for account and compliance products

Tenlord is a compliance product. Much of the UI depends on the signed-in landlord, tenancy data, billing state, and feature flags. That is a poor fit for a fully static shell that then waterfalls client fetches.

Server Components and server-side data loading keep personal data off the public cache and give the first response real content. Client components stay reserved for forms, uploads, and dense interactive panels.

  • Benefit: HTML arrives with the user's data, not a loading skeleton as the product.
  • Benefit: secrets and role checks stay on the server.
  • Trade-off: TTFB depends on upstream APIs. Cache carefully, never cache private HTML on a shared CDN key.
app/(app)/properties/[id]/page.tsxtsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";
import { getProperty } from "@/lib/properties";
 
export default async function PropertyPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const session = await getSession();
  if (!session) redirect("/login");
 
  const property = await getProperty(id, session.userId);
  if (!property) redirect("/properties");
 
  return (
    <main>
      <h1>{property.address}</h1>
      <ComplianceStatus status={property.compliance} />
      <DocumentUpload propertyId={property.id} />
    </main>
  );
}
Authenticated product route: server load, authorize, then render. No private data in generateStaticParams.

Streaming and Suspense for slow panels

When one panel depends on a slow upstream, do not block the whole page. Stream the shell and defer the slow section. High-traffic account portals benefit here: billing summary can wait while navigation and identity render first.

  • Benefit: perceived performance without lying about readiness.
  • Benefit: failures can be isolated to one boundary.
app/(app)/dashboard/page.tsxtsx
import { Suspense } from "react";
import { BillingSummary } from "./BillingSummary";
import { BillingSkeleton } from "./BillingSkeleton";
 
export default function DashboardPage() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<BillingSkeleton />}>
        <BillingSummary />
      </Suspense>
    </main>
  );
}
Shell first, slow widget later. Users see structure instead of a blank document.

Client rendering for tool-like islands

TrueFit CV and Rhema both need browser-heavy surfaces: editors, previews, media controls, offline-tolerant UI. Those pieces should be Client Components. The route around them can still be server-rendered.

The mistake is making the entire app 'use client' because one widget needs state. Keep the island small. Let the server own the document and auth boundary.

  • Benefit: smaller JS for most of the page.
  • Benefit: clearer ownership between data loading and interaction.
app/cv/[id]/edit/page.tsxtsx
import { getCv } from "@/lib/cv";
import { CvEditor } from "@/components/CvEditor"; // 'use client'
 
export default async function EditCvPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const cv = await getCv(id);
 
  return (
    <main>
      <h1>Edit CV</h1>
      <CvEditor initialDocument={cv.document} cvId={cv.id} />
    </main>
  );
}
Server page owns data. Client editor owns interaction.

Same-origin BFF with rewrites

TrueFit CV runs Next.js as the web origin and rewrites /bff to an Express service. That is not a rendering mode by itself, but it shapes rendering. The UI can server-render pages while mutations and auth stay on the BFF behind httpOnly cookies.

The browser talks to one origin. The server components and route handlers decide what is HTML versus what is an API call.

  • Benefit: no CORS theatre for first-party product calls.
  • Benefit: rendering strategy and auth strategy can stay aligned.
next.config.mjsjs
/** @type {import('next').NextConfig} */
const nextConfig = {
  async rewrites() {
    return [
      {
        source: "/bff/:path*",
        destination: `${process.env.BFF_ORIGIN}/v1/:path*`,
      },
    ];
  },
};
 
export default nextConfig;
Same-origin BFF: UI and API share the site, different processes behind the rewrite.

CDN-aware fetching on large customer portals

On telecom self-care work at scale, rendering is only half the story. Caching headers, stale-while-revalidate, and what must bypass the cache matter as much as SSR versus CSR.

Public marketing can be aggressive. Account pages must be private. Shared fragments like plan catalogues can be cached with short TTLs. The benefit shows up in Lighthouse and in origin load during peaks.

  • Benefit: origin stays calm when traffic spikes.
  • Benefit: Core Web Vitals stay measurable in CI with Lighthouse CI.

How I choose

I ask four questions per route. Is the data public? Can it be cached? Does the first paint need real content for SEO or trust? How much of the page is truly interactive?

  • Public + stable content → static or ISR.
  • Private + personalized → server render, no shared HTML cache.
  • Slow dependency → stream with Suspense.
  • Heavy interaction → client island inside a server page.
notes/rendering-cheatsheet.tsts
type RouteKind =
  | "marketing"
  | "content"
  | "account"
  | "tool";
 
function defaultStrategy(kind: RouteKind) {
  switch (kind) {
    case "marketing":
      return "SSG";
    case "content":
      return "ISR";
    case "account":
      return "SSR + Suspense";
    case "tool":
      return "SSR shell + client island";
  }
}
A short decision helper, not a dogma.

What good looks like

Users get useful HTML early. Private data never rides a public cache key. JavaScript stays where interaction needs it. CI watches LCP and INP so a clever rendering change does not quietly hurt the product.

That is the whole game. Pick the strategy that matches the page's job, then prove it with metrics.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract