Skip to main content

Command Palette

Search for a command to run...

Next.js Mastery: SSG vs SSR, Dynamic Routing, APIs, and Server Actions

Building full-stack React applications with Next.js's powerful rendering strategies and server-side capabilities

Published
16 min read

Introduction: Beyond Client-Side React

While React excels at building interactive user interfaces, it runs entirely in the browser, creating challenges for SEO, initial load performance, and server-side functionality. Next.js solves these problems by extending React with server-side rendering, static generation, file-based routing, and full-stack capabilities.

Next.js transforms React from a client-side library into a complete web application framework. It provides multiple rendering strategies, automatic code splitting, API routes for backend functionality, and modern features like Server Actions that blur the lines between frontend and backend development.

Understanding Next.js is essential for modern web development because it enables building production-ready applications with optimal performance, SEO-friendly content, and integrated backend capabilities - all while maintaining the developer experience that makes React so popular.

Chapter 1: SSG vs SSR - Choosing the Right Rendering Strategy

Understanding Rendering Strategies

Next.js offers multiple rendering strategies, each optimized for different use cases:

  • Static Site Generation (SSG): Pre-renders pages at build time

  • Server-Side Rendering (SSR): Renders pages on each request

  • Client-Side Rendering (CSR): Traditional React rendering in the browser

  • Incremental Static Regeneration (ISR): Combines SSG with on-demand updates

Static Site Generation (SSG) - Build-Time Pre-rendering

SSG generates HTML pages at build time, creating static files that can be served instantly:

// pages/blog/[slug].js - SSG Blog Post
import { getAllPosts, getPostBySlug } from '../../lib/blog'

export default function BlogPost({ post }) {
  return (
    <article className="blog-post">
      <header>
        <h1>{post.title}</h1>
        <p className="meta">
          Published on {new Date(post.date).toLocaleDateString()}
        </p>
      </header>

      <div 
        className="content"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />

      <footer>
        <div className="tags">
          {post.tags.map(tag => (
            <span key={tag} className="tag">{tag}</span>
          ))}
        </div>
      </footer>
    </article>
  )
}

// Pre-render all blog post paths at build time
export async function getStaticPaths() {
  const posts = getAllPosts(['slug'])

  return {
    paths: posts.map(post => ({
      params: { slug: post.slug }
    })),
    fallback: false // Return 404 for unknown slugs
  }
}

// Fetch post data at build time
export async function getStaticProps({ params }) {
  const post = getPostBySlug(params.slug, [
    'title',
    'date', 
    'slug',
    'content',
    'tags'
  ])

  return {
    props: {
      post
    }
  }
}

Server-Side Rendering (SSR) - Request-Time Generation

SSR generates HTML on each request, perfect for dynamic, personalized content:

// pages/dashboard.js - SSR Dashboard
import { useRouter } from 'next/router'
import { getUserData, getUserOrders } from '../lib/api'

export default function Dashboard({ user, orders }) {
  const router = useRouter()

  if (!user) {
    return (
      <div className="auth-required">
        <h1>Access Denied</h1>
        <button onClick={() => router.push('/login')}>
          Login Required
        </button>
      </div>
    )
  }

  return (
    <div className="dashboard">
      <header className="dashboard-header">
        <h1>Welcome back, {user.name}!</h1>
        <p>Last login: {new Date(user.lastLogin).toLocaleString()}</p>
      </header>

      <div className="dashboard-content">
        <section className="user-stats">
          <div className="stat-card">
            <h3>Total Orders</h3>
            <p className="stat-number">{orders.length}</p>
          </div>

          <div className="stat-card">
            <h3>Account Balance</h3>
            <p className="stat-number">${user.balance}</p>
          </div>
        </section>

        <section className="recent-orders">
          <h2>Recent Orders</h2>
          {orders.slice(0, 5).map(order => (
            <div key={order.id} className="order-item">
              <span>Order #{order.id}</span>
              <span>{order.status}</span>
              <span>${order.total}</span>
            </div>
          ))}
        </section>
      </div>
    </div>
  )
}

// Fetch fresh data on every request
export async function getServerSideProps(context) {
  const { req } = context

  // Check authentication from session/cookie
  const sessionToken = req.cookies.sessionToken

  if (!sessionToken) {
    return {
      props: {
        user: null,
        orders: []
      }
    }
  }

  try {
    // Fetch user data from API
    const user = await getUserData(sessionToken)
    const orders = await getUserOrders(user.id)

    return {
      props: {
        user,
        orders
      }
    }
  } catch (error) {
    return {
      props: {
        user: null,
        orders: []
      }
    }
  }
}

When to Use SSG vs SSR

// SSG - Perfect for blogs, marketing sites, documentation
// pages/about.js
export default function About() {
  return (
    <div>
      <h1>About Our Company</h1>
      <p>Founded in 2020, we're passionate about web development...</p>
    </div>
  )
}

export async function getStaticProps() {
  // This content rarely changes
  const companyStats = await fetchCompanyStats()

  return {
    props: { companyStats },
    revalidate: 86400 // Regenerate once per day
  }
}

// SSR - Perfect for user dashboards, search results, real-time data
// pages/search.js
export default function SearchResults({ query, results, totalCount }) {
  return (
    <div>
      <h1>Search Results for "{query}"</h1>
      <p>Found {totalCount} results</p>

      {results.map(item => (
        <div key={item.id} className="search-result">
          <h3>{item.title}</h3>
          <p>{item.description}</p>
        </div>
      ))}
    </div>
  )
}

export async function getServerSideProps({ query }) {
  const searchQuery = query.q || ''

  if (!searchQuery) {
    return {
      props: {
        query: '',
        results: [],
        totalCount: 0
      }
    }
  }

  // Fresh search results on every request
  const searchResults = await searchAPI(searchQuery)

  return {
    props: {
      query: searchQuery,
      results: searchResults.items,
      totalCount: searchResults.total
    }
  }
}

Chapter 2: Dynamic Routing and File-Based Navigation

Next.js File-Based Routing System

Next.js uses the file system to define routes automatically. The folder structure in your pages or app directory maps directly to URL paths:

pages/
├── index.js                    → /
├── about.js                    → /about
├── blog/
│   ├── index.js               → /blog
│   ├── [slug].js              → /blog/my-post
│   └── [category]/
│       └── [slug].js          → /blog/tech/my-tech-post
├── products/
│   ├── index.js               → /products
│   ├── [id].js                → /products/123
│   └── [...params].js         → /products/category/sub/item
└── api/
    ├── users.js               → /api/users
    └── products/
        └── [id].js            → /api/products/123

Dynamic Route Examples

Single Dynamic Segment

// pages/products/[id].js - Product detail page
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'
import Link from 'next/link'

export default function Product() {
  const router = useRouter()
  const { id } = router.query
  const [product, setProduct] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!id) return

    const fetchProduct = async () => {
      try {
        setLoading(true)
        const response = await fetch(`/api/products/${id}`)

        if (!response.ok) {
          throw new Error('Product not found')
        }

        const productData = await response.json()
        setProduct(productData)
      } catch (err) {
        setError(err.message)
      } finally {
        setLoading(false)
      }
    }

    fetchProduct()
  }, [id])

  if (loading) {
    return (
      <div className="loading-container">
        <div className="loading-spinner" />
        <p>Loading product...</p>
      </div>
    )
  }

  if (error) {
    return (
      <div className="error-container">
        <h1>Product Not Found</h1>
        <p>{error}</p>
        <Link href="/products">
          <a className="back-link">← Back to Products</a>
        </Link>
      </div>
    )
  }

  return (
    <div className="product-page">
      <nav className="breadcrumb">
        <Link href="/"><a>Home</a></Link><Link href="/products"><a>Products</a></Link><span>{product.name}</span>
      </nav>

      <div className="product-content">
        <div className="product-images">
          <img 
            src={product.image} 
            alt={product.name}
            className="main-image"
          />
        </div>

        <div className="product-info">
          <h1>{product.name}</h1>
          <p className="price">${product.price}</p>
          <p className="description">{product.description}</p>

          <div className="product-actions">
            <button className="add-to-cart">
              Add to Cart
            </button>
            <button 
              className="buy-now"
              onClick={() => router.push(`/checkout?product=${id}`)}
            >
              Buy Now
            </button>
          </div>
        </div>
      </div>

      <section className="related-products">
        <h2>Related Products</h2>
        {/* Related products component */}
      </section>
    </div>
  )
}

// Pre-render popular products at build time
export async function getStaticPaths() {
  const popularProducts = await getPopularProducts()

  return {
    paths: popularProducts.map(product => ({
      params: { id: product.id.toString() }
    })),
    fallback: 'blocking' // Generate other products on-demand
  }
}

export async function getStaticProps({ params }) {
  try {
    const product = await getProduct(params.id)

    return {
      props: { product },
      revalidate: 3600 // Regenerate every hour
    }
  } catch (error) {
    return {
      notFound: true
    }
  }
}

Multiple Dynamic Segments

// pages/blog/[category]/[slug].js - Nested blog routing
import { useRouter } from 'next/router'
import Link from 'next/link'
import { getBlogPost, getBlogPosts } from '../../../lib/blog'

export default function BlogPost({ post, category }) {
  const router = useRouter()

  if (router.isFallback) {
    return <div>Loading...</div>
  }

  return (
    <article className="blog-post">
      <nav className="breadcrumb">
        <Link href="/"><a>Home</a></Link><Link href="/blog"><a>Blog</a></Link><Link href={`/blog/${category}`}>
          <a className="category-link">{category}</a>
        </Link><span>{post.title}</span>
      </nav>

      <header className="post-header">
        <div className="category-badge">{category}</div>
        <h1>{post.title}</h1>
        <div className="post-meta">
          <span className="author">By {post.author}</span>
          <span className="date">{post.date}</span>
          <span className="read-time">{post.readTime} min read</span>
        </div>
      </header>

      <div 
        className="post-content"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />

      <footer className="post-footer">
        <div className="tags">
          {post.tags.map(tag => (
            <Link key={tag} href={`/blog/tag/${tag}`}>
              <a className="tag">#{tag}</a>
            </Link>
          ))}
        </div>

        <div className="navigation">
          {post.previousPost && (
            <Link href={`/blog/${category}/${post.previousPost.slug}`}>
              <a className="nav-link prev">← {post.previousPost.title}</a>
            </Link>
          )}
          {post.nextPost && (
            <Link href={`/blog/${category}/${post.nextPost.slug}`}>
              <a className="nav-link next">{post.nextPost.title} →</a>
            </Link>
          )}
        </div>
      </footer>
    </article>
  )
}

export async function getStaticPaths() {
  const posts = await getBlogPosts()

  return {
    paths: posts.map(post => ({
      params: { 
        category: post.category,
        slug: post.slug 
      }
    })),
    fallback: true
  }
}

export async function getStaticProps({ params }) {
  const post = await getBlogPost(params.category, params.slug)

  if (!post) {
    return { notFound: true }
  }

  return {
    props: {
      post,
      category: params.category
    },
    revalidate: 86400 // Revalidate daily
  }
}

Catch-All Routes

// pages/docs/[...slug].js - Documentation with nested paths
import { useRouter } from 'next/router'
import { getDocByPath, getDocsNavigation } from '../../lib/docs'

export default function DocsPage({ doc, navigation }) {
  const router = useRouter()
  const { slug } = router.query

  // slug is an array: ['getting-started', 'installation'] for /docs/getting-started/installation
  const currentPath = Array.isArray(slug) ? slug.join('/') : slug

  return (
    <div className="docs-layout">
      <aside className="docs-sidebar">
        <nav className="docs-nav">
          {navigation.map(section => (
            <div key={section.title} className="nav-section">
              <h3>{section.title}</h3>
              <ul>
                {section.pages.map(page => (
                  <li key={page.path}>
                    <Link href={`/docs/${page.path}`}>
                      <a className={currentPath === page.path ? 'active' : ''}>
                        {page.title}
                      </a>
                    </Link>
                  </li>
                ))}
              </ul>
            </div>
          ))}
        </nav>
      </aside>

      <main className="docs-content">
        <div className="docs-header">
          <h1>{doc.title}</h1>
          <p className="docs-description">{doc.description}</p>
        </div>

        <div 
          className="docs-body"
          dangerouslySetInnerHTML={{ __html: doc.content }}
        />

        <div className="docs-footer">
          <div className="page-navigation">
            {doc.previousPage && (
              <Link href={`/docs/${doc.previousPage.path}`}>
                <a className="nav-link prev">
                  ← {doc.previousPage.title}
                </a>
              </Link>
            )}
            {doc.nextPage && (
              <Link href={`/docs/${doc.nextPage.path}`}>
                <a className="nav-link next">
                  {doc.nextPage.title} →
                </a>
              </Link>
            )}
          </div>
        </div>
      </main>
    </div>
  )
}

export async function getStaticPaths() {
  const allDocs = await getAllDocs()

  return {
    paths: allDocs.map(doc => ({
      params: { slug: doc.path.split('/') }
    })),
    fallback: false
  }
}

export async function getStaticProps({ params }) {
  const path = params.slug.join('/')
  const doc = await getDocByPath(path)
  const navigation = await getDocsNavigation()

  return {
    props: {
      doc,
      navigation
    }
  }
}

Chapter 3: Creating API Routes for Backend Functionality

API Routes Fundamentals

Next.js API routes create serverless functions that handle HTTP requests. Files in the pages/api directory become API endpoints:

// pages/api/users.js - Basic CRUD API
export default async function handler(req, res) {
  const { method, query, body } = req

  switch (method) {
    case 'GET':
      return handleGetUsers(req, res)
    case 'POST':
      return handleCreateUser(req, res)
    case 'PUT':
      return handleUpdateUser(req, res)
    case 'DELETE':
      return handleDeleteUser(req, res)
    default:
      res.setHeader('Allow', ['GET', 'POST', 'PUT', 'DELETE'])
      return res.status(405).json({ error: `Method ${method} not allowed` })
  }
}

async function handleGetUsers(req, res) {
  try {
    const { page = 1, limit = 10, search } = req.query

    // Simulate database query
    const users = await getUsersFromDB({
      page: parseInt(page),
      limit: parseInt(limit),
      search
    })

    return res.status(200).json({
      success: true,
      data: users.data,
      pagination: {
        page: users.page,
        limit: users.limit,
        total: users.total,
        totalPages: Math.ceil(users.total / users.limit)
      }
    })
  } catch (error) {
    console.error('Error fetching users:', error)
    return res.status(500).json({
      success: false,
      error: 'Failed to fetch users'
    })
  }
}

async function handleCreateUser(req, res) {
  try {
    const { name, email, role = 'user' } = req.body

    // Validation
    if (!name || !email) {
      return res.status(400).json({
        success: false,
        error: 'Name and email are required'
      })
    }

    // Check if email exists
    const existingUser = await getUserByEmail(email)
    if (existingUser) {
      return res.status(409).json({
        success: false,
        error: 'User with this email already exists'
      })
    }

    // Create user
    const newUser = await createUser({ name, email, role })

    return res.status(201).json({
      success: true,
      data: newUser,
      message: 'User created successfully'
    })
  } catch (error) {
    console.error('Error creating user:', error)
    return res.status(500).json({
      success: false,
      error: 'Failed to create user'
    })
  }
}

Dynamic API Routes

// pages/api/products/[id].js - Product-specific API
import { getProduct, updateProduct, deleteProduct } from '../../../lib/database'
import { verifyAuth } from '../../../lib/auth'

export default async function handler(req, res) {
  const { method, query: { id } } = req

  // Validate product ID
  if (!id || isNaN(id)) {
    return res.status(400).json({ error: 'Invalid product ID' })
  }

  switch (method) {
    case 'GET':
      return handleGetProduct(id, req, res)
    case 'PUT':
      return handleUpdateProduct(id, req, res)
    case 'DELETE':
      return handleDeleteProduct(id, req, res)
    default:
      res.setHeader('Allow', ['GET', 'PUT', 'DELETE'])
      return res.status(405).json({ error: `Method ${method} not allowed` })
  }
}

