← Back to DevBytes

Server-Side Rendering with Shadcn UI: SSR, SSG, ISR

Server-Side Rendering with Shadcn UI: SSR, SSG, ISR

Shadcn UI has rapidly become one of the most popular component libraries in the React ecosystem. Unlike traditional libraries that ship pre-built components as npm packages, Shadcn UI gives you the actual source code of each component, letting you copy, paste, and customize them directly in your project. This approach pairs beautifully with Next.js rendering strategies — Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR). In this tutorial, we'll explore how to combine Shadcn UI with each of these rendering strategies to build fast, SEO-friendly, and maintainable applications.

What Is Shadcn UI?

Shadcn UI is a collection of reusable, accessible components built on top of Radix UI primitives and styled with Tailwind CSS. Instead of installing a package, you add components to your codebase using a CLI. This means you own the code, can modify it freely, and avoid the bloat of unused components. Because the components are built with Radix UI — which is fully compatible with React Server Components — Shadcn UI works seamlessly in server-rendered environments.

Why Rendering Strategy Matters

Choosing the right rendering strategy affects performance, SEO, and user experience. Here's a quick breakdown:

Shadcn UI components are compatible with all three strategies, but there are nuances to be aware of — particularly around client-side interactivity, hydration, and the use of the "use client" directive.

Setting Up the Project

Let's start by creating a new Next.js project and initializing Shadcn UI. We'll use the Next.js App Router, which is the recommended approach for modern Next.js applications.

npx create-next-app@latest my-shadcn-app
cd my-shadcn-app
npx shadcn@latest init

During initialization, you'll be prompted to choose a style, base color, and CSS variables. For this tutorial, we'll use the default options. Once initialized, let's add a few components we'll use throughout the examples:

npx shadcn@latest add button card input label badge alert-dialog table

This will create component files in components/ui/. Now let's explore each rendering strategy with practical examples.

Static Site Generation (SSG) with Shadcn UI

SSG is the default rendering strategy in the Next.js App Router. Any page that doesn't use dynamic functions like cookies(), headers(), or searchParams is statically rendered at build time. This makes it perfect for landing pages, documentation, and blog posts.

Let's create a static pricing page using Shadcn UI's Card and Button components:

