Because Server Components run on the server, you can fetch data directly in the component body using async/await. No useEffect, no loading state, no separate API call - just await the data you need.
// app/products/page.tsx
export default async function ProductsPage() {
const response = await fetch('<https://api.example.com/products>')
const products = await response.json()
return (
<ul>
{products.map((product: { id: string; name: string; price: number }) => (
<li key={product.id}>
{product.name} - €{product.price}
</li>
))}
</ul>
)
}
The component awaits the fetch, then renders. Next.js runs this on the server, streams the HTML to the browser, and the user sees real content immediately.
<aside> 💡
Next.js extends the native fetch API with caching options. By default, fetch in Server Components is cached. You can opt out with { cache: 'no-store' } for always-fresh data, or use { next: { revalidate: 60 } } to re-fetch every 60 seconds.
</aside>
<aside> ⌨️
Hands on: Create a src/app/posts/page.tsx that fetches from https://jsonplaceholder.typicode.com/posts?_limit=5 and renders the post titles in a list. This is a real public API - no auth needed. Confirm the fetch happens on the server by checking your terminal (not the browser console) for any logs you add.
</aside>
You don't have to fetch everything in the page. Child components can be async Server Components too, and each can fetch exactly what it needs. Next.js runs them in parallel where possible.
// app/dashboard/page.tsx
import { UserStats } from './UserStats'
import { RecentOrders } from './RecentOrders'
export default function DashboardPage() {
return (
<div>
<UserStats /> {/* fetches /api/stats */}
<RecentOrders /> {/* fetches /api/orders */}
</div>
)
}
// app/dashboard/UserStats.tsx
export async function UserStats() {
const stats = await fetch('<https://api.example.com/stats>').then(r => r.json())
return <div>Total orders: {stats.totalOrders}</div>
}
Wrap slower components in <Suspense> so faster ones don't have to wait:
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<UserStats />
<Suspense fallback={<p>Loading orders...</p>}>
<RecentOrders />
</Suspense>
</div>
)
}
<aside> ⌨️
Hands on: Create two async Server Components: one that fetches from https://jsonplaceholder.typicode.com/users/1 and one that fetches from https://jsonplaceholder.typicode.com/posts/1. To simulate a slow component, add await new Promise(r => setTimeout(r, 1500)) before fetching the posts. Wrap it with <Suspense fallback={<p>Loading...</p>}> and render both on the same page. Notice how the fast component renders immediately while the slow one streams in.
</aside>
For mutations - creating, updating, or deleting data - you use Server Functions. These are async functions marked with the "use server" directive that run on the server and can be called directly from the client.
The most important use case is forms.
// app/contact/page.tsx
export default function ContactPage() {
async function sendMessage(formData: FormData) {
"use server" // making this function a Server Action
const name = formData.get('name') as string
const message = formData.get('message') as string
await saveToDatabase({ name, message })
}
return (
<form action={sendMessage}>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit">Send</button>
</form>
)
}
When the form is submitted, Next.js calls sendMessage on the server with the form's FormData. The page never needs to manage submit state or make an explicit API call.
<aside> 💡
The "use server" directive can go inside the function body (for inline actions) or at the top of a file (marking all exports in that file as Server Functions). Putting it in a separate file is common when you want to reuse the action across multiple pages.
</aside>
// app/contact/actions.ts
"use server"
export async function sendMessage(formData: FormData) {
const name = formData.get('name') as string
const message = formData.get('message') as string
await saveToDatabase({ name, message })
}
export async function deleteMessage(id: string) {
await db.delete('messages', id)
}
// app/contact/page.tsx
import { sendMessage } from './actions'
export default function ContactPage() {
return (
<form action={sendMessage}>
{/* ... */}
</form>
)
}
After a successful write, you often want to send the user somewhere else. Use redirect from next/navigation inside the Server Function:
// app/contact/actions.ts
"use server"
import { redirect } from 'next/navigation'
export async function sendMessage(formData: FormData) {
const name = formData.get('name') as string
const message = formData.get('message') as string
await saveToDatabase({ name, message })
redirect('/contact/thank-you') // navigates the user after the action completes
}
<aside> 💡
redirect() throws a special internal error that Next.js catches and uses to perform the navigation. Don't wrap it in a try/catch block - if you do, the redirect will be silently swallowed.
</aside>
useFormStatusBy default a form just waits silently while the Server Function runs. To disable the submit button and show feedback, use useFormStatus from React DOM in a Client Component:
// components/SubmitButton.tsx
"use client"
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send'}
</button>
)
}
// app/contact/page.tsx
import { sendMessage } from './actions'
import { SubmitButton } from '@/components/SubmitButton'
export default function ContactPage() {
return (
<form action={sendMessage}>
<input name="name" required />
<textarea name="message" required />
<SubmitButton />
</form>
)
}
<aside> 💡
useFormStatus must be used inside a component that is a child of the <form> - that's why it lives in a separate SubmitButton component rather than the page itself.
</aside>
<aside> ⌨️
Hands on: Build a contact form page at src/app/contact/page.tsx with name and message fields. Create a Server Function in src/app/contact/actions.ts that logs the form data to the server console and then calls redirect('/contact/thank-you'). Create the thank-you page. Then add a SubmitButton component using useFormStatus that shows "Sending..." while the action is running to test the whole flow.
</aside>
revalidatePath - Refreshing Cached Data After a MutationAfter a mutation, the cached data on the page is stale. Call revalidatePath to tell Next.js to invalidate the cache for a specific path, so the next visit re-fetches fresh data.
// app/products/actions.ts
"use server"
import { revalidatePath } from 'next/cache'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = Number(formData.get('price'))
await db.insert('products', { name, price })
revalidatePath('/products') // clears the cache for /products
}
Without revalidatePath, a user adding a new product wouldn't see it in the list until they hard-refreshed the page. With it, the next navigation to /products fetches fresh data automatically.
revalidatePath only works inside Server Functions and Route Handlers. You can also use revalidateTag if you've tagged your fetches, which gives more fine-grained control.
<aside> ⌨️
Hands on: To see caching in action, create a page that fetches https://jsonplaceholder.typicode.com/posts/1 with the default caching. Add a form with a Server Function that calls revalidatePath on that page's path. Notice that without revalidatePath the page content doesn't change between navigations (it serves cached HTML), but after calling revalidatePath the next load re-fetches. You can observe this in the Next.js dev server terminal output.
</aside>
This is a common point of confusion. Here's the rule:
| Scenario | Use |
|---|---|
| Reading data to display on a page | async Server Component with fetch or DB call in the body |
| Mutating data (create, update, delete) | Server Function called from a <form action> |
| Mutations triggered by a button (not a form) | Server Function called from a Client Component |
| Fetching data that depends on user interaction (search, filters) | Server Function called from a Client Component, or a URL-based approach with searchParams |
// app/todos/store.ts ← in-memory store
type Todo = { id: string; title: string }
export const todos: Todo[] = []
// app/todos/page.tsx
import { addTodo } from './actions'
import { todos } from './store'
export default async function TodosPage() {
return (
<div>
<ul>
{todos.map(todo => <li key={todo.id}>{todo.title}</li>)}
</ul>
{/* Writing: use a Server Function */}
<form action={addTodo}>
<input name="title" placeholder="New todo" required />
<button type="submit">Add</button>
</form>
</div>
)
}
// app/todos/actions.ts
"use server"
import { revalidatePath } from 'next/cache'
export async function addTodo(formData: FormData) {
const title = formData.get('title') as string
todos.push({ id: crypto.randomUUID(), title })
revalidatePath('/todos')
}
<aside> ⌨️
Hands on: Build the todo page above. Then add a deleteTodo Server Function in actions.ts that takes an id: string, removes the matching item from the todos array, and calls revalidatePath('/todos'). Wire it up to a delete button next to each todo item. Because the button needs an onClick handler, put it in a Client Component - create components/DeleteButton.tsx with "use client", import deleteTodo directly, and call it with the todo's id. Render the DeleteButton next to each todo item.
</aside>
The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

Built with ❤️ by the HackYourFuture community · Thank you, contributors
Found a mistake or have a suggestion? Let us know in the feedback form.