Intermediate

This intermediate tutorial covers production-ready Next.js patterns through 25 heavily annotated examples. Each example maintains 1-2.25 comment lines per code line to ensure deep understanding.

Prerequisites

Before starting, ensure you understand:

  • Beginner Next.js concepts (Server/Client Components, routing, Server Actions)
  • React hooks (useState, useEffect, useTransition)
  • TypeScript advanced types (generics, union types, type guards)
  • HTTP concepts (status codes, headers, cookies)

Group 1: Advanced Server Actions

Example 26: Server Action with useFormState Hook

useFormState hook provides Server Action state and pending status in Client Components. Perfect for showing validation errors and loading states.

// app/actions.ts
// => File location: app/actions.ts (Server Actions file)
// => Place at app root for global Server Actions

'use server';
// => REQUIRED directive: marks file as Server Actions module
// => All exports in this file are Server Actions
// => Server Actions run ONLY on server (never sent to client)

type FormState = {
  // => State type for useFormState hook
  // => Hook requires consistent state shape across submissions

  message: string;
  // => Success or error message to show user
  // => Always present in response

  errors?: {
    // => Optional errors object (only present on validation failure)
    // => Question mark makes property optional
    // => undefined when no validation errors

    name?: string;
    // => Name field error message (optional)
    // => Example: "Name must be at least 2 characters"

    amount?: string;
    // => Amount field error message (optional)
    // => Example: "Minimum donation is IDR 10,000"
  };
};
// => FormState defines contract between Server Action and useFormState
// => Both prevState and return value must match this type

export async function submitDonation(
  // => Server Action function signature for useFormState
  // => Export required: makes function callable from Client Components
  // => async keyword: allows await for database/API calls

  prevState: FormState,
  // => Previous form state from last submission
  // => useFormState provides this automatically
  // => First submission: receives initial state
  // => Subsequent submissions: receives previous return value
  // => Type must match return type (FormState)

  formData: FormData
  // => Form data from form submission
  // => Automatically populated by browser on submit
  // => Contains all input values with name attributes
  // => FormData is browser API (not Next.js specific)
): Promise<FormState> {
  // => Return type: Promise<FormState>
  // => async functions always return Promise
  // => Resolved value must match FormState type

  const name = formData.get('name') as string;
  // => Extract 'name' input value from form
  // => formData.get() returns FormDataEntryValue (string | File | null)
  // => Type assertion 'as string': treat value as string
  // => For input name="name", value is "Ahmad"
  // => name is "Ahmad" (type: string)

  const amountStr = formData.get('amount') as string;
  // => Extract 'amount' input value as string
  // => Number inputs still submit as strings
  // => For input name="amount" value="100000"
  // => amountStr is "100000" (type: string, NOT number)
  // => Need parseInt() to convert to number

  const errors: FormState['errors'] = {};
  // => Initialize errors object
  // => Type: FormState['errors'] (indexed access type)
  // => Initially empty object {}
  // => Add properties only if validation fails

  if (!name || name.length < 2) {
    // => Validate name field
    // => !name: true if name is null, undefined, or empty string ""
    // => name.length < 2: true if name has less than 2 characters
    // => OR operator: true if either condition true

    errors.name = 'Name must be at least 2 characters';
    // => Add error message to errors object
    // => errors.name now exists (was optional)
    // => User sees this message below name input
  }

  const amount = parseInt(amountStr);
  // => Convert string to number
  // => parseInt("100000") returns 100000 (type: number)
  // => parseInt("abc") returns NaN (Not a Number)
  // => parseInt("") returns NaN
  // => amount is 100000 or NaN (type: number)

  if (isNaN(amount) || amount < 10000) {
    // => Validate amount field
    // => isNaN(amount): true if amount is Not a Number
    // =>   Example: user entered non-numeric text
    // => amount < 10000: true if donation too small
    // => OR operator: true if either condition true

    errors.amount = 'Minimum donation is IDR 10,000';
    // => Add error message to errors object
    // => errors.amount now exists (was optional)
  }

  if (Object.keys(errors).length > 0) {
    // => Check if any validation errors exist
    // => Object.keys(errors) returns array of property names
    // => Example: ["name", "amount"] if both fields invalid
    // => .length > 0: true if errors object has any properties
    // => Validation FAILED if true

    return {
      // => Early return with validation errors
      // => Stops function execution here
      // => Client receives error state

      message: 'Validation failed',
      // => Generic error message
      // => Specific errors in errors object

      errors,
      // => Shorthand for errors: errors
      // => Includes all validation error messages
      // => Client Component displays these below fields
    };
    // => Return type matches FormState
    // => useFormState receives this, updates state
    // => Component re-renders with error messages
  }

  await new Promise(resolve => setTimeout(resolve, 1000));
  // => Simulate database save delay
  // => Production: await prisma.donation.create(...)
  // => new Promise: creates promise that resolves after 1000ms (1 second)
  // => setTimeout: delays resolve function call
  // => await: pauses execution until promise resolves

  console.log(`Saved donation: ${name} - IDR ${amount}`);
  // => Server-side logging
  // => For name="Ahmad", amount=100000
  // => Output: "Saved donation: Ahmad - IDR 100000"
  // => Logs appear in terminal (server), NOT browser console

  return {
    // => Success response
    // => Validation passed, database save complete

    message: `Thank you ${name}! Donation of IDR ${amount.toLocaleString()} received.`,
    // => Personalized success message
    // => Template literal with interpolation
    // => amount.toLocaleString() formats number with commas
    // => 100000 becomes "100,000"
    // => Result: "Thank you Ahmad! Donation of IDR 100,000 received."
  };
  // => Return type matches FormState (no errors property)
  // => useFormState receives this, updates state
  // => Component shows success message
}

// app/donate/page.tsx
// => File location: app/donate/page.tsx
// => Route: /donate
// => Client Component (needs useFormState hook)

'use client';
// => REQUIRED directive: marks component as Client Component
// => Needed for React hooks (useFormState)
// => Without this: Error "useFormState can only be used in Client Components"

import { useFormState } from 'react-dom';
// => Import useFormState hook from react-dom (not react)
// => useFormState: manages form state with Server Actions
// => Returns [state, formAction] tuple

import { submitDonation } from '../actions';
// => Import Server Action from actions.ts
// => Relative path: ../actions (up one level, then actions.ts)
// => submitDonation can be called from Client Component

export default function DonatePage() {
  // => Page component export
  // => Default export: Next.js renders this for /donate route

  const [state, formAction] = useFormState(submitDonation, {
    // => useFormState hook manages Server Action state
    // => First argument: Server Action function (submitDonation)
    // => Second argument: initial state
    // => Returns array: [current state, wrapped action]

    message: '',
    // => Initial state message (empty string)
    // => First render: state.message is ''
    // => After submission: state.message is success/error message
  });
  // => state: current form state (type: FormState)
  // =>   Updates after each submission with Server Action return value
  // => formAction: wrapped Server Action for form action attribute
  // =>   Handles state management automatically

  return (
    <div>
      <h1>Make a Donation</h1>

      <form action={formAction}>
        {/* => form element with action prop */}
        {/* => action={formAction}: wrapped Server Action from useFormState */}
        {/* => NOT action={submitDonation} (loses state management) */}
        {/* => On submit: calls formAction with FormData */}

        <div>
          <label>
            Name:
            <input type="text" name="name" />
            {/* => name attribute REQUIRED: used in formData.get('name') */}
            {/* => User types: value included in FormData on submit */}
          </label>

          {state.errors?.name && (
            // => Conditional rendering: only show if error exists
            // => state.errors?.name: optional chaining (safe access)
            // => If state.errors undefined: short-circuits to false
            // => If state.errors.name exists: renders error message
            // => && operator: renders right side only if left side truthy

            <p style={{ color: 'red' }}>{state.errors.name}</p>
            // => Error message paragraph
            // => Inline style: red text for visibility
            // => {state.errors.name}: "Name must be at least 2 characters"
          )}
        </div>

        <div>
          <label>
            Amount (IDR):
            <input type="number" name="amount" />
            {/* => type="number": numeric keyboard on mobile */}
            {/* => name="amount": used in formData.get('amount') */}
            {/* => Value still submitted as string (need parseInt) */}
          </label>

          {state.errors?.amount && (
            // => Conditional error rendering for amount field
            // => Same pattern as name field error

            <p style={{ color: 'red' }}>{state.errors.amount}</p>
            // => Amount field error message
            // => Example: "Minimum donation is IDR 10,000"
          )}
        </div>

        <button type="submit">Donate</button>
        {/* => Submit button triggers form submission */}
        {/* => type="submit": submits form (not just button) */}
        {/* => Calls formAction with FormData */}

        {state.message && (
          // => Show success or generic error message
          // => state.message always exists (never undefined)
          // => Empty string is falsy, so nothing renders initially
          // => After submission: shows "Thank you..." or "Validation failed"

          <p>{state.message}</p>
          // => Message paragraph
          // => Success: "Thank you Ahmad! Donation of IDR 100,000 received."
          // => Error: "Validation failed"
        )}
      </form>
    </div>
  );
}
// => Full workflow:
// => 1. User fills form, clicks "Donate"
// => 2. Browser creates FormData with input values
// => 3. formAction called with FormData
// => 4. submitDonation Server Action executes on server
// => 5. Returns FormState (success or errors)
// => 6. useFormState updates state with return value
// => 7. Component re-renders with new state
// => 8. User sees errors or success message

Key Takeaway: Use useFormState hook to manage Server Action state in Client Components. Perfect for validation errors, success messages, and form state persistence.

Expected Output: Form shows field-specific validation errors after submission. Success message displays on valid submission. State persists between submissions.

Common Pitfalls: Forgetting prevState parameter in Server Action (useFormState requires it), or not typing FormState properly (lose type safety).

Example 27: Server Action with useFormStatus Hook

useFormStatus hook provides form submission status (pending, data, method). Use in form children to show loading states during submission.

