← Back to DevBytes

Server-Side Rendering with Tailwind CSS: SSR, SSG, ISR

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:

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles