- Learn
- Stack Essentials
- Next.js
- Data Fetching Patterns
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
paramsare 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 Case | Approach |
|---|---|
| Load page data | Server Component fetch |
| Form submission | Server Action |
| Complex mutations | Server Action |
| Third-party integration | API Route |
| Webhooks | API Route |
| Public API | API Route |
| Client-side updates | API 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