// app/actions.ts
// => File location: app/actions.ts (Server Actions module)
// => Centralized Server Actions for application

'use server';
// => REQUIRED directive: makes all exports Server Actions
// => Server Actions execute on server only

export async function createPost(formData: FormData) {
  // => Server Action for creating blog posts
  // => Export required: callable from Client Components
  // => async keyword: allows await for database operations
  // => formData parameter: receives form data from submission

  const title = formData.get('title') as string;
  // => Extract 'title' field from FormData
  // => formData.get() returns FormDataEntryValue (string | File | null)
  // => Type assertion 'as string': treat as string
  // => For input name="title" value="Zakat Guide"
  // => title is "Zakat Guide" (type: string)

  const content = formData.get('content') as string;
  // => Extract 'content' field from FormData
  // => For textarea name="content" value="This guide explains..."
  // => content is "This guide explains..." (type: string)
  // => Textareas submit values as strings (like inputs)

  await new Promise(resolve => setTimeout(resolve, 2000));
  // => Simulate slow database operation
  // => Real code: await prisma.post.create({ data: { title, content } })
  // => new Promise: creates promise resolving after 2000ms (2 seconds)
  // => setTimeout: delays resolve call by 2 seconds
  // => await: pauses function execution until promise resolves
  // => During this time: useFormStatus pending is true

  console.log(`Created post: ${title}`);
  // => Server-side logging (appears in terminal)
  // => For title="Zakat Guide"
  // => Output: "Created post: Zakat Guide"
  // => Confirms Server Action executed successfully

  return { success: true };
  // => Return success indicator
  // => Object with success boolean property
  // => Client Component can check return value
  // => Type: { success: boolean }
}

// app/components/SubmitButton.tsx
// => File location: app/components/SubmitButton.tsx
// => Reusable submit button with loading state
// => MUST be separate component (useFormStatus requirement)

'use client';
// => REQUIRED directive: Client Component for useFormStatus hook
// => Cannot use useFormStatus in Server Component

import { useFormStatus } from 'react-dom';
// => Import useFormStatus hook from react-dom (not react)
// => Provides form submission status
// => Returns object with pending, data, method, action properties

export function SubmitButton() {
  // => Submit button component with loading state
  // => Export required: used in other components
  // => CRITICAL: Must be CHILD of form element (not same level)

  const { pending } = useFormStatus();
  // => useFormStatus hook reads form submission status
  // => pending: boolean, true during form submission
  // => Destructure pending from returned object
  // => pending is false initially
  // => pending becomes true when form submits
  // => pending becomes false when Server Action completes

  // => CRITICAL CONSTRAINT: useFormStatus ONLY works in components
  // =>   that are CHILDREN of form element
  // => If used in same component as <form>: returns default values (pending: false)
  // => Must be separate child component

  return (
    <button type="submit" disabled={pending}>
      {/* => Submit button with dynamic disabled state */}
      {/* => type="submit": submits parent form */}
      {/* => disabled={pending}: disable during submission */}
      {/* => disabled when pending=true (prevents double submission) */}
      {/* => enabled when pending=false (allows submission) */}

      {pending ? 'Creating Post...' : 'Create Post'}
      {/* => Conditional button text */}
      {/* => Ternary operator: condition ? ifTrue : ifFalse */}
      {/* => When pending=true: shows "Creating Post..." (loading state) */}
      {/* => When pending=false: shows "Create Post" (normal state) */}
      {/* => Provides visual feedback during submission */}
    </button>
  );
  // => Component returns button with dynamic content
  // => Re-renders automatically when pending changes
  // => Submission flow:
  // =>   1. User clicks → pending becomes true
  // =>   2. Button shows "Creating Post...", disables
  // =>   3. Server Action executes (2 second delay)
  // =>   4. Server Action completes → pending becomes false
  // =>   5. Button shows "Create Post", enables
}

// app/posts/new/page.tsx
// => File location: app/posts/new/page.tsx
// => Route: /posts/new
// => New post creation page

'use client';
// => REQUIRED: Client Component for importing Client Component (SubmitButton)
// => Also needed if page uses any hooks

import { createPost } from '@/app/actions';
// => Import Server Action
// => Absolute path with @/ alias (configured in tsconfig.json)
// => @/ represents project root
// => Equivalent to: import { createPost } from '../../actions';

import { SubmitButton } from '@/app/components/SubmitButton';
// => Import custom submit button component
// => Absolute path for clarity
// => Component has useFormStatus hook built-in

export default function NewPostPage() {
  // => Page component for creating new posts
  // => Default export: Next.js renders for /posts/new route

  return (
    <div>
      <h1>Create New Post</h1>
      {/* => Page heading */}

      <form action={createPost}>
        {/* => Form with Server Action */}
        {/* => action={createPost}: calls Server Action on submit */}
        {/* => createPost is Server Action function reference */}
        {/* => On submit: browser sends FormData to Server Action */}

        <label>
          Title:
          <input type="text" name="title" required />
          {/* => Title input field */}
          {/* => name="title": used in formData.get('title') */}
          {/* => required attribute: HTML5 validation (prevents empty submission) */}
          {/* => type="text": standard text input */}
        </label>

        <label>
          Content:
          <textarea name="content" required />
          {/* => Content textarea field */}
          {/* => name="content": used in formData.get('content') */}
          {/* => required: prevents empty submission */}
          {/* => textarea allows multi-line text input */}
        </label>

        <SubmitButton />
        {/* => Custom submit button component */}
        {/* => CRITICAL: SubmitButton is CHILD of form */}
        {/* => This placement allows useFormStatus to work */}
        {/* => Button automatically shows loading state during submission */}
        {/* => If we used <button> directly here without useFormStatus: */}
        {/* =>   No loading state, no disabled state during submission */}
      </form>
    </div>
  );
}
// => Complete workflow:
// => 1. User fills title and content fields
// => 2. User clicks "Create Post" button
// => 3. Form submits, sends FormData to createPost Server Action
// => 4. useFormStatus detects submission: pending becomes true
// => 5. SubmitButton shows "Creating Post...", disables
// => 6. Server Action executes (2 second delay)
// => 7. Server logs "Created post: [title]"
// => 8. Server Action returns { success: true }
// => 9. useFormStatus detects completion: pending becomes false
// => 10. SubmitButton shows "Create Post", enables again

Key Takeaway: Use useFormStatus hook in form children to access submission status. Perfect for submit button loading states and disabling during submission.

Expected Output: Submit button shows “Creating Post…” and disables during 2-second submission. Re-enables after completion.

Common Pitfalls: Using useFormStatus in same component as form (must be in child component), or not disabling inputs during submission (user can modify data).

Example 28: Progressive Enhancement with Server Actions

Server Actions work without JavaScript through native form submission. Add progressive enhancement with Client Component wrappers.

// app/actions.ts
'use server';
// => Server Actions work with/without JavaScript

import { redirect } from 'next/navigation';
// => Server-side navigation utility

export async function loginUser(formData: FormData) {
  // => Authentication Server Action
  const email = formData.get('email') as string;
  // => Extract email from form
  const password = formData.get('password') as string;
  // => Extract password from form

  if (email === 'user@example.com' && password === 'password123') {
    // => Validate credentials (production: check database)
    // cookies().set('auth_token', 'token123');
    // => Set auth cookie (commented for demo)
    redirect('/dashboard');
    // => HTTP 303 redirect without JavaScript
    // => Client-side navigation with JavaScript
  }

  return { error: 'Invalid email or password' };
  // => Return error for invalid credentials
}

// app/login/page.tsx
import { loginUser } from '../actions';
// => Import Server Action

export default function LoginPage() {
  // => Basic login page (no JavaScript required)
  return (
    <div>
      <h1>Login</h1>

      <form action={loginUser}>
        {/* => Form works WITHOUT JavaScript (HTTP POST) */}
        {/* => Form works WITH JavaScript (fetch, no reload) */}
        <label>
          Email:
          <input type="email" name="email" required />
          {/* => HTML5 email validation */}
        </label>

        <label>
          Password:
          <input type="password" name="password" required />
          {/* => Password field masked */}
        </label>

        <button type="submit">Login</button>
        {/* => Submits via HTTP POST (no JS) or fetch (with JS) */}
      </form>
    </div>
  );
}

// app/login-enhanced/page.tsx
'use client';
// => Client Component for hooks

import { useFormState, useFormStatus } from 'react-dom';
// => React form hooks
import { loginUser } from '../actions';

function SubmitButton() {
  // => Separate component (useFormStatus requirement)
  const { pending } = useFormStatus();
  // => Form submission status
  return (
    <button type="submit" disabled={pending}>
      {/* => Disable during submission */}
      {pending ? 'Logging in...' : 'Login'}
      {/* => Dynamic button text */}
    </button>
  );
}

export default function LoginEnhancedPage() {
  // => Enhanced with JavaScript features
  const [state, formAction] = useFormState(loginUser, {});
  // => useFormState manages form state
  // => state receives Server Action return value

  return (
    <div>
      <h1>Login (Enhanced)</h1>

      <form action={formAction}>
        {/* => Use wrapped formAction for state management */}
        <label>
          Email:
          <input type="email" name="email" required />
        </label>

        <label>
          Password:
          <input type="password" name="password" required />
        </label>

        <SubmitButton />
        {/* => Shows loading state during submission */}

        {state.error && (
          // => Conditional error display (JavaScript enhancement)
          <p style={{ color: 'red' }}>{state.error}</p>
          // => Inline error message
        )}
      </form>
    </div>
  );
}
// => Progressive enhancement: basic works without JS, enhanced with JS

Key Takeaway: Server Actions provide progressive enhancement. Base functionality works without JavaScript, enhanced features activate when JavaScript available.

Expected Output: Form works with JavaScript disabled (server-side submission and redirect). With JavaScript, shows loading states and inline errors.

