React SME Cookbook
All FAQs
basicsnextjs-dataexamplesserver-components

Next.js Data Basics

12 examples to get you started with Next.js Data -- 8 basic and 4 intermediate.

Prerequisites

All examples assume a Next.js 15+ App Router project with TypeScript. If you do not have one yet:

npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm run dev

Three conventions apply to every example below:

  1. Files under app/ run on the server by default. Add "use client" at the top only when you need interactivity, browser APIs, or React state.
  2. Server Components can be async -- await fetch(...) directly in the component body.
  3. Server Actions are functions tagged with "use server", callable from Client Components and <form action={...}>.

Basic Examples

1. Fetch in a Server Component

Call fetch directly inside an async Server Component -- no useEffect, no loading hooks.

// app/posts/page.tsx
interface Post {
  id: number;
  title: string;
}
 
export default async function PostsPage() {
  const res = await fetch("https://api.example.com/posts");
  const posts: Post[] = await res.json();
 
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
  • Server Components can be async functions -- await works like it does in a regular Node handler.
  • Next.js deduplicates identical fetch calls within a single request so you can call the same endpoint from multiple components.
  • The data never ships to the client -- only the rendered HTML does.
  • For Client Components, fetch via SWR or TanStack Query; never via useEffect as your default.

Related: Data Fetching -- patterns, deduplication, error handling | Server Components -- what runs where


2. Parallel Fetches with Promise.all

Fire requests in parallel so the slowest one sets the total wait time, not the sum.

// app/dashboard/page.tsx
interface User {
  name: string;
}
interface Stats {
  total: number;
}
 
async function getUser(): Promise<User> {
  return (await fetch("https://api.example.com/me")).json();
}
 
async function getStats(): Promise<Stats> {
  return (await fetch("https://api.example.com/stats")).json();
}
 
export default async function Dashboard() {
  const [user, stats] = await Promise.all([getUser(), getStats()]);
  return (
    <h1>
      {user.name} -- {stats.total} items
    </h1>
  );
}
  • Sequential await calls create a waterfall -- each request waits for the previous to finish.
  • Promise.all kicks all requests off at once; total time is the slowest request.
  • Use Promise.allSettled when one failure should not reject the whole set.
  • For parallel work across the tree, prefer Suspense boundaries so fast panels stream in first.

Related: Parallel Promises -- Promise.all, allSettled, and waterfall elimination | Streaming -- parallel fetches with per-panel Suspense


3. Server Action for Mutation

Define a server-only function with "use server" and wire it straight to a form.

// app/posts/actions.ts
"use server";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  await fetch("https://api.example.com/posts", {
    method: "POST",
    body: JSON.stringify({ title }),
  });
}
 
// app/posts/form.tsx
import { createPost } from "./actions";
 
export default function PostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}
  • Server Actions run on the server -- they never ship to the client, so secrets and DB clients stay safe.
  • Wiring one to <form action={...}> works even without JavaScript -- progressive enhancement for free.
  • The FormData argument comes from the form's named inputs automatically.
  • Always re-validate auth and authorization inside the action -- never trust the caller.

Related: Server Actions (Next.js) -- patterns, errors, redirects | Server Actions (React 19) -- the underlying primitive | Server Action Forms -- end-to-end form flow


4. Revalidate a Path After Mutation

Bust the cache for a specific route after writing data so the next render is fresh.

// app/posts/actions.ts
"use server";
import { revalidatePath } from "next/cache";
 
export async function createPost(formData: FormData) {
  const title = formData.get("title") as string;
  await fetch("https://api.example.com/posts", {
    method: "POST",
    body: JSON.stringify({ title }),
  });
  revalidatePath("/posts");
}
  • revalidatePath(path) marks any cache entry tied to that route as stale -- the next visit re-fetches.
  • For finer control across routes, use revalidateTag("posts") and tag your fetch calls with next: { tags: ["posts"] }.
  • Time-based ISR uses next: { revalidate: 60 } on the fetch itself -- pick seconds as needed.
  • Do not call revalidatePath from a Server Component body -- only from Server Actions or Route Handlers.

Related: Revalidation -- revalidatePath, revalidateTag, ISR | Caching -- what gets cached and for how long


5. Stream a Route with loading.tsx

Drop in a loading.tsx file to show a fallback while the route's Server Component streams.

// app/posts/loading.tsx
export default function Loading() {
  return <p>Loading posts...</p>;
}
 
