Server-Side Rendering with Tailwind CSS: SSR, SSG, ISR
Modern web development demands fast, SEO-friendly, and visually polished applications. Combining server-side rendering strategies with Tailwind CSS gives you the best of both worlds: performant HTML delivery and a utility-first styling workflow that scales. This tutorial walks you through what SSR, SSG, and ISR mean, why they matter when paired with Tailwind, and how to implement them in a Next.js project.
What Is Server-Side Rendering?
Server-Side Rendering (SSR) refers to generating HTML on the server for each incoming request. When a user visits a page, the server fetches data, renders the React (or other framework) components into HTML, and sends a fully formed document to the browser. This contrasts with Client-Side Rendering (CSR), where the browser downloads a minimal HTML shell and JavaScript then builds the UI.
Next.js supports three primary pre-rendering strategies that work seamlessly with Tailwind CSS:
- SSR (Server-Side Rendering): HTML is generated on every request. Ideal for highly dynamic, personalized content.
- SSG (Static Site Generation): HTML is generated once at build time. Perfect for blogs, marketing pages, and documentation.
- ISR (Incremental Static Regeneration): Static pages are regenerated in the background at a configurable interval. A hybrid approach combining SSG speed with SSR freshness.
Why It Matters
Pairing these rendering strategies with Tailwind CSS produces several concrete benefits. First, because Tailwind generates only the CSS classes you actually use, the stylesheet stays tiny and can be inlined or preloaded easily. Second, server-rendered HTML means search engines and social crawlers receive fully styled content immediately, improving SEO and link previews. Third, users see meaningful, styled content on the first paint without waiting for JavaScript to hydrate and apply styles.
Without SSR, a CSR app sends an empty <div id="root"></div> and unstyled flash of content. With SSR plus Tailwind, the first byte already contains semantic HTML with all utility classes attached.
Project Setup
Let's start by creating a Next.js project with Tailwind CSS configured. Run the following commands:
npx create-next-app@latest my-tailwind-ssr-app
cd my-tailwind-ssr-app
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Configure your tailwind.config.js to scan the relevant files:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./app/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
};
Add the Tailwind directives to your global stylesheet, typically app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
With the foundation in place, let's explore each rendering strategy.
Implementing SSG with Tailwind CSS
Static Site Generation is the default in Next.js when you don't use dynamic functions. Pages are pre-rendered at build time, producing static HTML files that can be served from a CDN. This is the fastest option for content that rarely changes.
Here is an example of a statically generated blog index page using the App Router:
// app/blog/page.tsx
import Link from "next/link";
type Post = {
id: number;
title: string;
excerpt: string;
};
async function getPosts(): Promise<Post[]> {
const res = await fetch("https://api.example.com/posts", {
cache: "force-cache",
});
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<main className="max-w-4xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
Blog Posts
</h1>
<ul className="space-y-6">
{posts.map((post) => (
<li
key={post.id}
className="p-6 rounded-xl border border-gray-200 hover:shadow-lg transition-shadow bg-white"
>
<Link href={`/blog/${post.id}`}>
<h2 className="text-2xl font-semibold text-blue-600 hover:text-blue-800">
{post.title}
</h2>
</Link>
<p className="mt-2 text-gray-600 leading-relaxed">
{post.excerpt}
</p>
</li>
))}
</ul>
</main>
);
}
Because this page uses fetch with cache: "force-cache" and no dynamic APIs, Next.js treats it as a static page. The HTML, complete with all Tailwind classes, is generated once during next build and reused for every visitor.
For dynamic routes with SSG, use generateStaticParams:
// app/blog/[id]/page.tsx
type Post = { id: number; title: string; body: string };
async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${id}`, {
cache: "force-cache",
});
return res.json();
}
export async function generateStaticParams() {
const posts = await fetch("https://api.example.com/posts").then((r) => r.json());
return posts.map((post: Post) => ({ id: String(post.id) }));
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return (
<article className="max-w-3xl mx-auto px-6 py-12 prose prose-lg">
<h1 className="text-4xl font-bold text-gray-900 mb-6">
{post.title}
</h1>
<div className="text-gray-700 leading-relaxed">
{post.body}
</div>
</article>
);
}
Implementing SSR with Tailwind CSS
Server-Side Rendering generates the page on every request. This is essential when content is highly dynamic, personalized per user, or depends on request-time data like cookies or query parameters.
In the App Router, you opt into SSR by using dynamic functions such as cookies(), headers(), or searchParams, or by setting cache: "no-store" on your fetch calls:
// app/dashboard/page.tsx
import { cookies } from "next/headers";
type User = {
name: string;
email: string;
plan: string;
lastLogin: string;
};
async function getUser(token: string | undefined): Promise<User | null> {
if (!token) return null;
const res = await fetch("https://api.example.com/me", {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
});
if (!res.ok) return null;
return res.json();
}
export default async function DashboardPage() {
const cookieStore = cookies();
const token = cookieStore.get("session")?.value;
const user = await getUser(token);
if (!user) {
return (
<main className="max-w-md mx-auto px-6 py-20 text-center">
<h1 className="text-3xl font-bold text-gray-900 mb-4">
Please log in
</h1>
<p className="text-gray-600">
You need to be authenticated to view this page.
</p>
</main>
);
}
return (
<main className="max-w-4xl mx-auto px-6 py-12">
<div className="bg-gradient-to-r from-blue-500 to-purple-600 rounded-2xl p-8 text-white shadow-xl">
<h1 className="text-3xl font-bold mb-2">
Welcome back, {user.name}
</h1>
<p className="text-blue-100">{user.email}</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mt-8">
<div className="p-6 rounded-xl border border-gray-200 bg-white">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Current Plan
</h2>
<p className="text-2xl font-bold text-blue-600">
{user.plan}
</p>
</div>
<div className="p-6 rounded-xl border border-gray-200 bg-white">
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Last Login
</h2>
<p className="text-gray-600">
{new Date(user.lastLogin).toLocaleString()}
</p>
</div>
</div>
</main>
);
}
Because this page reads cookies and uses cache: "no-store", Next.js renders it fresh on every request. The Tailwind classes are applied server-side, so the user receives a fully styled dashboard immediately, even before hydration.
Implementing ISR with Tailwind CSS
Incremental Static Regeneration gives you the performance of static pages with the freshness of server rendering. The page is generated statically, but Next.js regenerates it in the background when a request comes in after a specified time interval has passed. The user always receives the cached static version instantly, while the next visitor gets the updated content.
To enable ISR, pass a revalidate value (in seconds) to your fetch call or export a revalidate constant from the page:
// app/products/page.tsx
type Product = {
id: number;
name: string;
price: number;
image: string;
inStock: boolean;
};
async function getProducts(): Promise<Product[]> {
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
return res.json();
}
export const revalidate = 60;
export default async function ProductsPage() {
const products = await getProducts();
return (
<main className="max-w-6xl mx-auto px-6 py-12">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
Our Products
</h1>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<div
key={product.id}
className="rounded-2xl overflow-hidden border border-gray-200 bg-white hover:shadow-xl transition-shadow"
>
<div className="aspect-w-16 aspect-h-9 bg-gray-100">
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover"
/>
</div>
<div className="p-6">
<h2 className="text-xl font-semibold text-gray-900">
{product.name}
</h2>
<div className="flex items-center justify-between mt-4">
<span className="text-2xl font-bold text-gray-900">
${product.price.toFixed(2)}
</span>
{product.inStock ? (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
In Stock
</span>
) : (
<span className="px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800">
Sold Out
</span>
)}
</div>
</div>
</div>
))}
</div>
</main>
);
}
With revalidate: 60, the page is statically generated at build time. When a request arrives more than 60 seconds after the last generation, Next.js serves the stale cached page immediately and triggers a background regeneration. The next request receives the freshly generated page. This means users never wait for the server to render, yet content stays reasonably fresh.
You can also trigger on-demand revalidation using the revalidation API. Create a route handler:
// app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-revalidate-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
revalidatePath("/products");
return NextResponse.json({ revalidated: true, now: Date.now() });
}
Now your CMS or admin panel can call this endpoint whenever a product is updated, instantly refreshing the static page without waiting for the interval.
Handling Tailwind CSS in Production Builds
One of Tailwind's strengths in an SSR context is its build-time purging. The compiler scans your source files and only includes the utility classes that appear in your code. This means your final CSS file is small regardless of how many Tailwind utilities exist in the framework.
However, there are a few gotchas to watch for when using dynamic class names. Tailwind's scanner looks for complete, static strings. It cannot evaluate JavaScript expressions. For example, this will not work:
// BAD: Tailwind cannot detect these classes at build time
<div className={`bg-${status}-500 text-white`}>
{status}
</div>
Instead, map status values to complete class strings:
// GOOD: Full class names are visible to the scanner
const statusClasses: Record<string, string> = {
active: "bg-green-500 text-white",
pending: "bg-yellow-500 text-white",
error: "bg-red-500 text-white",
};
export function StatusBadge({ status }: { status: string }) {
return (
<span className={`px-3 py-1 rounded-full text-sm font-medium ${statusClasses[status]}`}>
{status}
</span>
);
}
By keeping full class names in your source, the Tailwind compiler includes them in the final CSS, and they are available in both server-rendered HTML and client-hydrated components.
Best Practices
- Choose the right strategy per route. Use SSG for content that changes infrequently, SSR for personalized or request-dependent data, and ISR for pages that need periodic updates without per-request rendering cost.
- Avoid dynamic class construction. Always use complete, static class name strings so Tailwind's JIT compiler can detect and include them.
- Minimize client-side JavaScript. Keep components as Server Components by default. Only add
"use client"when you need interactivity, event handlers, or browser APIs. - Use Suspense for streaming. Wrap slow data-fetching components in
<Suspense>boundaries to stream HTML as it becomes available, improving perceived performance. - Cache aggressively where possible. Use
fetchcaching options andrevalidateto reduce server load. Reserveno-storefor truly dynamic content. - Inline critical CSS. Next.js automatically handles CSS optimization, but you can further reduce render-blocking by keeping your Tailwind output lean through proper content path configuration.
- Test with production builds. Always run
next buildto verify that pages are using the intended rendering strategy. Next.js prints a route table showing which pages are static, server-rendered, or ISR-enabled. - Handle loading and error states. Server components can throw during render. Provide
loading.tsxanderror.tsxfiles to give users styled feedback while data fetches or when something goes wrong.
Adding Loading and Error UI
Here is a styled loading state that displays while your server component fetches data:
// app/products/loading.tsx
export default function Loading() {
return (
<main className="max-w-6xl mx-auto px-6 py-12">
<div className="h-10 w-64 bg-gray-200 rounded animate-pulse mb-8" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-2xl overflow-hidden border border-gray-200">
<div className="h-48 bg-gray-200 animate-pulse" />
<div className="p-6 space-y-3">
<div className="h-5 w-3/4 bg-gray-200 rounded animate-pulse" />
<div className="h-5 w-1/4 bg-gray-200 rounded animate-pulse" />
</div>
</div>
))}
</div>
</main>
);
}
And an error boundary with Tailwind styling:
// app/products/error.tsx
"use client";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<main className="max-w-md mx-auto px-6 py-20 text-center">
<div className="rounded-2xl border border-red-200 bg-red-50 p-8">
<h2 className="text-2xl font-bold text-red-900 mb-2">
Something went wrong
</h2>
<p className="text-red-700 mb-6">
{error.message || "Failed to load products."}
</p>
<button
onClick={reset}
className="px-6 py-2 rounded-lg bg-red-600 text-white font-medium hover:bg-red-700 transition-colors"
>
Try again
</button>
</div>
</main>
);
}
Conclusion
Server-Side Rendering with Tailwind CSS is a powerful combination that delivers fast, SEO-friendly, and visually consistent web applications. By understanding when to use SSG for static content, SSR for personalized data, and ISR for periodically refreshed pages, you can optimize both performance and freshness across your application. Tailwind's utility-first approach fits naturally into server-rendered components because classes are applied as static strings in your JSX, producing fully styled HTML on the first byte. Follow the best practices of avoiding dynamic class construction, caching aggressively, minimizing client JavaScript, and providing styled loading and error states, and you will build applications that are delightful for users and maintainable for developers. Start with SSG as your default, reach for ISR when content updates on a schedule, and reserve SSR for routes that truly depend on request-time data.