Common Pitfalls: Relying on client-side features for core functionality (breaks without JavaScript), or not testing with JavaScript disabled.

Group 2: Cache Revalidation Strategies

Example 29: Time-Based Revalidation (ISR)

Use revalidate option to set cache lifetime. Next.js regenerates page after expiration, serving stale content while revalidating.

// app/posts/page.tsx
// => ISR (Incremental Static Regeneration) example

async function getPosts() {
  // => Fetch function with caching
  const res = await fetch('https://jsonplaceholder.typicode.com/posts', {
    // => Fetch posts from API
    next: { revalidate: 60 }
    // => Cache for 60 seconds
    // => After 60s: serve stale, regenerate in background
  });
  // => First request: fetch from API, cache result
  // => Within 60s: serve from cache (instant)
  // => After 60s: serve stale cache, fetch fresh data background
  // => Next request after regeneration: serve fresh data

  return res.json();
  // => Return parsed JSON response
}

export default async function PostsPage() {
  // => Async Server Component
  const posts = await getPosts();
  // => Fetch posts (may be cached)
  // => posts is array of Post objects

  return (
    <div>
      <h2>Blog Posts (ISR)</h2>

      <p>Last rendered: {new Date().toLocaleTimeString()}</p>
      {/* => Timestamp shows when page generated */}
      {/* => Updates every 60s after first request */}

      <ul>
        {posts.slice(0, 5).map((post: any) => (
          // => Display first 5 posts
          // => slice(0, 5) takes first 5 items from array
          <li key={post.id}>
            {/* => key prop required for list items */}
            <strong>{post.title}</strong>
            {/* => Post title in bold */}
          </li>
        ))}
      </ul>
    </div>
  );
}

// Alternative: page-level revalidate export
export const revalidate = 60;
// => Apply 60s revalidation to entire page
// => All fetch requests inherit this value
// => Simpler than setting revalidate per fetch

Key Takeaway: Use revalidate option for time-based cache invalidation (ISR). Page serves cached version, regenerates in background after expiration.

Expected Output: First visit generates page. Subsequent visits within 60s show cached version (same timestamp). After 60s, next visit triggers background revalidation.

Common Pitfalls: Setting revalidate too low (increases server load), or too high (users see stale data for long periods).

Example 30: On-Demand Revalidation with revalidatePath

Use revalidatePath() to invalidate specific route cache immediately. Perfect for content updates that should be visible instantly.

// app/actions.ts
'use server';
// => Server Actions module

import { revalidatePath } from 'next/cache';
// => Import cache invalidation function

export async function updatePost(postId: string, formData: FormData) {
  // => Update post Server Action
  // => First param: postId (from bind)
  // => Second param: formData (from form submission)
  const title = formData.get('title') as string;
  // => Extract title from form
  const content = formData.get('content') as string;
  // => Extract content from form

  // await db.posts.update({ where: { id: postId }, data: { title, content } });
  // => Database update (commented for demo)
  // => Production: update in database
  console.log(`Updated post ${postId}: ${title}`);
  // => Log update to server console

  revalidatePath(`/posts/${postId}`);
  // => Invalidate cache for specific post page
  // => Next request to /posts/[postId] fetches fresh data
  // => Cache cleared immediately

  revalidatePath('/posts');
  // => Invalidate posts list cache
  // => List shows updated post immediately
  // => Can invalidate multiple related paths
}

// app/posts/[id]/edit/page.tsx
'use client';
// => Client Component for form

import { updatePost } from '@/app/actions';
// => Import Server Action

export default function EditPostPage({
  params,
}: {
  params: { id: string };
  // => params from dynamic route [id]
}) {
  // => Edit page for specific post

  const updatePostWithId = updatePost.bind(null, params.id);
  // => Bind postId to first parameter
  // => updatePost expects (postId, formData)
  // => updatePostWithId now expects only formData
  // => postId is pre-filled with params.id

  return (
    <div>
      <h1>Edit Post {params.id}</h1>
      {/* => Show post ID being edited */}

      <form action={updatePostWithId}>
        {/* => Form calls bound Server Action */}
        {/* => Only formData sent (postId already bound) */}
        <label>
          Title:
          <input type="text" name="title" required />
          {/* => Title input field */}
        </label>

        <label>
          Content:
          <textarea name="content" required />
          {/* => Content textarea field */}
        </label>

        <button type="submit">Update Post</button>
        {/* => Submit triggers updatePostWithId */}
      </form>
    </div>
  );
}
// => On submit: updatePost(params.id, formData) called
// => Cache invalidated, fresh data served

Key Takeaway: Use revalidatePath() in Server Actions to invalidate specific route cache on-demand. Ensures users see fresh data immediately after mutations.

Expected Output: After updating post, navigating to /posts/[id] shows updated content immediately (cache invalidated). List also refreshed.

Common Pitfalls: Forgetting to revalidate related pages (post page updated but list still shows old data), or revalidating too broadly (invalidates unrelated caches).

Example 31: Tag-Based Revalidation with revalidateTag

Use fetch cache tags and revalidateTag() to invalidate multiple related requests at once. More efficient than path-based revalidation.

// app/lib/data.ts
// => Data fetching utilities with cache tags

export async function getUser(userId: string) {
  // => Fetch user by ID
  // => userId parameter: user identifier
  const res = await fetch(`https://api.example.com/users/${userId}`, {
    // => API request for user data
    next: {
      // => Next.js fetch options
      tags: [`user-${userId}`],
      // => Cache tag for revalidation
      // => Tag format: user-{userId}
      // => Allows selective cache invalidation
    },
  });
  // => res is Response object
  return res.json();
  // => Parse and return JSON
}

export async function getUserPosts(userId: string) {
  // => Fetch posts for specific user
  const res = await fetch(`https://api.example.com/users/${userId}/posts`, {
    // => API request for user's posts
    next: {
      tags: [`user-${userId}-posts`, `posts`],
      // => Multiple cache tags
      // => First tag: user-specific posts
      // => Second tag: all posts globally
      // => Enables both granular and broad revalidation
    },
  });
  return res.json();
  // => Return posts array
}

// app/actions.ts
'use server';
// => Server Actions module

import { revalidateTag } from 'next/cache';
// => Import tag-based cache invalidation

export async function updateUserProfile(userId: string, formData: FormData) {
  // => Update user profile Server Action
  // => userId: bound parameter
  // => formData: form submission data
  const name = formData.get('name') as string;
  // => Extract name from form
  // => name is "Ahmad Updated"

  // await db.users.update({ where: { id: userId }, data: { name } });
  // => Database update (commented for demo)

  revalidateTag(`user-${userId}`);
  // => Invalidate cache for user tag
  // => Affects getUser(userId) fetch
  // => Also affects getUserPosts (shares tag)
  // => All requests with this tag refreshed

  console.log(`Revalidated cache for user ${userId}`);
  // => Server log confirmation
}

export async function createPost(userId: string, formData: FormData) {
  // => Create new post Server Action
  const title = formData.get('title') as string;
  // => Extract title from form
  const content = formData.get('content') as string;
  // => Extract content from form

  // await db.posts.create({ data: { userId, title, content } });
  // => Database insertion (commented)

  revalidateTag('posts');
  // => Invalidate all posts caches
  // => Affects ALL fetches tagged 'posts'
  // => Refreshes post lists across site

  revalidateTag(`user-${userId}-posts`);
  // => Invalidate specific user's posts cache
  // => More granular than 'posts' tag
  // => Targets specific user's post list
}

// app/users/[id]/page.tsx
// => User profile page
import { getUser, getUserPosts } from '@/app/lib/data';
// => Import tagged fetch functions

export default async function UserPage({
  params,
}: {
  params: { id: string };
  // => Dynamic route params
}) {
  // => User page component

  const [user, posts] = await Promise.all([
    // => Fetch both concurrently
    // => Promise.all runs requests in parallel
    getUser(params.id),
    // => Fetch user data
    // => Tagged: user-${id}
    getUserPosts(params.id),
    // => Fetch user's posts
    // => Tagged: user-${id}-posts, posts
  ]);
  // => user contains profile data
  // => posts contains array of posts

  return (
    <div>
      <h1>{user.name}</h1>
      {/* => Display user name */}
      <p>Posts by {user.name}:</p>

      <ul>
        {posts.map((post: any) => (
          // => Iterate over posts array
          <li key={post.id}>{post.title}</li>
          // => Display post title
          // => key prop required for list items
        ))}
      </ul>
    </div>
  );
}
// => Tag revalidation workflow:
// => 1. updateUserProfile('123') → revalidateTag('user-123')
// => 2. Next getUser('123') request fetches fresh data
// => 3. getUserPosts('123') also refreshes (shares tag)

Key Takeaway: Use cache tags and revalidateTag() to invalidate related data across multiple routes. More flexible and efficient than path-based revalidation.

Expected Output: Updating user profile invalidates both user data and user posts (shared tag). Creating post invalidates all post lists (posts tag).

Common Pitfalls: Not using consistent tag naming (typos break revalidation), or over-revalidating with broad tags (unnecessary cache invalidation).

Group 3: Route Organization Patterns

Example 32: Route Groups for Organization

Use (folder) syntax to organize routes without affecting URL structure. Perfect for grouping related routes or layouts.

// app/(marketing)/layout.tsx
// => File location: app/(marketing)/layout.tsx
// => Parentheses create route group: (marketing)
// => Route groups organize files WITHOUT affecting URLs
// => (marketing) folder NOT part of URL path
export default function MarketingLayout({
  // => Layout for marketing pages
  children,
  // => Child pages render in layout
}: {
  children: React.ReactNode;
  // => Type: React component or JSX
}) {
  // => Marketing layout component
  return (
    <div>
      <header style={{ background: '#0173B2', color: 'white', padding: '1rem' }}>
        {/* => Marketing header with brand colors */}
        {/* => background #0173B2: accessible blue */}
        <h1>Islamic Finance Platform</h1>
        {/* => Site branding */}
        <nav>
          <a href="/">Home</a> | <a href="/about">About</a> | <a href="/pricing">Pricing</a>
          {/* => Navigation links */}
          {/* => All routes in (marketing) group */}
        </nav>
      </header>

      <main>
        {children}
        {/* => Marketing pages (/, /about, /pricing) render here */}
        {/* => Each page wrapped by this layout */}
      </main>

      <footer style={{ background: '#f5f5f5', padding: '2rem', textAlign: 'center' }}>
        {/* => Marketing footer */}
        {/* => Light gray background */}
        <p>© 2026 Islamic Finance Platform</p>
        {/* => Copyright notice */}
      </footer>
    </div>
  );
}
// => All pages in (marketing)/ use this layout