async function handleGetProduct(id, req, res) {
  try {
    const product = await getProduct(id)

    if (!product) {
      return res.status(404).json({ error: 'Product not found' })
    }

    return res.status(200).json({
      success: true,
      data: product
    })
  } catch (error) {
    return res.status(500).json({ error: 'Failed to fetch product' })
  }
}

async function handleUpdateProduct(id, req, res) {
  try {
    // Check authentication
    const user = await verifyAuth(req)
    if (!user || user.role !== 'admin') {
      return res.status(403).json({ error: 'Access denied' })
    }

    const updates = req.body
    const updatedProduct = await updateProduct(id, updates)

    return res.status(200).json({
      success: true,
      data: updatedProduct,
      message: 'Product updated successfully'
    })
  } catch (error) {
    return res.status(500).json({ error: 'Failed to update product' })
  }
}

Advanced API Patterns

// pages/api/auth/login.js - Authentication API
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { serialize } from 'cookie'

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  try {
    const { email, password } = req.body

    // Validation
    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password required' })
    }

    // Find user
    const user = await getUserByEmail(email)
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    // Verify password
    const isValidPassword = await bcrypt.compare(password, user.password)
    if (!isValidPassword) {
      return res.status(401).json({ error: 'Invalid credentials' })
    }

    // Generate JWT token
    const token = jwt.sign(
      { userId: user.id, email: user.email },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    )

    // Set HTTP-only cookie
    const cookie = serialize('token', token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 60 * 60 * 24 * 7, // 7 days
      path: '/'
    })

    res.setHeader('Set-Cookie', cookie)

    return res.status(200).json({
      success: true,
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        role: user.role
      }
    })
  } catch (error) {
    console.error('Login error:', error)
    return res.status(500).json({ error: 'Login failed' })
  }
}

// pages/api/upload.js - File upload API
import multer from 'multer'
import path from 'path'

const upload = multer({
  dest: './public/uploads/',
  limits: { fileSize: 5 * 1024 * 1024 }, // 5MB limit
  fileFilter: (req, file, cb) => {
    const allowedTypes = /jpeg|jpg|png|gif/
    const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase())
    const mimetype = allowedTypes.test(file.mimetype)

    if (mimetype && extname) {
      return cb(null, true)
    } else {
      cb(new Error('Only image files are allowed'))
    }
  }
})

export default async function handler(req, res) {
  if (req.method !== 'POST') {
    return res.status(405).json({ error: 'Method not allowed' })
  }

  upload.single('image')(req, res, (err) => {
    if (err) {
      return res.status(400).json({ error: err.message })
    }

    if (!req.file) {
      return res.status(400).json({ error: 'No file uploaded' })
    }

    const fileUrl = `/uploads/${req.file.filename}`

    return res.status(200).json({
      success: true,
      data: {
        filename: req.file.filename,
        url: fileUrl,
        size: req.file.size
      }
    })
  })
}

export const config = {
  api: {
    bodyParser: false, // Required for multer
  },
}

Chapter 4: Server Actions - The Next.js Way to Handle Server-Side Tasks

Understanding Server Actions

Server Actions are a new Next.js feature that allows you to run server-side code directly from client components. They provide a seamless way to handle form submissions, database operations, and server-side logic without creating separate API routes.

Basic Server Actions

// app/actions.js - Server Actions file
'use server'

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

export async function createPost(formData) {
  const title = formData.get('title')
  const content = formData.get('content')
  const authorId = formData.get('authorId')

  // Validation
  if (!title || !content) {
    throw new Error('Title and content are required')
  }

  try {
    // Create post in database
    const post = await db.post.create({
      data: {
        title,
        content,
        authorId: parseInt(authorId),
        createdAt: new Date()
      }
    })

    // Revalidate the posts page to show new post
    revalidatePath('/posts')

    // Redirect to the new post
    redirect(`/posts/${post.id}`)
  } catch (error) {
    console.error('Failed to create post:', error)
    throw new Error('Failed to create post')
  }
}

