Week 11

App Router

Data Fetching and Mutations

Migrating from React Router

Going to production

Rendering Strategies

Practice

Assignment

Front end Track

Fetching Data in Server Components

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>

Fetching in Nested Components

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>

Server Functions

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.

A basic form with a Server Function

// 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>

Organising actions in a separate file

// 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>
  )
}

Redirecting after a mutation

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>

Showing a pending state with useFormStatus

By 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 Mutation

After 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>

When to Use a Server Function vs Fetching in a Server Component

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

Example: a page that reads and writes

// 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>


Additional Resources

Reading


The HackYourFuture curriculum is licensed under CC BY-NC-SA 4.0 *https://hackyourfuture.net/*

CC BY-NC-SA 4.0 Icons

Built with ❤️ by the HackYourFuture community · Thank you, contributors

Found a mistake or have a suggestion? Let us know in the feedback form.