// app/(marketing)/page.tsx
// => File: app/(marketing)/page.tsx
// => URL: "/" (route group NOT in URL)
// => (marketing) group omitted from path
export default function HomePage() {
  // => Homepage component
  return (
    <div>
      <h1>Welcome to Islamic Finance</h1>
      {/* => Page heading */}
      <p>Learn Sharia-compliant financial products.</p>
      {/* => Page description */}
    </div>
  );
}
// => Wrapped by MarketingLayout (blue header + footer)

// app/(marketing)/about/page.tsx
// => File: app/(marketing)/about/page.tsx
// => URL: "/about" (NOT "/marketing/about")
// => Route group name omitted
export default function AboutPage() {
  // => About page component
  return (
    <div>
      <h1>About Us</h1>
      <p>Providing Islamic financial education since 2026.</p>
    </div>
  );
}
// => Also wrapped by MarketingLayout

// app/(app)/layout.tsx
// => File: app/(app)/layout.tsx
// => Separate route group: (app)
// => Different layout for application pages
export default function AppLayout({
  // => Application layout (different from marketing)
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div>
      <header style={{ background: '#029E73', color: 'white', padding: '1rem' }}>
        {/* => App header with different color */}
        {/* => background #029E73: accessible teal */}
        {/* => Visually distinct from marketing */}
        <nav>
          <a href="/dashboard">Dashboard</a> | <a href="/profile">Profile</a>
          {/* => App-specific navigation */}
        </nav>
      </header>

      {children}
      {/* => App pages render here */}
      {/* => No footer in app layout */}
    </div>
  );
}

// app/(app)/dashboard/page.tsx
// => File: app/(app)/dashboard/page.tsx
// => URL: "/dashboard" (NOT "/app/dashboard")
// => Route group omitted from URL
export default function DashboardPage() {
  // => Dashboard page component
  return (
    <div>
      <h1>Dashboard</h1>
      <p>Your donation history and stats.</p>
    </div>
  );
}
// => Wrapped by AppLayout (teal header, no footer)
// => Route groups enable multiple layouts without URL changes

Key Takeaway: Use (folder) route groups to organize routes without affecting URLs. Apply different layouts to different groups of routes.

Expected Output: “/” and “/about” use marketing layout (blue header). “/dashboard” uses app layout (green header). Route group names not visible in URLs.

Common Pitfalls: Forgetting parentheses (creates /marketing path), or nesting route groups unnecessarily (keep structure flat).

Example 33: Parallel Routes with @folder Convention

Use @folder syntax to render multiple pages in the same layout simultaneously. Perfect for dashboards with multiple sections.

// app/dashboard/layout.tsx
// => Layout receiving parallel routes as props
export default function DashboardLayout({
  children,
  analytics,                                // => @analytics parallel route
  notifications,                            // => @notifications parallel route
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;               // => Parallel route slot
  notifications: React.ReactNode;           // => Parallel route slot
}) {
  return (
    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
      {/* => Main content */}
      <div style={{ gridColumn: '1 / -1' }}>
        {children}
        {/* => app/dashboard/page.tsx content */}
      </div>

      {/* => Analytics section */}
      <div>
        <h2>Analytics</h2>
        {analytics}
        {/* => app/dashboard/@analytics/page.tsx content */}
      </div>

      {/* => Notifications section */}
      <div>
        <h2>Notifications</h2>
        {notifications}
        {/* => app/dashboard/@notifications/page.tsx content */}
      </div>
    </div>
  );
}

// app/dashboard/page.tsx
// => Main dashboard page
export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard Overview</h1>
      <p>Welcome to your dashboard.</p>
    </div>
  );
}

// app/dashboard/@analytics/page.tsx
// => Analytics parallel route (@ makes it parallel)
export default function AnalyticsPage() {
  return (
    <div>
      <p>Total Donations: IDR 1,500,000</p>
      <p>This Month: IDR 350,000</p>
    </div>
  );
}

// app/dashboard/@notifications/page.tsx
// => Notifications parallel route
export default function NotificationsPage() {
  return (
    <div>
      <p> Donation processed</p>
      <p> Profile updated</p>
    </div>
  );
}

Key Takeaway: Use @folder parallel routes to render multiple pages simultaneously in layout. Each parallel route can have independent loading/error states.

Expected Output: /dashboard shows three sections: main content, analytics, and notifications. All rendered in single layout with different data sources.

Common Pitfalls: Forgetting @ symbol (creates regular nested route), or not handling default.tsx fallback (shows error when route doesn’t exist).

Example 34: Intercepting Routes for Modals

Use (.)folder and (..)folder syntax to intercept routes and show as modals while preserving URL for direct access.

// app/posts/page.tsx
// => Posts list page
import Link from 'next/link';

export default function PostsPage() {
  const posts = [
    { id: '1', title: 'Zakat Guide' },
    { id: '2', title: 'Murabaha Basics' },
  ];

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <Link href={`/posts/${post.id}`}>
              {/* => Link to post detail */}
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

// app/posts/[id]/page.tsx
// => Full post detail page (direct access)
export default function PostDetailPage({
  params,
}: {
  params: { id: string };
}) {
  return (
    <div>
      <h1>Post {params.id} - Full Page</h1>
      <p>This is the full post page (direct access or refresh).</p>
      <a href="/posts">Back to Posts</a>
    </div>
  );
}

// app/posts/(.)posts/[id]/page.tsx
// => Intercepted route (.) means same level
// => Shows as modal when navigating from /posts
'use client';

import { useRouter } from 'next/navigation';

export default function PostModal({
  params,
}: {
  params: { id: string };
}) {
  const router = useRouter();

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        bottom: 0,
        background: 'rgba(0,0,0,0.5)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
      onClick={() => router.back()}
      // => Click backdrop closes modal
    >
      <div
        style={{
          background: 'white',
          padding: '2rem',
          borderRadius: '8px',
          maxWidth: '500px',
        }}
        onClick={e => e.stopPropagation()}
        // => Prevent closing when clicking modal content
      >
        <h1>Post {params.id} - Modal</h1>
        <p>This is the modal view (soft navigation).</p>
        <button onClick={() => router.back()}>Close</button>
      </div>
    </div>
  );
}

Key Takeaway: Use (.) intercepting routes to show content as modal on soft navigation while preserving full page for direct access/refresh.

Expected Output: Clicking post link from /posts shows modal overlay. Refreshing /posts/1 shows full page. Back button closes modal.

Common Pitfalls: Wrong interception syntax (. for same level, .. for parent level), or not handling direct access (only modal, no full page).

Group 4: Advanced Forms & Validation

Example 35: Form Validation with Zod Schema

Use Zod for runtime validation in Server Actions. Provides type-safe validation with detailed error messages.

// app/lib/schemas.ts
import { z } from "zod";
// => Import Zod schema builder

// => Zod schema for donation form
export const donationSchema = z.object({
  name: z.string().min(2, "Name must be at least 2 characters").max(50, "Name must be less than 50 characters"),
  // => String validation with length constraints

  email: z.string().email("Invalid email address"),
  // => Email format validation

  amount: z
    .number()
    .min(10000, "Minimum donation is IDR 10,000")
    .max(1000000000, "Maximum donation is IDR 1,000,000,000"),
  // => Number validation with range

  category: z.enum(["zakat", "sadaqah", "infaq"], {
    errorMap: () => ({ message: "Invalid category" }),
  }),
  // => Enum validation (must be one of these values)
});

// => TypeScript type inferred from schema
export type DonationInput = z.infer<typeof donationSchema>;
// => DonationInput is { name: string; email: string; amount: number; category: "zakat" | "sadaqah" | "infaq" }

// app/actions.ts
("use server");

import { donationSchema } from "./lib/schemas";

export async function submitDonation(formData: FormData) {
  // => Extract form data
  const rawData = {
    name: formData.get("name"),
    email: formData.get("email"),
    amount: parseFloat(formData.get("amount") as string),
    category: formData.get("category"),
  };

  // => Validate with Zod schema
  const result = donationSchema.safeParse(rawData);
  // => safeParse returns { success: boolean, data?: T, error?: ZodError }

  if (!result.success) {
    // => Validation failed
    const errors = result.error.flatten().fieldErrors;
    // => errors is { name?: string[], email?: string[], ... }

    return {
      success: false,
      errors: {
        // => Convert array errors to single messages
        name: errors.name?.[0],
        email: errors.email?.[0],
        amount: errors.amount?.[0],
        category: errors.category?.[0],
      },
    };
  }

  // => Validation passed, data is type-safe
  const { name, email, amount, category } = result.data;
  // => result.data is DonationInput type

  // => Save to database
  console.log(`Donation: ${name} (${email}) - IDR ${amount} - ${category}`);

  return {
    success: true,
    message: `Thank you ${name}! Your ${category} donation of IDR ${amount.toLocaleString()} has been received.`,
  };
}

Key Takeaway: Use Zod schemas for type-safe validation in Server Actions. Provides runtime validation, TypeScript types, and detailed error messages.

Expected Output: Form submission validates all fields. Invalid data returns field-specific errors. Valid data processes successfully with type safety.

Common Pitfalls: Not handling safeParse errors properly (check result.success), or mixing up parse() and safeParse() (parse throws, safeParse returns result).

