Intermediate20 min1 prerequisite

Master data fetching in Next.js with Server Components, Server Actions, and API routes for AI development.

Data Fetching Patterns

Next.js provides multiple ways to fetch and mutate data. Understanding these patterns helps you work effectively with AI-generated code.

Server Component Data Fetching

Direct Async Fetching

The simplest approach—fetch directly in components:

Terminal
// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products')
  if (!res.ok) throw new Error('Failed to fetch')
  return res.json()
}

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

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  )
}

Database Queries

Access databases directly in Server Components:

Terminal
// app/users/page.tsx
import { prisma } from '@/lib/db'

export default async function UsersPage() {
  const users = await prisma.user.findMany({
    include: { posts: true },
    orderBy: { createdAt: 'desc' },
    take: 10,
  })

  return <UserList users={users} />
}

Parallel Fetching

Fetch multiple resources simultaneously:

Terminal
// app/dashboard/page.tsx
async function Dashboard() {
  // These run in parallel
  const [users, products, orders] = await Promise.all([
    getUsers(),
    getProducts(),
    getOrders(),
  ])

  return (
    <div>
      <UsersWidget users={users} />
      <ProductsWidget products={products} />
      <OrdersWidget orders={orders} />
    </div>
  )
}

Sequential Fetching

When data depends on previous fetches:

Terminal
// app/user/[id]/posts/page.tsx
async function UserPostsPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params

  // Must fetch user first
  const user = await getUser(id)

  // Then fetch their posts
  const posts = await getPostsByUser(user.id)

  return <PostList user={user} posts={posts} />
}

Caching and Revalidation

Default Caching

By default, fetch results are cached:

Terminal
// Cached indefinitely (static)
const data = await fetch('https://api.example.com/data')

// Opt out of caching
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})

// Revalidate every hour
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }
})

Segment-Level Revalidation

Terminal
// app/products/page.tsx

// Revalidate all data on this page every 60 seconds
export const revalidate = 60

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

On-Demand Revalidation

Revalidate when data changes:

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

import { revalidatePath, revalidateTag } from 'next/cache'

export async function createProduct(formData: FormData) {
  await db.product.create({
    data: { name: formData.get('name') as string }
  })

  // Revalidate the products page
  revalidatePath('/products')

  // Or revalidate by tag
  revalidateTag('products')
}
Terminal
// Tag a fetch for revalidation
const products = await fetch('https://api.example.com/products', {
  next: { tags: ['products'] }
})

Server Actions

Basic Server Action

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

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

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string

  await db.user.create({
    data: { name, email }
  })

  revalidatePath('/users')
  redirect('/users')
}

Using in Forms

Terminal
// app/signup/page.tsx
import { createUser } from './actions'

export default function SignupPage() {
  return (
    <form action={createUser}>
      <input name="name" placeholder="Name" required />
      <input name="email" type="email" required />
      <button type="submit">Sign Up</button>
    </form>
  )
}

With Validation

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

import { z } from 'zod'

const userSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
})

export async function createUser(formData: FormData) {
  const rawData = {
    name: formData.get('name'),
    email: formData.get('email'),
  }

  const result = userSchema.safeParse(rawData)

  if (!result.success) {
    return { error: result.error.flatten() }
  }

  await db.user.create({ data: result.data })
  revalidatePath('/users')
}

Client-Side Usage

Terminal
// components/CreateUserForm.tsx
'use client'

import { useTransition } from 'react'
import { createUser } from '@/app/actions'

export function CreateUserForm() {
  const [isPending, startTransition] = useTransition()

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      await createUser(formData)
    })
  }

  return (
    <form action={handleSubmit}>
      <input name="name" disabled={isPending} />
      <input name="email" disabled={isPending} />
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Create User'}
      </button>
    </form>
  )
}

useActionState Pattern

For better form state management:

Terminal
'use client'

import { useActionState } from 'react'
import { createUser } from '@/app/actions'

