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 devThree conventions apply to every example below:
- 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. - Server Components can be async --
await fetch(...)directly in the component body. - 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 --
awaitworks like it does in a regular Node handler. - Next.js deduplicates identical
fetchcalls 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
useEffectas 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
awaitcalls create a waterfall -- each request waits for the previous to finish. Promise.allkicks all requests off at once; total time is the slowest request.- Use
Promise.allSettledwhen 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
FormDataargument 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 yourfetchcalls withnext: { tags: ["posts"] }. - Time-based ISR uses
next: { revalidate: 60 }on thefetchitself -- pick seconds as needed. - Do not call
revalidatePathfrom 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.tsxis a conventional file -- Next wraps the siblingpage.tsxin 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 withrevalidateTagto invalidate on demand.next.revalidatesets a time-based expiration in seconds -- good for data that can be slightly stale.next.tagslets you invalidate a group of fetches with onerevalidateTagcall 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,
searchParamsis a Promise -- alwaysawaitit before reading keys. - Values are always
string | string[] | undefined-- parse numbers and booleans yourself. - For Client Components, use
useSearchParams()fromnext/navigationinstead. - Changing
searchParamsre-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()andheaders()are async in Next.js 15 -- alwaysawaitthem. - 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 viax-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
Dashboardis a sync function; the async work lives in its children. - Combine with
Promise.allinside 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
hasMoreflag, or for streaming large data sets. - Stop early with
breakto 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
fetcherin one utility so auth headers and error handling stay consistent. - Use SWR's
mutateto 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]-- wireactioninto<form action={...}>.- The action's return value becomes the next
state-- use it for validation errors and success messages. revalidatePathinvalidates the cached route so the next render reflects the new data.- Disable the submit button on
isPendingto 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