Example 36: Optimistic Updates with useOptimistic

Use useOptimistic hook to show immediate UI feedback while Server Action processes. Reverts on error.

// app/posts/[id]/page.tsx
'use client';

import { useOptimistic, useTransition } from 'react';
// => Import useOptimistic and useTransition hooks

type Comment = {
  id: string;
  author: string;
  text: string;
  createdAt: Date;
};

export default function PostPage({
  initialComments,
}: {
  initialComments: Comment[];
}) {
  // => useOptimistic manages optimistic state
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    initialComments,                        // => Initial state
    (state, newComment: Comment) => [...state, newComment]
    // => Reducer: how to add optimistic comment
  );

  const [isPending, startTransition] = useTransition();

  async function handleSubmit(formData: FormData) {
    const author = formData.get('author') as string;
    const text = formData.get('text') as string;

    // => Create optimistic comment (shown immediately)
    const optimisticComment: Comment = {
      id: crypto.randomUUID(),
      author,
      text,
      createdAt: new Date(),
    };

    // => Add optimistic comment to UI (instant feedback)
    startTransition(() => {
      addOptimisticComment(optimisticComment);
      // => Comment appears in list immediately
    });

    // => Submit to server (async, might fail)
    try {
      // await createComment(formData);
      // => If server succeeds, page revalidates and shows real comment
      await new Promise(resolve => setTimeout(resolve, 2000));
      console.log('Comment created');
    } catch (error) {
      // => If server fails, optimistic update reverts
      console.error('Failed to create comment');
      // => Optimistic comment disappears from list
    }
  }

  return (
    <div>
      <h1>Post with Comments</h1>

      <div>
        <h2>Comments</h2>
        <ul>
          {optimisticComments.map(comment => (
            <li key={comment.id} style={{ opacity: isPending ? 0.5 : 1 }}>
              {/* => Optimistic comments shown with reduced opacity */}
              <strong>{comment.author}</strong>: {comment.text}
              <small> - {comment.createdAt.toLocaleTimeString()}</small>
            </li>
          ))}
        </ul>
      </div>

      <form action={handleSubmit}>
        <input type="text" name="author" placeholder="Your name" required />
        <textarea name="text" placeholder="Your comment" required />
        <button type="submit" disabled={isPending}>
          {isPending ? 'Posting...' : 'Post Comment'}
        </button>
      </form>
    </div>
  );
}

Key Takeaway: Use useOptimistic for immediate UI feedback during Server Actions. Shows optimistic state instantly, reverts on error, replaces with real data on success.

Expected Output: Submitting comment shows it immediately in list (optimistic). After 2 seconds, comment persists (server success). If error, comment disappears.

Common Pitfalls: Not wrapping in startTransition (optimistic update won’t work), or forgetting to revalidate on success (shows both optimistic and real comment).

Group 5: Authentication Patterns

Example 37: Cookies-Based Authentication

Use Next.js cookies() to manage authentication tokens. Accessible in Server Components and Route Handlers.

// app/lib/auth.ts
import { cookies } from 'next/headers';
// => Import cookies function

export async function getCurrentUser() {
  // => Server-only function (can't run on client)
  const cookieStore = cookies();            // => Access cookies
  const authToken = cookieStore.get('auth_token');
  // => authToken is { name: 'auth_token', value: 'token123' } or undefined

  if (!authToken) {
    return null;                            // => Not authenticated
  }

  // => Verify token and get user
  // const user = await db.users.findByToken(authToken.value);
  // => Simplified: return mock user
  return {
    id: '1',
    name: 'Ahmad',
    email: 'ahmad@example.com',
  };
}

// app/actions.ts
'use server';

import { cookies } from 'next/headers';

export async function login(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;

  // => Verify credentials
  if (email === 'user@example.com' && password === 'password123') {
    // => Set auth cookie (simplified)
    cookies().set('auth_token', 'token123', {
      httpOnly: true,                       // => Not accessible via JavaScript (security)
      secure: process.env.NODE_ENV === 'production', // => HTTPS only in production
      sameSite: 'lax',                      // => CSRF protection
      maxAge: 60 * 60 * 24 * 7,            // => 7 days
    });

    return { success: true };
  }

  return { success: false, error: 'Invalid credentials' };
}

export async function logout() {
  // => Delete auth cookie
  cookies().delete('auth_token');
  // => User logged out
}

// app/dashboard/page.tsx
// => Protected page using getCurrentUser
import { getCurrentUser } from '@/app/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardPage() {
  const user = await getCurrentUser();      // => Check authentication

  if (!user) {
    // => Not authenticated, redirect to login
    redirect('/login');
    // => Server-side redirect
  }

  return (
    <div>
      <h1>Welcome, {user.name}!</h1>
      <p>Email: {user.email}</p>
      {/* => Protected content only visible to authenticated users */}
    </div>
  );
}

Key Takeaway: Use cookies() function for authentication in Server Components and Server Actions. Set httpOnly and secure flags for security.

Expected Output: Login sets httpOnly cookie. Dashboard checks cookie and shows user data or redirects to login. Logout deletes cookie.

Common Pitfalls: Not setting httpOnly (XSS vulnerability), or forgetting secure flag in production (transmits over HTTP).

Example 38: Middleware-Based Authentication

Use middleware to protect multiple routes at once. More efficient than checking authentication in every page.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

// => Protected route patterns
const protectedRoutes = ['/dashboard', '/profile', '/settings'];

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // => Check if current route is protected
  const isProtected = protectedRoutes.some(route =>
    pathname.startsWith(route)
  );

  if (isProtected) {
    // => Check for auth token
    const authToken = request.cookies.get('auth_token');

    if (!authToken) {
      // => Not authenticated, redirect to login
      const loginUrl = new URL('/login', request.url);
      loginUrl.searchParams.set('redirect', pathname);
      // => Save intended destination for post-login redirect

      return NextResponse.redirect(loginUrl);
    }

    // => TODO: Verify token validity
    // const isValid = await verifyToken(authToken.value);
    // if (!isValid) { return NextResponse.redirect(...) }
  }

  // => Authenticated or public route
  return NextResponse.next();
}

export const config = {
  // => Run middleware on protected routes only
  matcher: ['/dashboard/:path*', '/profile/:path*', '/settings/:path*'],
};

// app/login/page.tsx
'use client';

import { useSearchParams, useRouter } from 'next/navigation';
import { login } from '../actions';

export default function LoginPage() {
  const searchParams = useSearchParams();
  const router = useRouter();
  const redirect = searchParams.get('redirect') || '/dashboard';
  // => Get redirect parameter from URL

  async function handleSubmit(formData: FormData) {
    const result = await login(formData);

    if (result.success) {
      // => Login successful, redirect to intended destination
      router.push(redirect);
    }
  }

  return (
    <div>
      <h1>Login</h1>
      <p>Redirecting to: {redirect}</p>

      <form action={handleSubmit}>
        <input type="email" name="email" required />
        <input type="password" name="password" required />
        <button type="submit">Login</button>
      </form>
    </div>
  );
}

Key Takeaway: Use middleware to protect multiple routes efficiently. Checks authentication once for all protected paths, redirects with intended destination.

Expected Output: Accessing /dashboard without auth redirects to /login?redirect=/dashboard. After login, returns to /dashboard.

Common Pitfalls: Infinite redirect loop (login page also protected), or not preserving redirect parameter (users lose intended destination).

Group 6: Database Integration Patterns

Example 39: Prisma Integration with Server Components

Use Prisma ORM in Server Components for type-safe database queries. Zero client JavaScript, automatic TypeScript types.

// prisma/schema.prisma
// => Prisma schema defines database structure
// model Post {
//   id        String   @id @default(cuid())
//   title     String
//   content   String
//   published Boolean  @default(false)
//   createdAt DateTime @default(now())
// }

// app/lib/prisma.ts
import { PrismaClient } from '@prisma/client';

// => Singleton pattern for Prisma client
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: ['query'],                         // => Log SQL queries in development
  });

if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}
// => Prevents multiple Prisma instances in development (hot reload)

// app/posts/page.tsx
// => Server Component with Prisma query
import { prisma } from '@/app/lib/prisma';

export default async function PostsPage() {
  // => Type-safe Prisma query
  const posts = await prisma.post.findMany({
    // => posts is Post[] (TypeScript type from schema)
    where: { published: true },             // => Only published posts
    orderBy: { createdAt: 'desc' },         // => Newest first
    take: 10,                               // => Limit to 10 posts
    select: {
      // => Select specific fields
      id: true,
      title: true,
      createdAt: true,
    },
  });
  // => Query runs on server, results cached

  return (
    <div>
      <h1>Published Posts</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>
            <strong>{post.title}</strong>
            <small> - {post.createdAt.toLocaleDateString()}</small>
          </li>
        ))}
      </ul>
    </div>
  );
}

// app/actions.ts
'use server';

import { prisma } from './lib/prisma';
import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // => Create post with Prisma
  const post = await prisma.post.create({
    data: {
      title,
      content,
      published: true,
    },
  });
  // => post is Post object with all fields

  // => Revalidate posts page
  revalidatePath('/posts');

  return { success: true, postId: post.id };
}

Key Takeaway: Use Prisma in Server Components and Server Actions for type-safe database queries. Singleton pattern prevents multiple clients in development.

Expected Output: Posts page shows database posts (type-safe query). Creating post saves to database and revalidates page cache.

Common Pitfalls: Multiple Prisma instances in development (memory leak), or not revalidating after mutations (stale cache).

Example 40: Error Handling for Database Queries

Wrap database queries in try-catch blocks to handle errors gracefully. Show user-friendly messages instead of crashes.

// app/posts/[id]/page.tsx
import { prisma } from '@/app/lib/prisma';
import { notFound } from 'next/navigation';