export async function updatePost(id, formData) {
  const title = formData.get('title')
  const content = formData.get('content')

  try {
    await db.post.update({
      where: { id: parseInt(id) },
      data: { title, content, updatedAt: new Date() }
    })

    revalidatePath(`/posts/${id}`)
    revalidatePath('/posts')
  } catch (error) {
    throw new Error('Failed to update post')
  }
}

export async function deletePost(id) {
  try {
    await db.post.delete({
      where: { id: parseInt(id) }
    })

    revalidatePath('/posts')
    redirect('/posts')
  } catch (error) {
    throw new Error('Failed to delete post')
  }
}

Server Actions in Forms

// app/posts/create/page.js - Form with Server Action
import { createPost } from '../../actions'

export default function CreatePost() {
  return (
    <div className="create-post-page">
      <h1>Create New Post</h1>

      <form action={createPost} className="post-form">
        <div className="form-group">
          <label htmlFor="title">Title</label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="form-input"
          />
        </div>

        <div className="form-group">
          <label htmlFor="content">Content</label>
          <textarea
            id="content"
            name="content"
            rows="10"
            required
            className="form-textarea"
          />
        </div>

        <input type="hidden" name="authorId" value="1" />

        <div className="form-actions">
          <button type="submit" className="submit-button">
            Create Post
          </button>
        </div>
      </form>
    </div>
  )
}

// app/posts/[id]/edit/page.js - Edit form with Server Action
import { updatePost } from '../../../actions'
import { getPost } from '../../../lib/posts'

export default async function EditPost({ params }) {
  const post = await getPost(params.id)

  if (!post) {
    return <div>Post not found</div>
  }

  const updatePostWithId = updatePost.bind(null, params.id)

  return (
    <div className="edit-post-page">
      <h1>Edit Post</h1>

      <form action={updatePostWithId} className="post-form">
        <div className="form-group">
          <label htmlFor="title">Title</label>
          <input
            type="text"
            id="title"
            name="title"
            defaultValue={post.title}
            required
            className="form-input"
          />
        </div>

        <div className="form-group">
          <label htmlFor="content">Content</label>
          <textarea
            id="content"
            name="content"
            defaultValue={post.content}
            rows="10"
            required
            className="form-textarea"
          />
        </div>

        <div className="form-actions">
          <button type="submit" className="submit-button">
            Update Post
          </button>
        </div>
      </form>
    </div>
  )
}

Advanced Server Actions with Client Components

// app/components/PostManager.jsx - Client component using Server Actions
'use client'

import { useState, useTransition } from 'react'
import { createPost, deletePost } from '../actions'

