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.
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>
);
}
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.
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>
);
}
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.
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>
);
}
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.
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>
);
}
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.
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: "/bff/:path*",
destination: `${process.env.BFF_ORIGIN}/v1/:path*`,
},
];
},
};
export default nextConfig;
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.
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";
}
}
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.