export default async function PostPage({
  params,
}: {
  params: { id: string };
}) {
  try {
    // => Query might fail (network error, invalid ID, etc.)
    const post = await prisma.post.findUnique({
      where: { id: params.id },
    });

    if (!post) {
      // => Post not found in database
      notFound();
      // => Triggers not-found.tsx rendering
    }

    return (
      <div>
        <h1>{post.title}</h1>
        <p>{post.content}</p>
      </div>
    );
  } catch (error) {
    // => Database error (connection failed, timeout, etc.)
    console.error('Database error:', error);

    // => Throw error to trigger error.tsx
    throw new Error('Failed to load post');
  }
}

// app/actions.ts
'use server';

import { prisma } from './lib/prisma';

export async function deletePost(postId: string) {
  try {
    // => Delete operation might fail
    await prisma.post.delete({
      where: { id: postId },
    });

    return { success: true };
  } catch (error) {
    // => Handle Prisma errors
    if (error.code === 'P2025') {
      // => Prisma error code: Record not found
      return {
        success: false,
        error: 'Post not found',
      };
    }

    // => Generic error
    console.error('Failed to delete post:', error);
    return {
      success: false,
      error: 'Failed to delete post',
    };
  }
}

Key Takeaway: Wrap database queries in try-catch blocks. Use notFound() for missing resources, throw errors for server issues, return error objects from Server Actions.

Expected Output: Missing post shows not-found.tsx. Database errors show error.tsx. Server Actions return error messages without crashing.

Common Pitfalls: Not handling Prisma error codes (generic error messages), or throwing errors in Server Actions (should return error objects).

Group 7: Client-Side Data Fetching

Example 41: Client-Side Data Fetching with SWR

Use SWR for client-side data fetching with automatic caching, revalidation, and error handling. Perfect for user-specific or frequently updating data.

// app/dashboard/donations/page.tsx
'use client';
// => Client Component for SWR

import useSWR from 'swr';
// => Import SWR hook

// => Fetcher function (required by SWR)
const fetcher = (url: string) => fetch(url).then(res => res.json());
// => fetcher receives URL, returns promise of JSON data

export default function DonationsPage() {
  // => useSWR hook manages data fetching
  const { data, error, isLoading, mutate } = useSWR('/api/donations', fetcher);
  // => data: fetched data (undefined while loading)
  // => error: error object if request failed
  // => isLoading: true during initial fetch
  // => mutate: function to revalidate data manually

  if (isLoading) {
    // => Show loading state
    return <p>Loading donations...</p>;
  }

  if (error) {
    // => Show error state
    return <p>Failed to load donations: {error.message}</p>;
  }

  // => Data loaded successfully
  return (
    <div>
      <h1>Your Donations</h1>

      <ul>
        {data.donations.map((donation: any) => (
          <li key={donation.id}>
            IDR {donation.amount.toLocaleString()} - {donation.date}
          </li>
        ))}
      </ul>

      <button onClick={() => mutate()}>
        {/* => Manually revalidate data */}
        Refresh
      </button>
    </div>
  );
}
// => SWR features:
// => - Automatic caching (subsequent renders instant)
// => - Revalidation on focus (window regains focus)
// => - Revalidation on reconnect (network reconnects)
// => - Interval revalidation (optional)

Key Takeaway: Use SWR for client-side data fetching with automatic caching, revalidation, and error handling. Perfect for dynamic, user-specific data.

Expected Output: Page loads donations from API. Data cached for instant subsequent renders. Clicking “Refresh” manually revalidates.

Common Pitfalls: Using SWR in Server Components (only works in Client Components), or not providing fetcher function (required parameter).

Example 42: Client-Side Data Fetching with TanStack Query

Use TanStack Query (React Query) for advanced client-side data management with powerful caching and synchronization.

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export function Providers({ children }: { children: React.ReactNode }) {
  // => Create QueryClient instance
  const [queryClient] = useState(() => new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,               // => Data fresh for 1 minute
        cacheTime: 5 * 60 * 1000,           // => Cache for 5 minutes
      },
    },
  }));

  return (
    <QueryClientProvider client={queryClient}>
      {/* => All children can use TanStack Query hooks */}
      {children}
    </QueryClientProvider>
  );
}

// app/layout.tsx
import { Providers } from './providers';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <Providers>
          {children}
        </Providers>
      </body>
    </html>
  );
}

// app/posts/page.tsx
'use client';

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

export default function PostsPage() {
  const queryClient = useQueryClient();

  // => useQuery for fetching data
  const { data, isLoading, error } = useQuery({
    queryKey: ['posts'],                    // => Unique query identifier
    queryFn: async () => {
      // => Query function: fetches data
      const res = await fetch('/api/posts');
      return res.json();
    },
  });
  // => data is cached with key ['posts']

  // => useMutation for mutations
  const createPostMutation = useMutation({
    mutationFn: async (newPost: { title: string }) => {
      // => Mutation function: creates post
      const res = await fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      });
      return res.json();
    },
    onSuccess: () => {
      // => Invalidate posts query after successful mutation
      queryClient.invalidateQueries({ queryKey: ['posts'] });
      // => Triggers refetch of posts data
    },
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>Posts</h1>
      <ul>
        {data.posts.map((post: any) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      <button
        onClick={() => createPostMutation.mutate({ title: 'New Post' })}
        disabled={createPostMutation.isPending}
      >
        {createPostMutation.isPending ? 'Creating...' : 'Create Post'}
      </button>
    </div>
  );
}

Key Takeaway: Use TanStack Query for advanced client-side data management. Provides powerful caching, automatic refetching, and mutation invalidation.

Expected Output: Posts load from API with caching. Creating post invalidates cache and refetches automatically. Loading states managed by library.

Common Pitfalls: Not wrapping app in QueryClientProvider (hooks won’t work), or forgetting to invalidate queries after mutations (stale data).

Group 8: Advanced Form Patterns

Example 43: Form Handling with React Hook Form

Use React Hook Form for complex forms with validation, field arrays, and better performance than native form handling.

// app/register/page.tsx
'use client';

import { useForm, SubmitHandler } from 'react-hook-form';
// => Import useForm hook and types

// => Form data type
type FormData = {
  name: string;
  email: string;
  password: string;
  confirmPassword: string;
};

export default function RegisterPage() {
  // => useForm hook manages form state
  const {
    register,                               // => Function to register inputs
    handleSubmit,                           // => Form submit handler
    formState: { errors, isSubmitting },    // => Form state
    watch,                                  // => Watch field values
  } = useForm<FormData>();

  // => Submit handler
  const onSubmit: SubmitHandler<FormData> = async (data) => {
    // => data is FormData type (type-safe)
    console.log('Form data:', data);
    // => data is { name: "Ahmad", email: "...", password: "...", ... }

    // => Simulate API call
    await new Promise(resolve => setTimeout(resolve, 2000));
    console.log('Registration successful');
  };

  // => Watch password for confirmation validation
  const password = watch('password');       // => password is current password value

  return (
    <div>
      <h1>Register</h1>

      <form onSubmit={handleSubmit(onSubmit)}>
        {/* => handleSubmit wraps onSubmit with validation */}

        <div>
          <label>Name:</label>
          <input
            type="text"
            {...register('name', {
              // => Register input with validation rules
              required: 'Name is required',
              minLength: {
                value: 2,
                message: 'Name must be at least 2 characters',
              },
            })}
          />
          {/* => Show validation error */}
          {errors.name && <p style={{ color: 'red' }}>{errors.name.message}</p>}
        </div>

        <div>
          <label>Email:</label>
          <input
            type="email"
            {...register('email', {
              required: 'Email is required',
              pattern: {
                value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
                message: 'Invalid email address',
              },
            })}
          />
          {errors.email && <p style={{ color: 'red' }}>{errors.email.message}</p>}
        </div>

        <div>
          <label>Password:</label>
          <input
            type="password"
            {...register('password', {
              required: 'Password is required',
              minLength: {
                value: 8,
                message: 'Password must be at least 8 characters',
              },
            })}
          />
          {errors.password && <p style={{ color: 'red' }}>{errors.password.message}</p>}
        </div>

        <div>
          <label>Confirm Password:</label>
          <input
            type="password"
            {...register('confirmPassword', {
              required: 'Please confirm password',
              validate: value =>
                // => Custom validation: must match password
                value === password || 'Passwords do not match',
            })}
          />
          {errors.confirmPassword && (
            <p style={{ color: 'red' }}>{errors.confirmPassword.message}</p>
          )}
        </div>

        <button type="submit" disabled={isSubmitting}>
          {/* => Disable during submission */}
          {isSubmitting ? 'Registering...' : 'Register'}
        </button>
      </form>
    </div>
  );
}

Key Takeaway: Use React Hook Form for complex forms with validation, better performance (uncontrolled inputs), and type safety. Built-in validation rules and custom validators.

Expected Output: Form validates on submit. Shows field-specific errors. Submit button disables during submission. Type-safe form data.

Common Pitfalls: Not using {…register()} spread operator (validation won’t work), or forgetting to use handleSubmit wrapper (validation bypassed).

Example 44: Advanced Zod Validation with Transform

Use Zod transform() to convert and validate form data simultaneously. Perfect for normalizing input before processing.

// app/lib/schemas.ts
import { z } from "zod";

// => Schema with transformations
export const productSchema = z.object({
  name: z
    .string()
    .trim() // => Remove whitespace
    .toLowerCase() // => Normalize to lowercase
    .min(3, "Product name must be at least 3 characters"),

  price: z
    .string() // => Input as string (from form)
    .transform((val) => parseFloat(val)) // => Transform to number
    .pipe(
      // => Chain with number validations
      z.number().min(0, "Price must be positive").max(1000000000, "Price too high"),
    ),

  category: z.enum(["murabaha", "ijarah", "musharakah"]).transform((val) => val.toUpperCase()), // => Transform to uppercase

  tags: z
    .string() // => Input: "tag1, tag2, tag3"
    .transform((val) =>
      // => Transform comma-separated string to array
      val
        .split(",")
        .map((tag) => tag.trim())
        .filter((tag) => tag.length > 0),
    )
    .pipe(
      // => Validate array
      z.array(z.string()).min(1, "At least one tag required"),
    ),

  availableFrom: z
    .string() // => Input: "2026-01-29"
    .transform((val) => new Date(val)) // => Transform to Date
    .pipe(z.date().min(new Date(), "Date must be in the future")),
});

// => Inferred type includes transformations
export type ProductInput = z.infer<typeof productSchema>;
// => ProductInput is {
// =>   name: string (trimmed, lowercase),
// =>   price: number (transformed from string),
// =>   category: "MURABAHA" | "IJARAH" | "MUSHARAKAH" (uppercase),
// =>   tags: string[] (transformed from comma-separated),
// =>   availableFrom: Date (transformed from string)
// => }

// app/actions.ts
("use server");

import { productSchema } from "./lib/schemas";

export async function createProduct(formData: FormData) {
  // => Parse and transform form data
  const result = productSchema.safeParse({
    name: formData.get("name"),
    price: formData.get("price"),
    category: formData.get("category"),
    tags: formData.get("tags"),
    availableFrom: formData.get("availableFrom"),
  });

  if (!result.success) {
    return {
      success: false,
      errors: result.error.flatten().fieldErrors,
    };
  }

  // => result.data is transformed and validated
  const { name, price, category, tags, availableFrom } = result.data;
  // => name is trimmed + lowercase
  // => price is number (not string)
  // => category is uppercase enum
  // => tags is array (not comma-separated string)
  // => availableFrom is Date object

  console.log("Creating product:", {
    name, // => "murabaha financing"
    price, // => 1000000 (number)
    category, // => "MURABAHA" (uppercase)
    tags, // => ["islamic", "finance"]
    availableFrom, // => Date object
  });

  return { success: true };
}

Key Takeaway: Use Zod transform() to convert and normalize data during validation. Reduces manual parsing and ensures consistent data format.

Expected Output: Form data automatically transformed (strings to numbers/dates, comma-separated to arrays, normalization). Type-safe transformed data.

Common Pitfalls: Not using pipe() after transform() (validation on wrong type), or forgetting transform order matters (operations applied sequentially).

Example 45: File Upload Handling with Server Actions

Handle file uploads securely with Server Actions. Validate file types, sizes, and save to storage.

// app/actions.ts
'use server';

import { writeFile } from 'fs/promises';
import path from 'path';

export async function uploadDocument(formData: FormData) {
  // => Extract file from FormData
  const file = formData.get('document') as File;
  // => file is File object (or null if not uploaded)

  if (!file) {
    return {
      success: false,
      error: 'No file uploaded',
    };
  }

  // => Validate file type
  const allowedTypes = ['application/pdf', 'image/jpeg', 'image/png'];
  if (!allowedTypes.includes(file.type)) {
    return {
      success: false,
      error: 'Invalid file type. Only PDF, JPEG, and PNG allowed.',
    };
  }

  // => Validate file size (max 5MB)
  const maxSize = 5 * 1024 * 1024;          // => 5MB in bytes
  if (file.size > maxSize) {
    return {
      success: false,
      error: 'File too large. Maximum size is 5MB.',
    };
  }

  // => Convert File to buffer
  const bytes = await file.arrayBuffer();
  const buffer = Buffer.from(bytes);
  // => buffer contains file data

  // => Generate unique filename
  const timestamp = Date.now();
  const filename = `${timestamp}-${file.name}`;
  // => filename is "1706524800000-document.pdf"

  // => Save file to public/uploads directory
  const uploadPath = path.join(process.cwd(), 'public', 'uploads', filename);
  await writeFile(uploadPath, buffer);
  // => File saved to disk

  // => Return file URL
  const fileUrl = `/uploads/${filename}`;

  return {
    success: true,
    fileUrl,
    message: `File uploaded: ${file.name}`,
  };
}

// app/upload/page.tsx
'use client';

import { useState } from 'react';
import { uploadDocument } from '../actions';

export default function UploadPage() {
  const [result, setResult] = useState<any>(null);

  async function handleSubmit(formData: FormData) {
    // => Call Server Action with FormData
    const uploadResult = await uploadDocument(formData);
    setResult(uploadResult);
  }

  return (
    <div>
      <h1>Upload Document</h1>

      <form action={handleSubmit}>
        <input
          type="file"
          name="document"
          accept=".pdf,.jpg,.jpeg,.png"
          // => HTML validation (client-side)
          required
        />

        <button type="submit">Upload</button>
      </form>

      {/* => Show upload result */}
      {result && (
        <div>
          {result.success ? (
            <>
              <p style={{ color: 'green' }}>{result.message}</p>
              <a href={result.fileUrl} target="_blank" rel="noopener noreferrer">
                View File
              </a>
            </>
          ) : (
            <p style={{ color: 'red' }}>{result.error}</p>
          )}
        </div>
      )}
    </div>
  );
}

Key Takeaway: Handle file uploads securely in Server Actions with type/size validation. Convert File to buffer, save to disk or cloud storage.

Expected Output: Form uploads file to server. Server validates type/size, saves to public/uploads/, returns URL for access.

Common Pitfalls: Not validating file types server-side (security risk), or storing files in wrong directory (not accessible publicly).

Group 9: Pagination & Infinite Scroll

Example 46: Pagination with Server Components

Implement pagination with Server Components and URL search params. SEO-friendly, works without JavaScript.

// app/posts/page.tsx
// => Server Component with pagination

type PageProps = {
  searchParams: { page?: string };          // => URL search params
};

const POSTS_PER_PAGE = 10;

export default async function PostsPage({ searchParams }: PageProps) {
  // => Get current page from URL (?page=2)
  const currentPage = parseInt(searchParams.page || '1');
  // => currentPage is 2 for ?page=2, default 1

  // => Fetch total count
  const totalPosts = 50;                    // => From database: await db.posts.count()

  // => Calculate pagination
  const totalPages = Math.ceil(totalPosts / POSTS_PER_PAGE);
  // => totalPages is 5 (50 posts / 10 per page)

  const offset = (currentPage - 1) * POSTS_PER_PAGE;
  // => offset is 10 for page 2 (skip first 10 posts)

  // => Fetch posts for current page
  const posts = Array.from({ length: POSTS_PER_PAGE }, (_, i) => ({
    id: offset + i + 1,
    title: `Post ${offset + i + 1}`,
  }));
  // => posts is posts 11-20 for page 2

  return (
    <div>
      <h1>Blog Posts</h1>

      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>

      {/* => Pagination controls */}
      <div style={{ display: 'flex', gap: '0.5rem', marginTop: '2rem' }}>
        {/* => Previous page link */}
        {currentPage > 1 && (
          <a href={`/posts?page=${currentPage - 1}`}>
            Previous
          </a>
        )}

        {/* => Page number links */}
        {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
          <a
            key={page}
            href={`/posts?page=${page}`}
            style={{
              fontWeight: page === currentPage ? 'bold' : 'normal',
              textDecoration: page === currentPage ? 'underline' : 'none',
            }}
          >
            {page}
          </a>
        ))}

        {/* => Next page link */}
        {currentPage < totalPages && (
          <a href={`/posts?page=${currentPage + 1}`}>
            Next
          </a>
        )}
      </div>

      <p>
        Page {currentPage} of {totalPages}
      </p>
    </div>
  );
}

Key Takeaway: Implement pagination with Server Components using URL search params. SEO-friendly (each page has unique URL), works without JavaScript.

Expected Output: Posts show 10 per page. Pagination controls navigate between pages. URL updates to /posts?page=2, /posts?page=3, etc.

Common Pitfalls: Not validating page parameter (could be negative or exceed max), or forgetting to handle empty results (page beyond limit).

Example 47: Infinite Scroll with Intersection Observer

Implement infinite scroll in Client Component using Intersection Observer API. Loads more content as user scrolls.

// app/posts/infinite/page.tsx
'use client';

import { useState, useEffect, useRef } from 'react';

export default function InfiniteScrollPage() {
  const [posts, setPosts] = useState<any[]>([]);
  const [page, setPage] = useState(1);
  const [hasMore, setHasMore] = useState(true);
  const [isLoading, setIsLoading] = useState(false);

  // => Ref to sentinel element (bottom of list)
  const sentinelRef = useRef<HTMLDivElement>(null);

  // => Fetch posts function
  async function fetchPosts(pageNum: number) {
    setIsLoading(true);

    // => Fetch from API
    const res = await fetch(`/api/posts?page=${pageNum}&limit=10`);
    const data = await res.json();
    // => data is { posts: [...], hasMore: boolean }

    // => Append new posts
    setPosts(prev => [...prev, ...data.posts]);
    setHasMore(data.hasMore);               // => More pages available?

    setIsLoading(false);
  }

  // => Intersection Observer effect
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;

    // => Create observer
    const observer = new IntersectionObserver(
      (entries) => {
        // => entries[0] is sentinel element
        const entry = entries[0];

        if (entry.isIntersecting && hasMore && !isLoading) {
          // => Sentinel visible, has more pages, not currently loading
          setPage(prev => prev + 1);        // => Increment page
          // => Triggers fetchPosts in separate effect
        }
      },
      {
        threshold: 0.1,                     // => Trigger when 10% visible
      }
    );

    // => Observe sentinel
    observer.observe(sentinel);

    // => Cleanup
    return () => observer.disconnect();
  }, [hasMore, isLoading]);

  // => Fetch posts when page changes
  useEffect(() => {
    fetchPosts(page);
  }, [page]);

  return (
    <div>
      <h1>Infinite Scroll Posts</h1>

      <ul>
        {posts.map((post, index) => (
          <li key={`${post.id}-${index}`}>
            {post.title}
          </li>
        ))}
      </ul>

      {/* => Sentinel element (invisible) */}
      <div ref={sentinelRef} style={{ height: '10px' }} />

      {/* => Loading indicator */}
      {isLoading && <p>Loading more posts...</p>}

      {/* => End message */}
      {!hasMore && <p>No more posts to load.</p>}
    </div>
  );
}