export default function PostManager({ posts, currentUser }) {
  const [isPending, startTransition] = useTransition()
  const [formData, setFormData] = useState({ title: '', content: '' })
  const [error, setError] = useState('')

  const handleSubmit = async (e) => {
    e.preventDefault()
    setError('')

    if (!formData.title || !formData.content) {
      setError('Title and content are required')
      return
    }

    startTransition(async () => {
      try {
        const formDataObj = new FormData()
        formDataObj.append('title', formData.title)
        formDataObj.append('content', formData.content)
        formDataObj.append('authorId', currentUser.id.toString())

        await createPost(formDataObj)
        setFormData({ title: '', content: '' })
      } catch (err) {
        setError(err.message)
      }
    })
  }

  const handleDelete = (postId) => {
    if (!confirm('Are you sure you want to delete this post?')) {
      return
    }

    startTransition(async () => {
      try {
        await deletePost(postId)
      } catch (err) {
        setError('Failed to delete post')
      }
    })
  }

  return (
    <div className="post-manager">
      <form onSubmit={handleSubmit} className="create-form">
        <h2>Create New Post</h2>

        {error && <div className="error-message">{error}</div>}

        <input
          type="text"
          placeholder="Post title"
          value={formData.title}
          onChange={(e) => setFormData({ ...formData, title: e.target.value })}
          disabled={isPending}
        />

        <textarea
          placeholder="Post content"
          value={formData.content}
          onChange={(e) => setFormData({ ...formData, content: e.target.value })}
          disabled={isPending}
          rows="5"
        />

        <button type="submit" disabled={isPending}>
          {isPending ? 'Creating...' : 'Create Post'}
        </button>
      </form>

      <div className="posts-list">
        <h2>Your Posts</h2>
        {posts.map(post => (
          <div key={post.id} className="post-item">
            <h3>{post.title}</h3>
            <p>{post.content.substring(0, 100)}...</p>
            <div className="post-actions">
              <button
                onClick={() => handleDelete(post.id)}
                disabled={isPending}
                className="delete-button"
              >
                Delete
              </button>
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

Server Actions with Error Handling

// app/actions/user-actions.js - Robust Server Actions
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const UserSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  age: z.number().min(18, 'Must be at least 18 years old')
})

export async function createUser(prevState, formData) {
  // Validate input
  const validatedFields = UserSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    age: parseInt(formData.get('age'))
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
      message: 'Validation failed'
    }
  }

  try {
    // Check if email exists
    const existingUser = await db.user.findUnique({
      where: { email: validatedFields.data.email }
    })

    if (existingUser) {
      return {
        errors: { email: ['Email already exists'] },
        message: 'User creation failed'
      }
    }

    // Create user
    const user = await db.user.create({
      data: validatedFields.data
    })

    revalidatePath('/users')

    return {
      success: true,
      message: 'User created successfully',
      user
    }
  } catch (error) {
    return {
      errors: {},
      message: 'Database error. Please try again.'
    }
  }
}

// app/components/UserForm.jsx - Using Server Action with error handling
'use client'

import { useFormState } from 'react-dom'
import { createUser } from '../actions/user-actions'

const initialState = {
  message: '',
  errors: {}
}

export default function UserForm() {
  const [state, dispatch] = useFormState(createUser, initialState)

  return (
    <form action={dispatch} className="user-form">
      <div className="form-group">
        <label htmlFor="name">Name</label>
        <input type="text" id="name" name="name" />
        {state.errors?.name && (
          <div className="error">{state.errors.name}</div>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="email">Email</label>
        <input type="email" id="email" name="email" />
        {state.errors?.email && (
          <div className="error">{state.errors.email}</div>
        )}
      </div>

      <div className="form-group">
        <label htmlFor="age">Age</label>
        <input type="number" id="age" name="age" />
        {state.errors?.age && (
          <div className="error">{state.errors.age}</div>
        )}
      </div>

      <button type="submit">Create User</button>

      {state.message && (
        <div className={state.success ? 'success' : 'error'}>
          {state.message}
        </div>
      )}
    </form>
  )
}

Conclusion

Next.js transforms React from a client-side library into a full-stack framework capable of building production-ready applications with optimal performance, SEO benefits, and integrated backend functionality. Understanding these core concepts - rendering strategies, dynamic routing, API routes, and Server Actions - enables building sophisticated web applications that meet modern performance and user experience standards.

Key Concepts Mastered

Rendering Strategies: Choosing between SSG, SSR, and hybrid approaches based on content requirements and performance needs ensures optimal user experiences and search engine optimization.

Dynamic Routing: Leveraging file-based routing with dynamic segments creates scalable, maintainable navigation systems that handle complex URL structures efficiently.

Full-Stack Capabilities: API routes and Server Actions provide seamless backend functionality without leaving the React ecosystem, enabling everything from authentication to database operations.

Real-World Applications

These Next.js features enable:

  • E-commerce Platforms: Product pages with SSG for performance, user dashboards with SSR for personalization

  • Content Management: Blog systems with dynamic routing and Server Actions for content creation

  • SaaS Applications: Authentication systems, user management, and real-time features with integrated APIs

Best Practices Learned

Performance Optimization: Understanding when to use SSG vs SSR prevents over-engineering while ensuring fast load times and good SEO.

Code Organization: File-based routing and collocated Server Actions create intuitive project structures that scale with application complexity.

Error Handling: Proper validation, error boundaries, and user feedback in both client and server contexts create robust, user-friendly applications.

Next.js represents the evolution of React development, providing the tools necessary to build modern web applications that compete with traditional server-side frameworks while maintaining the developer experience and component model that makes React so compelling.