// app/posts/page.tsx
interface Post {
  id: number;
  title: string;
}
 
export default async function PostsPage() {
  const res = await fetch("https://api.example.com/posts");
  const posts: Post[] = await res.json();
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}
  • loading.tsx is a conventional file -- Next wraps the sibling page.tsx in a Suspense boundary automatically.
  • The fallback streams to the browser immediately; the real content replaces it when ready.
  • Use skeletons matching the final layout to avoid layout shift when content arrives.
  • For finer-grained loading (one slow panel, not the whole route), use explicit <Suspense> boundaries instead.

Related: Streaming & Suspense -- loading.tsx, Suspense, skeletons | Suspense patterns -- when and how to split boundaries


6. fetch Cache Options

Next.js extends fetch with cache controls -- pick the one that matches how fresh the data needs to be.

// Fully cached across requests (opt-in in Next 15)
const cached = await fetch("https://api.example.com/data", {
  cache: "force-cache",
});
 
// Always fresh, never cached
const fresh = await fetch("https://api.example.com/data", {
  cache: "no-store",
});
 
// Regenerate in the background every 60 seconds (ISR)
const isr = await fetch("https://api.example.com/data", {
  next: { revalidate: 60 },
});
 
// Tag for on-demand revalidation
const tagged = await fetch("https://api.example.com/data", {
  next: { tags: ["posts"] },
});
  • In Next.js 15, the default is "no-store" -- you must opt in to caching, unlike Next 14.
  • cache: "force-cache" caches indefinitely; pair with revalidateTag to invalidate on demand.
  • next.revalidate sets a time-based expiration in seconds -- good for data that can be slightly stale.
  • next.tags lets you invalidate a group of fetches with one revalidateTag call from an action.

Related: Caching -- the four cache layers in depth | Revalidation -- on-demand invalidation


7. Read searchParams in a Server Page

Pages receive URL query parameters as an async prop -- no router hook required.

// app/search/page.tsx
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string; page?: string }>;
}) {
  const { q = "", page = "1" } = await searchParams;
 
  const res = await fetch(
    `https://api.example.com/search?q=${q}&page=${page}`
  );
  const results = await res.json();
 
  return <pre>{JSON.stringify(results, null, 2)}</pre>;
}
  • In Next.js 15, searchParams is a Promise -- always await it before reading keys.
  • Values are always string | string[] | undefined -- parse numbers and booleans yourself.
  • For Client Components, use useSearchParams() from next/navigation instead.
  • Changing searchParams re-runs the Server Component -- no explicit refetch needed.

Related: Search Params -- server vs. client, setting params, typed parsers | Navigation -- updating the URL from links and actions


8. cookies() and headers()

Read request headers and cookies on the server with the async APIs from next/headers.

// app/me/page.tsx
import { cookies, headers } from "next/headers";
 
export default async function MePage() {
  const cookieStore = await cookies();
  const headerStore = await headers();
 
  const theme = cookieStore.get("theme")?.value ?? "light";
  const userAgent = headerStore.get("user-agent");
 
  return (
    <p>
      Theme: {theme} | UA: {userAgent}
    </p>
  );
}
  • Both cookies() and headers() are async in Next.js 15 -- always await them.
  • Reading either opts the route into dynamic rendering -- it can no longer be statically cached.
  • Setting cookies only works from a Server Action or a Route Handler, not from a Server Component's render.
  • Use headers() for request metadata (UA, IP via x-forwarded-for, locale); do not mutate headers from here.

Related: Cookies & Headers -- reading, setting, auth patterns | Server Actions -- where to mutate cookies


Intermediate Examples

9. Per-Panel Streaming with Suspense

Wrap slow sub-trees in <Suspense> so the fast parts render first and the slow parts stream in.

// app/dashboard/page.tsx
import { Suspense } from "react";
 
async function FastPanel() {
  const data = await fetch("https://api.example.com/fast").then((r) =>
    r.json()
  );
  return <p>Fast: {data.msg}</p>;
}
 
async function SlowPanel() {
  const data = await fetch("https://api.example.com/slow").then((r) =>
    r.json()
  );
  return <p>Slow: {data.msg}</p>;
}
 
export default function Dashboard() {
  return (
    <div>
      <Suspense fallback={<p>Loading fast panel...</p>}>
        <FastPanel />
      </Suspense>
      <Suspense fallback={<p>Loading slow panel...</p>}>
        <SlowPanel />
      </Suspense>
    </div>
  );
}
  • Each <Suspense> boundary streams independently -- the fast panel does not wait on the slow one.
  • The outer Dashboard is a sync function; the async work lives in its children.
  • Combine with Promise.all inside a boundary when several fetches belong to the same panel.
  • Avoid a single large boundary for a whole route -- the user waits for the slowest leaf.

Related: Streaming -- boundary placement, skeletons, errors | Parallel Promises -- batching fetches inside a boundary


10. Async Generator for a Paginated API

Consume a paginated endpoint lazily with async function* and for await -- no manual page-tracking in the caller.

// app/posts/all/page.tsx
interface Post {
  id: number;
  title: string;
}
 
async function* paginatedPosts(): AsyncGenerator<Post> {
  let page = 1;
  while (true) {
    const res = await fetch(
      `https://api.example.com/posts?page=${page}`
    );
    const { items, hasMore } = (await res.json()) as {
      items: Post[];
      hasMore: boolean;
    };
    for (const item of items) yield item;
    if (!hasMore) return;
    page++;
  }
}
 
export default async function AllPostsPage() {
  const all: Post[] = [];
  for await (const post of paginatedPosts()) {
    all.push(post);
    if (all.length >= 100) break;
  }
  return <p>{all.length} posts loaded</p>;
}
  • async function* yields values one at a time -- the caller drives when to pull the next page.
  • for await (... of ...) consumes the generator until it returns or the loop breaks.
  • Works great for APIs that return a cursor or hasMore flag, or for streaming large data sets.
  • Stop early with break to cap memory use -- the generator just stops being pulled.

Related: Async Generators -- patterns, cancellation, Route Handlers | Streaming -- pairing with Suspense for progressive UI


11. Client-Side SWR Fetching

When you need real-time updates, user-initiated refetches, or infinite scroll, fetch from a Client Component with SWR.

"use client";
import useSWR from "swr";
 
interface User {
  id: string;
  name: string;
}
 
const fetcher = (url: string) => fetch(url).then((r) => r.json());
 
export default function UserCard({ id }: { id: string }) {
  const { data, error, isLoading } = useSWR<User>(`/api/users/${id}`, fetcher);
 
  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Failed to load</p>;
  if (!data) return null;
 
  return <h2>{data.name}</h2>;
}
  • SWR gives you stale-while-revalidate: instant data from cache, then a background refresh.
  • Centralize the fetcher in one utility so auth headers and error handling stay consistent.
  • Use SWR's mutate to optimistically update the cache before the server confirms.
  • Default to Server Components for data fetching -- reach for SWR only when the client genuinely needs it.

Related: SWR Fetch Utility -- centralized client, auth, error handling | SWR Basic Fetching -- keys, fetchers, config


12. Server Action with useActionState

Combine a server action, useActionState, and revalidatePath for a form that handles pending state, validation errors, and cache busting in one flow.

// app/contact/actions.ts
"use server";
import { revalidatePath } from "next/cache";
 
type State = { ok: boolean; message: string };
 
export async function submitContact(
  _prev: State | null,
  formData: FormData
): Promise<State> {
  const email = formData.get("email") as string;
  if (!email.includes("@")) {
    return { ok: false, message: "Invalid email" };
  }
  await fetch("https://api.example.com/contacts", {
    method: "POST",
    body: JSON.stringify({ email }),
  });
  revalidatePath("/contact");
  return { ok: true, message: "Thanks!" };
}
 
// app/contact/form.tsx
"use client";
import { useActionState } from "react";
import { submitContact } from "./actions";
 
export default function ContactForm() {
  const [state, action, isPending] = useActionState(submitContact, null);
 
  return (
    <form action={action}>
      <input name="email" type="email" />
      <button type="submit" disabled={isPending}>
        {isPending ? "Sending..." : "Send"}
      </button>
      {state && <p>{state.message}</p>}
    </form>
  );
}
  • useActionState(action, initialState) returns [state, action, isPending] -- wire action into <form action={...}>.
  • The action's return value becomes the next state -- use it for validation errors and success messages.
  • revalidatePath invalidates the cached route so the next render reflects the new data.
  • Disable the submit button on isPending to prevent double-submits and accidental retries.

Related: Server Actions -- full action patterns and error handling | useActionState -- the hook API | Server Action Forms -- complete form flows