Key Takeaway: Implement infinite scroll with Intersection Observer API. Automatically loads more content when user scrolls to bottom.

Expected Output: Posts load 10 at a time. Scrolling to bottom automatically fetches next page. Loading indicator shows during fetch.

Common Pitfalls: Not disconnecting observer (memory leak), or triggering multiple fetches (check isLoading flag).

Group 10: Search & Filtering

Example 48: Search with Debouncing

Implement search with debouncing to reduce API calls. Waits for user to stop typing before searching.

// app/lib/hooks.ts
'use client';

import { useEffect, useState } from 'react';

// => Custom debounce hook
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    // => Set timeout to update debounced value
    const timer = setTimeout(() => {
      setDebouncedValue(value);
      // => Updates after delay (500ms)
    }, delay);

    // => Cleanup: cancel timeout if value changes
    return () => clearTimeout(timer);
    // => Prevents update if user still typing
  }, [value, delay]);

  return debouncedValue;
  // => Returns value only after user stops typing for 'delay' ms
}

// app/search/page.tsx
'use client';

import { useState, useEffect } from 'react';
import { useDebounce } from '../lib/hooks';

export default function SearchPage() {
  const [searchTerm, setSearchTerm] = useState('');
  // => searchTerm updates on every keystroke

  const debouncedSearchTerm = useDebounce(searchTerm, 500);
  // => debouncedSearchTerm updates 500ms after user stops typing

  const [results, setResults] = useState<any[]>([]);
  const [isSearching, setIsSearching] = useState(false);

  // => Search effect (triggers on debounced value)
  useEffect(() => {
    if (debouncedSearchTerm.length < 2) {
      // => Don't search for single characters
      setResults([]);
      return;
    }

    // => Perform search
    async function search() {
      setIsSearching(true);

      // => API call with debounced search term
      const res = await fetch(`/api/search?q=${encodeURIComponent(debouncedSearchTerm)}`);
      const data = await res.json();
      // => data is { results: [...] }

      setResults(data.results);
      setIsSearching(false);
    }

    search();
  }, [debouncedSearchTerm]);
  // => Only runs when debouncedSearchTerm changes (500ms after typing stops)

  return (
    <div>
      <h1>Search Posts</h1>

      <input
        type="search"
        placeholder="Search..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        // => Updates on every keystroke (fast)
        style={{ width: '100%', padding: '0.5rem' }}
      />

      {/* => Show searching indicator */}
      {isSearching && <p>Searching...</p>}

      {/* => Show results */}
      <ul>
        {results.map(result => (
          <li key={result.id}>
            <strong>{result.title}</strong>
            <p>{result.excerpt}</p>
          </li>
        ))}
      </ul>

      {/* => No results message */}
      {!isSearching && results.length === 0 && searchTerm.length >= 2 && (
        <p>No results found for "{searchTerm}"</p>
      )}
    </div>
  );
}

Key Takeaway: Use debouncing to reduce API calls during search. Waits for user to stop typing (500ms) before triggering search.

Expected Output: Typing updates input instantly. Search API called only after user stops typing for 500ms. Reduces API calls from dozens to one.

Common Pitfalls: Not cleaning up timers (memory leak), or setting debounce delay too long (feels unresponsive).

Example 49: Real-Time Updates with Server Actions

Use Server Actions for real-time updates without WebSocket complexity. Polling or manual refresh patterns.

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';

// => Simulated database (in-memory)
let donations: Array<{ id: number; name: string; amount: number; timestamp: Date }> = [];

export async function getDonations() {
  // => Server Action to fetch donations
  return donations;
}

export async function addDonation(formData: FormData) {
  const name = formData.get('name') as string;
  const amount = parseInt(formData.get('amount') as string);

  // => Add donation to "database"
  const donation = {
    id: Date.now(),
    name,
    amount,
    timestamp: new Date(),
  };
  donations.push(donation);

  // => Revalidate donations page
  revalidatePath('/donations/live');
  // => Any user viewing /donations/live sees updated list

  return { success: true };
}

// app/donations/live/page.tsx
// => Server Component showing live donations
import { getDonations } from '@/app/actions';

export const revalidate = 5;                // => Revalidate every 5 seconds
// => Page data refreshes automatically every 5 seconds

export default async function LiveDonationsPage() {
  const donations = await getDonations();   // => Fetch current donations

  return (
    <div>
      <h1>Live Donations</h1>
      <p>Updates every 5 seconds</p>

      <ul>
        {donations.map(donation => (
          <li key={donation.id}>
            <strong>{donation.name}</strong> donated IDR {donation.amount.toLocaleString()}
            <small> - {donation.timestamp.toLocaleTimeString()}</small>
          </li>
        ))}
      </ul>

      {donations.length === 0 && <p>No donations yet.</p>}
    </div>
  );
}

// app/donations/add/page.tsx
'use client';

import { addDonation } from '@/app/actions';
import { useRouter } from 'next/navigation';

export default function AddDonationPage() {
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    await addDonation(formData);
    // => Server Action adds donation and revalidates

    // => Redirect to live page
    router.push('/donations/live');
    // => Live page shows updated list
  }

  return (
    <div>
      <h1>Add Donation</h1>

      <form action={handleSubmit}>
        <input type="text" name="name" placeholder="Your name" required />
        <input type="number" name="amount" placeholder="Amount" required />
        <button type="submit">Donate</button>
      </form>
    </div>
  );
}

Key Takeaway: Use Server Actions with revalidatePath() for real-time updates. Simpler than WebSockets for most use cases. Page auto-refreshes every 5 seconds.

Expected Output: Live donations page shows current donations, auto-updates every 5 seconds. Adding donation immediately visible to all users viewing live page.

Common Pitfalls: Setting revalidate too low (server load), or not revalidating after mutations (users see stale data).

Example 50: Advanced Middleware with Custom Headers

Create advanced middleware patterns for redirects, headers, and conditional logic chains.

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const response = NextResponse.next();

  // => Pattern 1: Redirect old URLs to new structure
  if (pathname.startsWith("/old-blog")) {
    // => Permanent redirect (301)
    const newPath = pathname.replace("/old-blog", "/blog");
    return NextResponse.redirect(new URL(newPath, request.url), 301);
    // => Browser updates bookmarks, SEO passes to new URL
  }

  // => Pattern 2: Add custom headers based on path
  if (pathname.startsWith("/api/")) {
    // => API routes get CORS headers
    response.headers.set("Access-Control-Allow-Origin", "*");
    response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
    response.headers.set("Access-Control-Allow-Headers", "Content-Type");
  }

  // => Pattern 3: Conditional redirect based on cookie
  const subscription = request.cookies.get("subscription");
  if (pathname.startsWith("/premium") && subscription?.value !== "active") {
    // => No active subscription, redirect to pricing
    return NextResponse.redirect(new URL("/pricing", request.url));
  }

  // => Pattern 4: A/B testing with cookies
  if (pathname === "/pricing") {
    const variant = request.cookies.get("pricing_variant");

    if (!variant) {
      // => Assign random variant
      const newVariant = Math.random() > 0.5 ? "a" : "b";
      response.cookies.set("pricing_variant", newVariant, {
        maxAge: 60 * 60 * 24 * 30, // => 30 days
      });

      // => Rewrite to variant page
      if (newVariant === "b") {
        return NextResponse.rewrite(new URL("/pricing-variant-b", request.url));
      }
    } else if (variant.value === "b") {
      return NextResponse.rewrite(new URL("/pricing-variant-b", request.url));
    }
  }

  // => Pattern 5: Add security headers to all responses
  response.headers.set("X-Frame-Options", "DENY");
  response.headers.set("X-Content-Type-Options", "nosniff");
  response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
  response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");

  // => Pattern 6: Geo-based redirects (using Vercel geo headers)
  const country = request.geo?.country;
  if (pathname === "/" && country === "ID") {
    // => Indonesian visitors see Indonesian homepage
    return NextResponse.rewrite(new URL("/id", request.url));
  }

  return response;
}

export const config = {
  matcher: [
    // => Run on all paths except static files
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};

Key Takeaway: Middleware enables powerful request/response manipulation: redirects, rewrites, headers, cookies, A/B testing, geo-routing. Runs on every matching request.

Expected Output: Old URLs redirect to new structure. API routes get CORS headers. Premium content protected. A/B test variants assigned. Security headers added.

Common Pitfalls: Middleware runs on every request (keep it fast), or forgetting matcher config (runs on static files unnecessarily).

Summary

These 25 intermediate examples cover production-ready Next.js patterns:

Advanced Server Actions: useFormState (validation errors), useFormStatus (loading states), progressive enhancement (works without JavaScript)

Cache Revalidation: Time-based (ISR with revalidate), path-based (revalidatePath), tag-based (revalidateTag)

Route Organization: Route groups ((folder) syntax), parallel routes (@folder), intercepting routes ((.)folder for modals)

Advanced Forms: Zod validation (type-safe schemas), optimistic updates (useOptimistic), React Hook Form (complex forms), Zod transforms (data normalization), file uploads (secure handling)

Authentication: Cookies-based (httpOnly cookies), middleware-based (protect multiple routes)

Database Integration: Prisma ORM (type-safe queries), error handling (try-catch, Prisma error codes)

Client-Side Data Fetching: SWR (caching, revalidation), TanStack Query (advanced state management)

Pagination & Infinite Scroll: Server-side pagination (SEO-friendly), infinite scroll (Intersection Observer)

Search & Real-Time: Debounced search (reduce API calls), real-time updates (Server Actions with revalidation)

Advanced Middleware: Custom headers, redirects, A/B testing, geo-routing, security headers

Next: Advanced examples for performance optimization, advanced caching, streaming, and deployment patterns.

Last updated