Next.js Data Basics
12 examples to get you started with Next.js Data -- 8 basic and 4 intermediate.
Search across all documentation pages
12 examples to get you started with Next.js Data -- 8 basic and 4 intermediate.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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:
app/ run on the server by default. Add "use client" at the top only when you need interactivity, browser APIs, or React state.await fetch(...) directly in the component body."use server", callable from Client Components and <form action={...}>.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>
);
}await works like it does in a regular Node handler.fetch calls within a single request so you can call the same endpoint from multiple components.useEffect as your default.Related: Data Fetching -- patterns, deduplication, error handling | Server Components -- what runs where
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>
);
}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.Promise.allSettled when one failure should not reject the whole set.Related: Parallel Promises --
Promise.all,allSettled, and waterfall elimination | Streaming -- parallel fetches with per-panel Suspense
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>
);
}<form action={...}> works even without JavaScript -- progressive enhancement for free.FormData argument comes from the form's named inputs automatically.Related: Server Actions (Next.js) -- patterns, errors, redirects | Server Actions (React 19) -- the underlying primitive | Server Action Forms -- end-to-end form flow
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.revalidateTag("posts") and tag your fetch calls with next: { tags: ["posts"] }.next: { revalidate: 60 } on the fetch itself -- pick seconds as needed.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
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.<Suspense> boundaries instead.Related: Streaming & Suspense -- loading.tsx, Suspense, skeletons | Suspense patterns -- when and how to split boundaries
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"] },
});"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
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>;
}searchParams is a Promise -- always await it before reading keys.string | string[] | undefined -- parse numbers and booleans yourself.useSearchParams() from next/navigation instead.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
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>
);
}cookies() and headers() are async in Next.js 15 -- always await them.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
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>
);
}<Suspense> boundary streams independently -- the fast panel does not wait on the slow one.Dashboard is a sync function; the async work lives in its children.Promise.all inside a boundary when several fetches belong to the same panel.Related: Streaming -- boundary placement, skeletons, errors | Parallel Promises -- batching fetches inside a boundary
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.hasMore flag, or for streaming large data sets.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
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>;
}fetcher in one utility so auth headers and error handling stay consistent.mutate to optimistically update the cache before the server confirms.Related: SWR Fetch Utility -- centralized client, auth, error handling | SWR Basic Fetching -- keys, fetchers, config
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={...}>.state -- use it for validation errors and success messages.revalidatePath invalidates the cached route so the next render reflects the new data.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
Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