export function SignupForm() {
  const [state, formAction, isPending] = useActionState(
    createUser,
    { error: null }
  )

  return (
    <form action={formAction}>
      <input name="name" />
      <input name="email" />
      {state?.error && <p className="text-red-500">{state.error}</p>}
      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Sign Up'}
      </button>
    </form>
  )
}

API Routes

Basic Route Handler

Terminal
// app/api/products/route.ts
import { NextResponse } from 'next/server'

export async function GET() {
  const products = await db.product.findMany()
  return NextResponse.json(products)
}

export async function POST(request: Request) {
  const body = await request.json()

  const product = await db.product.create({
    data: body
  })

  return NextResponse.json(product, { status: 201 })
}

Dynamic Route Handler

Terminal
// app/api/products/[id]/route.ts
import { NextResponse } from 'next/server'

export async function GET(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const product = await db.product.findUnique({
    where: { id }
  })

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

  return NextResponse.json(product)
}

export async function PATCH(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  const body = await request.json()

  const product = await db.product.update({
    where: { id },
    data: body
  })

  return NextResponse.json(product)
}

export async function DELETE(
  request: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  await db.product.delete({
    where: { id }
  })

  return new NextResponse(null, { status: 204 })
}

Next.js 15+ Change: Route handler params are now Promises and must be awaited.

Query Parameters

Terminal
// app/api/products/route.ts
import { NextRequest, NextResponse } from 'next/server'

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const category = searchParams.get('category')
  const limit = parseInt(searchParams.get('limit') || '10')

  const products = await db.product.findMany({
    where: category ? { category } : undefined,
    take: limit,
  })

  return NextResponse.json(products)
}

Choosing the Right Approach

When to Use Each

Use CaseApproach
Load page dataServer Component fetch
Form submissionServer Action
Complex mutationsServer Action
Third-party integrationAPI Route
WebhooksAPI Route
Public APIAPI Route
Client-side updatesAPI Route + fetch

Server Actions vs API Routes

Prefer Server Actions when:

  • Submitting forms
  • Mutations from your own app
  • Revalidating after changes
  • Simpler code, less boilerplate

Use API Routes when:

  • External apps need access
  • Complex request/response handling
  • Webhooks from third parties
  • File uploads
  • Streaming responses

Loading and Error States

Streaming with Suspense

Terminal
// app/products/page.tsx
import { Suspense } from 'react'

export default function ProductsPage() {
  return (
    <main>
      <h1>Products</h1>
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />
      </Suspense>
    </main>
  )
}

async function ProductList() {
  const products = await getProducts() // Slow operation
  return (
    <ul>
      {products.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

Error Handling

Terminal
// app/products/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

AI-Generated Patterns

Common AI Patterns

Terminal
// AI often generates this full-stack pattern:

// 1. Types
// types/product.ts
export interface Product {
  id: string
  name: string
  price: number
}

// 2. Server Action
// app/actions/products.ts
'use server'
export async function createProduct(data: Omit<Product, 'id'>) {
  return db.product.create({ data })
}

// 3. Form Component
// components/ProductForm.tsx
'use client'
export function ProductForm() {
  // ...uses createProduct action
}

// 4. Page
// app/products/new/page.tsx
export default function NewProductPage() {
  return <ProductForm />
}

Prompting for Data Patterns

Terminal
"Create a Server Action for user registration with:
- Zod validation
- Password hashing
- Error handling
- Redirect on success"
Terminal
"Build an API route for products that supports:
- GET with pagination and filtering
- POST with validation
- Proper error responses"

Summary

  • Server Components: Fetch directly with async/await
  • Server Actions: Mutations from forms, call with useTransition
  • API Routes: External access, webhooks, complex handling
  • Caching: Automatic with manual revalidation options
  • Suspense: Stream content with loading states
  • Error boundaries: Handle failures gracefully

Next Steps

Learn how to deploy your Next.js application to production in the final lesson.

Mark this lesson as complete to track your progress