// app/pricing/page.tsx
import { Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"

const plans = [
  {
    name: "Starter",
    price: "$0",
    description: "Perfect for trying out the platform",
    features: ["Up to 3 projects", "Community support", "1GB storage"],
    popular: false,
  },
  {
    name: "Pro",
    price: "$29",
    description: "For growing teams that need more power",
    features: ["Unlimited projects", "Priority support", "50GB storage", "Advanced analytics"],
    popular: true,
  },
  {
    name: "Enterprise",
    price: "Custom",
    description: "For large organizations with custom needs",
    features: ["Everything in Pro", "Dedicated support", "Unlimited storage", "SLA guarantee"],
    popular: false,
  },
]

export default function PricingPage() {
  return (
    <div className="container mx-auto py-16">
      <div className="text-center mb-12">
        <h1 className="text-4xl font-bold tracking-tight">Simple, transparent pricing</h1>
        <p className="mt-4 text-lg text-muted-foreground">
          Choose the plan that works best for your team.
        </p>
      </div>

      <div className="grid md:grid-cols-3 gap-6 max-w-5xl mx-auto">
        {plans.map((plan) => (
          <Card key={plan.name} className={plan.popular ? "border-primary shadow-lg" : ""}>
            <CardHeader>
              <div className="flex items-center justify-between">
                <CardTitle>{plan.name}</CardTitle>
                {plan.popular && <Badge>Most Popular</Badge>}
              </div>
              <CardDescription>{plan.description}</CardDescription>
            </CardHeader>
            <CardContent>
              <p className="text-3xl font-bold">{plan.price}<span className="text-sm font-normal text-muted-foreground">/mo</span></p>
              <ul className="mt-4 space-y-2">
                {plan.features.map((feature) => (
                  <li key={feature} className="flex items-center gap-2 text-sm">
                    <Check className="h-4 w-4 text-primary" />
                    {feature}
                  </li>
                ))}
              </ul>
            </CardContent>
            <CardFooter>
              <Button className="w-full" variant={plan.popular ? "default" : "outline"}>
                Get started
              </Button>
            </CardFooter>
          <Card>
        ))}
      </div>
    </div>
  )
}

Notice that we didn't need to add "use client" at the top of this file. The Card, Badge, and Button components from Shadcn UI are server-compatible. The Button component does include "use client" in its own file because it uses Radix UI's slot pattern, but that's handled internally — you can still import and render it from a Server Component.

Since this page has no dynamic data fetching, Next.js will statically generate it at build time. You can verify this by running next build and checking the output — you should see the route marked with the symbol, indicating static rendering.

Server-Side Rendering (SSR) with Shadcn UI

SSR renders the page on the server for each incoming request. This is useful when the content depends on request-specific data, such as user authentication, cookies, or real-time data. In the App Router, SSR happens automatically when you use dynamic functions or set dynamic = "force-dynamic".

Let's build a dashboard page that displays user-specific data fetched on each request:

// app/dashboard/page.tsx
import { cookies, headers } from "next/headers"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

// Force server-side rendering on every request
export const dynamic = "force-dynamic"

interface Order {
  id: string
  product: string
  amount: number
  status: "completed" | "pending" | "failed"
  date: string
}

async function getOrders(token: string): Promise<Order[]> {
  const res = await fetch(`${process.env.API_URL}/orders`, {
    headers: { Authorization: `Bearer ${token}` },
    cache: "no-store",
  })
  if (!res.ok) throw new Error("Failed to fetch orders")
  return res.json()
}

export default async function DashboardPage() {
  const cookieStore = cookies()
  const token = cookieStore.get("auth-token")?.value ?? ""

  const orders = await getOrders(token)
  const totalRevenue = orders
    .filter((o) => o.status === "completed")
    .reduce((sum, o) => sum + o.amount, 0)

  const statusVariant = (status: Order["status"]) => {
    switch (status) {
      case "completed": return "default" as const
      case "pending": return "secondary" as const
      case "failed": return "destructive" as const
    }
  }

  return (
    <div className="container mx-auto py-10 space-y-8">
      <div>
        <h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
        <p className="text-muted-foreground">Welcome back, here's your overview.</p>
      </div>

      <div className="grid gap-4 md:grid-cols-3">
        <Card>
          <CardHeader>
            <CardDescription>Total Orders</CardDescription>
            <CardTitle className="text-3xl">{orders.length}</CardTitle>
          </CardHeader>
        </Card>
        <Card>
          <CardHeader>
            <CardDescription>Total Revenue</CardTitle>
            <CardTitle className="text-3xl">${totalRevenue.toFixed(2)}</CardTitle>
          </CardHeader>
        </Card>
        <Card>
          <CardHeader>
            <CardDescription>Pending Orders</CardTitle>
            <CardTitle className="text-3xl">
              {orders.filter((o) => o.status === "pending").length}
            </CardTitle>
          </CardHeader>
        </Card>
      </div>

      <Card>
        <CardHeader>
          <CardTitle>Recent Orders</CardTitle>
          <CardDescription>Your latest transactions</CardDescription>
        </CardHeader>
        <CardContent>
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Order ID</TableHead>
                <TableHead>Product</TableHead>
                <TableHead>Amount</TableHead>
                <TableHead>Status</TableHead>
                <TableHead>Date</TableHead>
              </TableRow>
            <TableHeader>
            <TableBody>
              {orders.map((order) => (
                <TableRow key={order.id}>
                  <TableCell className="font-mono">{order.id}</TableCell>
                  <TableCell>{order.product}</TableCell>
                  <TableCell>${order.amount.toFixed(2)}</TableCell>
                  <TableCell>
                    <Badge variant={statusVariant(order.status)}>
                      {order.status}
                    </Badge>
                  </TableCell>
                  <TableCell>{new Date(order.date).toLocaleDateString()}</TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </CardContent>
      </Card>
    </div>
  )
}

In this example, we use cookies() to read the authentication token, which automatically opts the page into SSR. The data is fetched with cache: "no-store" to ensure fresh data on every request. All the Shadcn UI components — Card, Table, Badge — render on the server and stream HTML to the client.

Incremental Static Regeneration (ISR) with Shadcn UI

ISR gives you the best of both worlds: the performance of static generation with the freshness of server rendering. Pages are generated statically but revalidated at a specified interval. This is perfect for content that updates periodically, such as product catalogs, news articles, or documentation.

Let's create a product listing page that uses ISR with a revalidation period of 60 seconds:

// app/products/page.tsx
import Link from "next/link"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"

// Revalidate every 60 seconds
export const revalidate = 60

interface Product {
  id: number
  name: string
  price: number
  category: string
  inStock: boolean
  rating: number
}

async function getProducts(): Promise<Product[]> {
  const res = await fetch(`${process.env.API_URL}/products`, {
    next: { revalidate: 60 },
  })
  if (!res.ok) throw new Error("Failed to fetch products")
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div className="container mx-auto py-10">
      <div className="flex items-center justify-between mb-8">
        <div>
          <h1 className="text-3xl font-bold tracking-tight">Products</h1>
          <p className="text-muted-foreground">
            Browse our catalog of {products.length} products
          </p>
        </div>
      </div>

      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
        {products.map((product) => (
          <Card key={product.id} className="flex flex-col">
            <CardHeader>
              <div className="flex items-start justify-between">
                <CardTitle className="text-lg">{product.name}</CardTitle>
                <Badge variant={product.inStock ? "default" : "destructive"}>
                  {product.inStock ? "In Stock" : "Out of Stock"}
                </Badge>
              </div>
            </CardHeader>
            <CardContent className="flex-1">
              <p className="text-2xl font-bold">${product.price.toFixed(2)}</p>
              <p className="text-sm text-muted-foreground mt-1">
                {product.category}
              </p>
              <p className="text-sm mt-2">Rating: {product.rating} / 5</p>
            </CardContent>
            <CardFooter>
              <Button asChild className="w-full" variant="outline">
                <Link href={`/products/${product.id}`}>View Details</Link>
              </Button>
            </CardFooter>
          </Card>
        ))}
      </div>
    </div>
  )
}

You can also use on-demand revalidation with ISR, which allows you to trigger regeneration manually via an API route. This is useful when you want to update content immediately after a change in your CMS:

// 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 })
  }

  const body = await request.json()
  const path = body.path ?? "/products"

  revalidatePath(path)

  return NextResponse.json({ revalidated: true, path })
}

Now, whenever a product is updated in your CMS, you can send a POST request to /api/revalidate with the path to regenerate, and the static page will be updated in the background.

Handling Client-Side Interactivity

While Shadcn UI components render well on the server, some interactions require client-side JavaScript. For example, dialogs, dropdowns, and form inputs with complex state need the "use client" directive. The key principle is to keep the client boundary as small as possible.

Here's an example of a product page that combines server-rendered content with a client-side "Add to Cart" dialog:

// app/products/[id]/page.tsx
import { notFound } from "next/navigation"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { AddToCartDialog } from "./add-to-cart-dialog"

export const revalidate = 60

interface Product {
  id: number
  name: string
  price: number
  description: string
  inStock: boolean
}

async function getProduct(id: string): Promise<Product | null> {
  const res = await fetch(`${process.env.API_URL}/products/${id}`, {
    next: { revalidate: 60 },
  })
  if (!res.ok) return null
  return res.json()
}

export default async function ProductPage({ params }: { params: { id: string } }) {
  const product = await getProduct(params.id)
  if (!product) notFound()

  return (
    <div className="container mx-auto py-10 max-w-4xl">
      <div className="grid md:grid-cols-2 gap-8">
        <div className="aspect-square bg-muted rounded-lg" />
        <div className="space-y-6">
          <div>
            <h1 className="text-3xl font-bold">{product.name}</h1>
            <Badge variant={product.inStock ? "default" : "destructive"} className="mt-2">
              {product.inStock ? "In Stock" : "Out of Stock"}
            </Badge>
          </div>
          <p className="text-3xl font-bold">${product.price.toFixed(2)}</p>
          <Card>
            <CardContent className="pt-6">
              <p className="text-muted-foreground">{product.description}</p>
            </CardContent>
          </Card>
          {/* Client component for interactivity */}
          <AddToCartDialog product={product} />
        </div>
      </div>
    </div>
  )
}
// app/products/[id]/add-to-cart-dialog.tsx
"use client"

import { useState } from "react"
import { Button } from "@/components/ui/button"
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"

interface Product {
  id: number
  name: string
  price: number
  inStock: boolean
}

export function AddToCartDialog({ product }: { product: Product }) {
  const [quantity, setQuantity] = useState(1)
  const [isOpen, setIsOpen] = useState(false)

  const handleAddToCart = () => {
    // Add to cart logic here
    console.log(`Added ${quantity} of ${product.name} to cart`)
    setIsOpen(false)
  }

  return (
    <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
      <AlertDialogTrigger asChild>
        <Button className="w-full" disabled={!product.inStock}>
          Add to Cart
        </Button>
      </AlertDialogTrigger>
      <AlertDialogContent>
        <AlertDialogHeader>
          <AlertDialogTitle>Add to Cart</AlertDialogTitle>
          <AlertDialogDescription>
            How many units of {product.name} would you like to add?
          </AlertDialogDescription>
        </AlertDialogHeader>
        <div className="py-4 space-y-2">
          <Label htmlFor="quantity">Quantity</Label>
          <Input
            id="quantity"
            type="number"
            min={1}
            max={99}
            value={quantity}
            onChange={(e) => setQuantity(Number(e.target.value))}
          />
          <p className="text-sm text-muted-foreground">
            Total: ${(product.price * quantity).toFixed(2)}
          </p>
        </div>
        <AlertDialogFooter>
          <AlertDialogCancel>Cancel</AlertDialogCancel>
          <AlertDialogAction onClick={handleAddToCart}>
            Add to Cart
          </AlertDialogAction>
        </AlertDialogFooter>
      </AlertDialogContent>
    </AlertDialog>
  )
}

Notice how the server component fetches data and renders the product details, while only the interactive dialog is marked as a client component. This keeps the JavaScript bundle small and the initial page load fast.

Best Practices

1. Keep Client Components Small and Focused

Push the "use client" boundary as far down the component tree as possible. Server Components can import and render Client Components, but Client Components cannot import Server Components (they can only accept them as children props). By keeping client components small, you reduce the amount of JavaScript sent to the browser.

2. Pass Serializable Props Only

When passing data from Server Components to Client Components, ensure all props are serializable. This means no functions, no class instances, and no Dates (convert them to ISO strings first). If you need to pass a function, consider using a server action instead.

3. Use the Right Rendering Strategy for Each Route

Don't default to SSR for everything. Analyze your content:

4. Leverage Server Actions for Mutations

Instead of creating API routes for form submissions or data mutations, use Server Actions. They work seamlessly with Shadcn UI form components and eliminate the need for client-side fetch calls:

// app/contact/page.tsx
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { submitContactForm } from "./actions"

export default function ContactPage() {
  return (
    <div className="container mx-auto py-10 max-w-md">
      <h1 className="text-2xl font-bold mb-6">Contact Us</h1>
      <form action={submitContactForm} className="space-y-4">
        <div className="space-y-2">
          <Label htmlFor="name">Name</Label>
          <Input id="name" name="name" required />
        </div>
        <div className="space-y-2">
          <Label htmlFor="email">Email</Label>
          <Input id="email" name="email" type="email" required />
        </div>
        <div className="space-y-2">
          <Label htmlFor="message">Message</Label>
          <Textarea id="message" name="message" required />
        </div>
        <Button type="submit" className="w-full">Send Message</Button>
      </form>
    </div>
  )
}
// app/contact/actions.ts
"use server"

import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"

export async function submitContactForm(formData: FormData) {
  const name = formData.get("name") as string
  const email = formData.get("email") as string
  const message = formData.get("message") as string

  // Save to database or send email
  await fetch(`${process.env.API_URL}/contact`, {
    method: "POST",
    body: JSON.stringify({ name, email, message }),
    headers: { "Content-Type": "application/json" },
  })

  redirect("/contact/success")
}

5. Handle Loading and Error States Gracefully

Use Next.js loading.tsx and error.tsx files to provide good UX during data fetching. Shadcn UI's Skeleton component is perfect for loading states:

// app/products/loading.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Skeleton } from "@/components/ui/skeleton"

export default function Loading() {
  return (
    <div className="container mx-auto py-10">
      <Skeleton className="h-10 w-48 mb-8" />
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
        {Array.from({ length: 8 }).map((_, i) => (
          <Card key={i}>
            <CardHeader>
              <Skeleton className="h-6 w-3/4" />
              <Skeleton className="h-4 w-1/2" />
            </CardHeader>
            <CardContent>
              <Skeleton className="h-8 w-20 mb-2" />
              <Skeleton className="h-4 w-full" />
            </CardContent>
          </Card>
        ))}
      </div>
    </div>
  )
}
// app/products/error.tsx
"use client"

import { Button } from "@/components/ui/button"
import { AlertCircle } from "lucide-react"

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="container mx-auto py-20 text-center">
      <AlertCircle className="h-12 w-12 mx-auto text-destructive mb-4" />
      <h2 className="text-2xl font-bold mb-2">Something went wrong</h2>
      <p className="text-muted-foreground mb-6">{error.message}</p>
      <Button onClick={reset}>Try again</Button>
    </div>
  )
}

6. Be Mindful of Hydration Mismatches

When using SSR or ISR, ensure that the server-rendered HTML matches what the client expects. Avoid using Date.now(), Math.random(), or new Date() directly in render output, as these will produce different values on the server and client. Instead, format dates in your data fetching layer or use a stable representation like ISO strings.

Conclusion

Shadcn UI and Next.js's rendering strategies are a powerful combination. By understanding when to use SSG, SSR, and ISR, and by keeping the client-server boundary clean, you can build applications that are fast, SEO-friendly, and highly interactive. Shadcn UI's component ownership model means you're never fighting against the framework — you have full control over how each component renders and behaves. Start with static generation by default, opt into server-side rendering only when you need request-specific data, and use ISR for content that updates on a schedule. With these patterns in your toolkit, you'll be well-equipped to build production-grade applications that deliver excellent performance and user experience.

— Ad —

Google AdSense will appear here after approval

← Back to all articles