Data Fetching & Caching
Data Fetching in Next.js
Next.js rewrites the rules for data fetching. Instead of useEffect + loading spinner in the browser, you fetch data directly in your Server Components using plain async/await. The data arrives with the HTML — no extra round trips, no loading flicker, no hydration mismatch.
Fetching in Server Components
The simplest possible approach:
// src/app/courses/page.tsx
async function getCourses() {
const res = await fetch("https://api.example.com/courses");
if (!res.ok) throw new Error("Failed to fetch courses");
return res.json();
}
export default async function CoursesPage() {
const courses = await getCourses(); // runs on the server
return (
<ul>
{courses.map((course: any) => (
<li key={course.id}>{course.title}</li>
))}
</ul>
);
}
The await is at the component level — something impossible in Client Components. When the request comes in, Next.js runs this function, waits for the data, renders the HTML, and sends it down.
The Fetch Cache
Next.js extends the native fetch API with caching options:
// Default: cached indefinitely (equivalent to SSG)
const res = await fetch("https://api.example.com/courses");
// No cache — fresh data on every request (like SSR)
const res = await fetch("https://api.example.com/live-prices", {
cache: "no-store",
});
// Revalidate every 60 seconds
const res = await fetch("https://api.example.com/products", {
next: { revalidate: 60 },
});
// Tag-based revalidation — useful for on-demand refreshes
const res = await fetch("https://api.example.com/courses", {
next: { tags: ["courses"] },
});
Pick based on how fresh your data needs to be:
- Blog posts, course content, static pages → default (cached)
- Product catalog →
revalidate: 300(every 5 minutes) - Stock prices, live scores →
cache: "no-store"(every request)
Fetching from a Database
You're not limited to fetch. Anything async works — database queries, file reads, ORM calls:
// src/app/courses/page.tsx
import { db } from "@/lib/db"; // your database client (Prisma, Drizzle, etc.)
export default async function CoursesPage() {
const courses = await db.course.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, title: true, slug: true, lessonCount: true },
});
return (
<div>
{courses.map(course => (
<CourseCard key={course.id} course={course} />
))}
</div>
);
}
Database secrets stay on the server. They never reach the browser. This is a major security advantage.
Parallel Data Fetching
When a component needs multiple independent pieces of data, fetch them in parallel with Promise.all:
export default async function CoursePage({ params }: { params: { slug: string } }) {
// ❌ Sequential — each waits for the previous
const course = await getCourse(params.slug);
const reviews = await getReviews(params.slug);
const related = await getRelatedCourses(params.slug);
// ✅ Parallel — all three start simultaneously
const [course, reviews, related] = await Promise.all([
getCourse(params.slug),
getReviews(params.slug),
getRelatedCourses(params.slug),
]);
return (
<div>
<h1>{course.title}</h1>
<RelatedCourses courses={related} />
<Reviews reviews={reviews} />
</div>
);
}
Loading States with loading.tsx
Create a loading.tsx file next to your page.tsx to show a skeleton while the page data loads:
// src/app/courses/loading.tsx
export default function CoursesLoading() {
return (
<div className="grid grid-cols-3 gap-6">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-white rounded-xl border border-gray-200 p-6 animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="h-3 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
))}
</div>
);
}
Next.js automatically shows this file while the page data loads, then replaces it with the real content using React Suspense under the hood.
Streaming with Suspense
For granular loading — show part of the page immediately while expensive parts stream in:
import { Suspense } from "react";
export default function CoursePage({ params }: { params: { slug: string } }) {
return (
<div>
{/* This renders immediately — no data needed */}
<CourseHeader slug={params.slug} />
{/* This streams in when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews slug={params.slug} />
</Suspense>
{/* This streams independently */}
<Suspense fallback={<RecommendedSkeleton />}>
<RecommendedCourses slug={params.slug} />
</Suspense>
</div>
);
}
// Reviews fetches its own data
async function Reviews({ slug }: { slug: string }) {
const reviews = await getReviews(slug); // slow query
return <ReviewList reviews={reviews} />;
}
The header and page structure render immediately. Reviews and recommendations stream in as they become available. Users see content faster without waiting for the slowest query.
Client-Side Fetching
Sometimes you still want client-side fetching — for data that should update after user interactions, or that depends on client state. Use the standard fetch in a useEffect or, better, reach for SWR:
npm install swr
"use client";
import useSWR from "swr";
const fetcher = (url: string) => fetch(url).then(r => r.json());
function UserEnrollments() {
const { data, error, isLoading } = useSWR("/api/user/enrollments", fetcher);
if (isLoading) return <div>Loading enrollments...</div>;
if (error) return <div>Failed to load</div>;
return (
<ul>
{data.map((enrollment: any) => (
<li key={enrollment.id}>{enrollment.course.title}</li>
))}
</ul>
);
}
SWR handles caching, revalidation, deduplication, and background updates automatically. Much cleaner than managing loading state manually.
Error Handling in Server Data Fetching
// src/app/courses/[slug]/page.tsx
import { notFound } from "next/navigation";
async function getCourse(slug: string) {
const res = await fetch(`https://api.example.com/courses/${slug}`);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`Failed to fetch course: ${res.status}`);
return res.json();
}
export default async function CoursePage({ params }: { params: { slug: string } }) {
const course = await getCourse(params.slug);
if (!course) {
notFound(); // renders not-found.tsx
}
return <CourseDetail course={course} />;
}
notFound() is a Next.js function that triggers the nearest not-found.tsx. Use it whenever a dynamic route has no matching data.
When Data Gets Stale: On-Demand Revalidation
When a user publishes new content, you don't want to wait for the cache to expire. Revalidate on demand from a Server Action or API route:
// src/app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from "next/cache";
import { NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const secret = request.headers.get("x-revalidate-secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { tag, path } = await request.json();
if (tag) revalidateTag(tag); // invalidate all fetches with this tag
if (path) revalidatePath(path); // invalidate a specific path
return Response.json({ revalidated: true });
}
Call this endpoint from your CMS or admin panel whenever content changes. The next visitor gets fresh data.
Next lesson: Dynamic routes — generating pages from data at build time and handling unknown slugs.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises