React: Next.js Data Retrieval and APIs
Last updated: 2026-08-26
After switching his blog to App Router, Tom ran into a new problem: the frequency of data updates varied greatly across different pages—articles were updated once a month, product prices were updated several times a day, and user avatars could change every time a user logged in. He needed to choose different data fetching strategies based on the characteristics of each type of data, while also providing a unified API interface for the front end. Next.js’s Server Components for data fetching and Route Handlers were exactly what he needed to solve these pain points.
1. What You'll Learn
- Three caching strategies for directly
fetchdata in the Server Component (force-cached / no-store / revalidate) - Route Handler creates RESTful API endpoints(GET/POST/PUT/DELETE)
- How ISR Incremental Static Generation Works and How to Configure It
- Request Interception and Route Guards in Middleware
- Security Access Policies for Environment Variables on the Server and Client Sides
- Implementation of On-Demand ISR for On-Demand Cache Revalidation (revalidateTag / revalidatePath)
- Capability Boundaries and Performance Considerations for Middleware in Edge Runtime
2. Conceptual Diagrams
Tom outlined the complete data retrieval process in Next.js: When a page requisição arrives, Next.js determines whether to cached the data, revalidate it, or route it to an API based on the data retrieval strategy. Understanding this process helps him choose the right approach for different types of data.
The decision node {Caching Strategy} in the flowchart is the core of this section—it determines the performance and real-time capabilities of data retrieval. force-cached is the fastest but not real-time; no-store is the most real-time but has the worst performance; revalidate and tags offer flexible trade-offs between the two. Route Handlers and Middleware serve as supplementary mechanisms, handling API access and requisição interception, respectively.
flowchart TD
A[Page Request] --> B{Server Component}
B --> C[fetch Data]
C --> D{Caching Policy}
D -->|force-cache| E[Read Cache<br/>Default SSG]
D -->|no-store| F[Request every time<br/>Real-time SSR]
D -->|revalidate: N| G[Cache N seconds<br/>ISR Pattern]
D -->|{next: {tags: [...]}}| H[Cache by Tag<br/>On-Demand ISR]
E --> I[Back HTML]
F --> I
G --> I
H --> I
I --> J[Route Handler<br/>/api/*]
J --> K[Database/External API]
I --> L{To be verified?}
L -->|is | M[Middleware<br/>Auth/Redirect]
L -->|No| N[Direct Rendering]
3. A Real-Life Scenario
After migrating his blog to App Router, Tom discovered a new challenge with data retrieval. Previously, he had been using getStaticProps and getServerSideProps, which, while familiar, lacked flexibility. For example, the article list needed to be updated once an hour, but when a popular article was edited, it needed to be refreshed immediately—getStaticProps couldn’t handle this on a per-page basis.
App Router introduces a brand-new data retrieval paradigm: fetch is used directly within Server Components, while next.revalidate and next.tags are used to precisely control caching strategies. Tom also uses Route Handlers to expose API endpoints for the comment section’s front-end components to call, and employs Middleware to protect the dashboard routes. Below is the data retrieval architecture he designed for the blog.
He first analyzed the characteristics of all data sources on the blog: article content (updated hourly, can be cached), comments (updated in real time, must be displayed immediately), statistics (vary with each visit), and product information (updated several times a day, must be displayed as soon as possible after an update). He then selected different fetching strategies for each data type. This analysis helped him appreciate the beauty of Next.js’s data fetching design—it isn’t a one-size-fits-all “SSG or SSR” approach, but rather allows for precise control on a per-page, per-request, or even per-data-tag basis.
(1) Server Component: Data Retrieval
You can use await fetch directly within a Server Component—this is one of the most powerful features of the App Router. No need for useEffect, no need for SWR or React Query, and no need for additional client-side state management libraries—simply request data directly within the component function, and once server-side rendering is complete, it’s sent to the browser along with the HTML.
The second parameter of fetch accepts an object containing the next configuration, which is used to control caching behavior: { cache: 'force-cache' } is equivalent to SSG, where data is fetched only once during build time; { cache: 'no-store' } is equivalent to SSR, where data is fetched anew with every request; { next: { revalidate: 60 } } is equivalent to ISR, where data is revalidated after 60 seconds of caching.
A more advanced use case is On-Demand ISR: Use next: { tags: ['posts'] } to tag the data, then call revalidateTag('posts') in the Route Handler or the admin page to manually trigger a refresh. This way, articles can be regenerated immediately after editing, without having to wait for the revalidation period to expire.
Decision Table for Three Caching Strategies
| Strategy | fetch configuration | Behavior | Use Cases |
|---|---|---|---|
| Static SSG | cache: 'force-cache' |
Fetched once during build, then served entirely via CDN | Article content, About page |
| Real-time SSR | cache: 'no-store' |
Refresh on every request | User data, real-time dashboard |
| Incremental ISR | next: { revalidate: 60 } |
Re-validate after 60 seconds in cache | Product list, pricing page |
| Refresh on Demand | next: { tags: ['x'] } |
Cache + revalidateTag() to trigger a refresh |
Instant updates after CMS content is edited |
Tom’s principles for choosing strategies: Decisions are based on the frequency of data changes and real-time requirements. Article content (which changes infrequently) uses SSG; the number of comments (which changes frequently but can tolerate some delay) uses ISR, updated every 30 seconds; and user avatars (which require immediate updates) use SSR. By appropriately combining these strategies, it is possible to find the optimal balance between performance and real-time responsiveness.
▶ Example 1: Three Caching Strategies for Server Components
Output:
Heading: "List of Articles". async data fetching
// app/posts/page.tsx - Strategy 1:force-cache(Default SSG)
// Data is retrieved only once during build time.,All subsequent requests go through CDN cache
async function PostsPage() {
const posts = await fetch('https://api.example.com/posts', {
cache: 'force-cache' // equivalent to SSG,Default behavior
}).then(r => r.json())
return (
<div>
<h1>List of Articles</h1>
{posts.map((post: any) => (
<article key={post.id} style={{ marginBottom: 16 }}>
<h2>{post.title}</h2>
<p>{post.body.slice(0, 100)}...</p>
</article>
))}
</div>
)
}
export default PostsPage
// app/dashboard/stats/page.tsx - Strategy 2:no-store(Real-time SSR)
// Every request starts from API Get the latest data
export const dynamic = 'force-dynamic'
async function StatsPage() {
const stats = await fetch('https://api.example.com/dashboard/stats', {
cache: 'no-store' // Request the latest data every time
}).then(r => r.json())
return (
<div>
<h1>Real-Time Statistics</h1>
<p>Users Online:{stats.onlineUsers}</p>
<p>Today PV:{stats.pageViews}</p>
<p>API Number of calls:{stats.apiCalls}</p>
</div>
)
}
export default StatsPage
// app/products/page.tsx - Strategy 3:revalidate(ISR Incremental Update)
// Cache 60s, first visit after 60s triggers a regenerate
async function ProductsPage() {
const products = await fetch('https://api.example.com/products', {
next: { revalidate: 60 } // ISR:60 Please try again in a few seconds.
}).then(r => r.json())
return (
<div>
<h1>Product List</h1>
{products.map((p: any) => (
<div key={p.id} style={{ border: '1px solid #ddd', padding: 12, marginBottom: 8, borderRadius: 8 }}>
<h3>{p.name}</h3>
<p>Price:${p.price}</p>
<p>Inventory:{p.stock > 0 ? 'In stock' : 'Out of stock'}</p>
</div>
))}
</div>
)
}
export default ProductsPage
// app/api/revalidate/route.ts - On-Demand ISR Refresh on Demand
// Call this after the administrator edits the article API Refresh the cache now
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const body = await request.json()
const { tag } = body
// Verification secret Preventing Abuse
const secret = request.headers.get('x-revalidate-secret')
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(tag) // Refresh the cache by tag
return Response.json({ revalidated: true, tag })
}
Output:
ISR: revalidate: 30 → page served from cache, background regenerates after 30s. Always fast, data stays fresh.
(2) Route Handler API Routing
A Route Handler is the method used to create API endpoints in the App Router. Create a file named route.ts in the app/api/ directory and export a named function (GET, POST, PUT, DELETE, PATCH), where the function names correspond to HTTP methods. Route Handlers support all features of Server Components—they can access databases, use environment variables, and control caching policies.
Tom replaced the Express backend from his previous blog post with Route Handler. The article’s CRUD operations, user authentication, and comment system all expose APIs through Route Handler. Combined with On-Demand ISR, the cache is refreshed immediately after editing an article, so users don’t have to wait for the revalidation period to elapse.
Route Handlers also support dynamic routing. In app/api/products/[id]/route.ts, path parameters are retrieved via the params parameter, which is suitable for RESTful API design. Each Route Handler can also configure its own caching strategy—for example, GET requests can be configured with { next: { revalidate: 60 } } to implement API-level caching.
Quick Reference for Core Route Handler Usage
| File Path | HTTP Method | URL Endpoint | Purpose |
|---|---|---|---|
app/api/posts/route.ts |
GET | /api/posts |
Get Article List |
app/api/posts/route.ts |
POST | /api/posts |
Create New Post |
app/api/posts/[id]/route.ts |
GET | /api/posts/1 |
Get a single article |
app/api/posts/[id]/route.ts |
PUT | /api/posts/1 |
Update Article |
app/api/posts/[id]/route.ts |
DELETE | /api/posts/1 |
Delete Post |
app/api/auth/login/route.ts |
POST | /api/auth/login |
User Login |
app/api/revalidate/route.ts |
POST | /api/revalidate |
Refresh ISR Cache |
▶ Example 2: Complete Blog CRUD API
Output:
Heading: "List of Articles". Async data fetching/loading states
// app/api/posts/route.ts - List of Articles API(GET)and creating articles API(POST)
import { revalidateTag } from 'next/cache'
// Simulated Database
const posts = [
{ id: 1, title: 'Next.js Getting Started', body: 'This article introduces Next.js Basic Usage...', published: true },
{ id: 2, title: 'React 19 New Features', body: 'React 19 What changes has this brought about?...', published: true },
]
export async function GET() {
// Return only published posts
const published = posts.filter(p => p.published)
return Response.json(published)
}
export async function POST(request: Request) {
try {
const body = await request.json()
const newPost = {
id: posts.length + 1,
title: body.title,
body: body.body,
published: body.published ?? false,
createdAt: new Date().toISOString()
}
posts.push(newPost)
// Refresh the article list ISR cache
revalidateTag('posts')
return Response.json(newPost, { status: 201 })
} catch (error) {
return Response.json({ error: 'Invalid request body' }, { status: 400 })
}
}
// app/api/posts/[id]/route.ts - Operations on a Single Article(GET / PUT / DELETE)
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const post = posts.find(p => p.id === Number(params.id))
if (!post) {
return Response.json({ error: 'Post not found' }, { status: 404 })
}
return Response.json(post)
}
export async function PUT(
request: Request,
{ params }: { params: { id: string } }
) {
const body = await request.json()
const index = posts.findIndex(p => p.id === Number(params.id))
if (index === -1) {
return Response.json({ error: 'Post not found' }, { status: 404 })
}
posts[index] = { ...posts[index], ...body, id: Number(params.id) }
revalidateTag('posts')
return Response.json(posts[index])
}
export async function DELETE(
request: Request,
{ params }: { params: { id: string } }
) {
const index = posts.findIndex(p => p.id === Number(params.id))
if (index === -1) {
return Response.json({ error: 'Post not found' }, { status: 404 })
}
posts.splice(index, 1)
revalidateTag('posts')
return Response.json({ message: 'Deleted' })
}
Output:
try { await fetchData(); } catch (err) { setError(err.message); } → "Failed to load data" shown. Error handled, app stays running.
(3) Middleware and Request Interception
Middleware is a powerful request-interception mechanism in Next.js. It executes before each request reaches the page and can be used to handle scenarios such as redirects, authentication, internationalized routing, and A/B testing. Middleware runs in the Edge Runtime and has extremely low latency (on the order of milliseconds).
Tom used middleware to implement three features: First, redirecting users who are not logged in to the login page when they access the dashboard; second, automatically redirecting users to the appropriate language version based on their browser’s language settings; and third, blocking crawlers from accessing API routes.
Middleware uses the matcher configuration to match routes that need to be intercepted, thereby avoiding unnecessary execution overhead. Note that Middleware cannot read req.body or use Node.js’s native APIs—it runs in an Edge environment.
▶ Example 3: A Complete Middleware Authentication System
Output:
Async data fetching/loading states
// middleware.ts - Project Root Directory,and app/ Peer
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// === Features1:Route Guard ===
// Routes Protected from Unauthenticated Users → Redirect to the login page
const token = request.cookies.get('session_token')?.value
const protectedPaths = ['/dashboard', '/admin', '/profile']
if (!token && protectedPaths.some(path => pathname.startsWith(path))) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
// === Features2:Logged-in users are redirected to the login page → Redirect to the Dashboard ===
if (token && pathname.startsWith('/login')) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
// === Features3:International Routing ===
// According to Accept-Language Automatically Redirect to Language Version
const supportedLocales = ['zh', 'en', 'ja', 'pt']
const defaultLocale = 'zh'
// Inspection URL Is the language prefix included?
const pathnameHasLocale = supportedLocales.some(
locale => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (!pathnameHasLocale) {
const acceptLanguage = request.headers.get('accept-language') || ''
const preferredLocale = supportedLocales.find(locale =>
acceptLanguage.startsWith(locale)
) || defaultLocale
return NextResponse.redirect(new URL(`/${preferredLocale}${pathname}`, request.url))
}
// === Features4:Set Response Headers ===
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
return response
}
// Layout middleware Match Path(Required,Otherwise, all routes will be triggered.)
export const config = {
matcher: [
// Match all routes that need to be protected
'/dashboard/:path*',
'/admin/:path*',
'/profile/:path*',
'/login',
// Matching Internationalized Routes
'/((?!api|_next/static|_next/image|favicon.ico).*)',
]
}
Output:
Uses router
Middleware Configuration Guide
| Configuration Option | Description | Example |
|---|---|---|
matcher |
Routes that require middleware to be executed | ['/dashboard/:path*', '/login'] |
request.nextUrl |
URL object for the current request | Used to retrieve the pathname and searchParams |
request.cookies |
Read/Operate Cookie | request.cookies.get('token') |
NextResponse.redirect() |
Redirect to a specified URL | Used for login verification |
NextResponse.next() |
Continue processing the request as usual | Allow valid requests |
response.headers.set() |
Setting Response Headers | Security-Related Response Headers |
Middleware Execution Order and Considerations
Middleware runs on every matched request, so performance is critical. Edge Runtime is designed to execute in microseconds; therefore, database queries or complex computations should not be performed within it. The execution order of multiple middleware components is determined by the order configured in matcher. If a middleware component returns redirect() or rewrite(), subsequent middleware components will not execute.
An important note: Do environment variables in Middleware need to be exposed using the NEXT_PUBLIC_ prefix? No—since Middleware runs in a server environment, it can directly access all environment variables. However, keep in mind that process.env is replaced with the actual value during Middleware compilation, so environment variables cannot be read dynamically at runtime.
4. Environment Variable Security Policy
Tom also encountered a critical issue when using route handlers and middleware: secure access to environment variables. In Next.js, the rules for accessing environment variables depend on the prefix—which is very important for data retrieval and API development.
Rules for Accessing Environment Variables
| Prefix | Accessible Location | Description |
|---|---|---|
| No prefix | Server Component, Route Handler, Middleware | Available only on the server side; not exposed to the browser |
NEXT_PUBLIC_ |
All locations (including the browser) | will be compiled and bundled into JS, so do not include sensitive information |
NEXT_PRIVATE_ |
Available only on the server side | New explicit marker that behaves the same as the unprefixed version |
Tom’s rule of thumb: Sensitive information such as database connection strings, API secret keys, and JWT keys—use them without a prefix, and only within server components and route handlers. Variables that need to be used in the front end, such as Google Analytics IDs and public API URLs—prefix them with NEXT_PUBLIC_.
▶ Example 4: ISR Incremental Static Regeneration—Blog Post Page
Output:
React Router handles navigation
// app/blog/[slug]/page.tsx - ISR: Regenerate every 60s
interface BlogPost {
title: string
content: string
author: string
publishedAt: string
}
async function getPost(slug: string): Promise<BlogPost> {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 60 },
})
if (!res.ok) throw new Error('Post not found')
return res.json()
}
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return posts.map((post: { slug: string }) => ({ slug: post.slug }))
}
export default async function BlogPostPage({ params }: { params: { slug: string } }) {
const post = await getPost(params.slug)
return (
<article style={{ maxWidth: 700, margin: '0 auto', padding: 24 }}>
<h1>{post.title}</h1>
<p style={{ color: '#999', fontSize: 14 }}>
By {post.author} · {new Date(post.publishedAt).toLocaleDateString()}
</p>
<div style={{ lineHeight: 1.8, marginTop: 16 }}>{post.content}</div>
</article>
)
}
Output:
ISR: revalidate: 30 → page served from cache, background regenerates after 30s. Always fast, data stays fresh.
▶ Example 5: Server Actions—Form Submission and Data Changes
Output:
Heading: "{post.title}". Async data fetching/loading states
// app/actions.ts - Server Actions
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
if (!title || !content) return
await fetch('https://api.example.com/posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, author: 'Alice' }),
})
revalidatePath('/blog')
redirect('/blog')
}
export async function deletePost(slug: string) {
await fetch(`https://api.example.com/posts/${slug}`, { method: 'DELETE' })
revalidatePath('/blog')
}
// app/blog/new/page.tsx - New Article Form
function NewPostPage() {
return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
<h1>New Post</h1>
<form action={createPost} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<input name="title" placeholder="Title" required style={{ padding: 8, borderRadius: 4 }} />
<textarea name="content" placeholder="Content..." rows={8} required style={{ padding: 8, borderRadius: 4 }} />
<button type="submit" style={{ padding: 10, background: '#1890ff', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>
Publish
</button>
</form>
</div>
)
}
export default NewPostPage
Output:
ISR: revalidate: 30 → page served from cache, background regenerates after 30s. Always fast, data stays fresh.
❓ FAQ
fetch in a Server Component and useEffect’s fetch on the client side?fetch in a Server Component runs on the server; the data is rendered into HTML and sent directly to the browser, so the user sees the complete page without a loading screen. useEffect’s fetch runs on the client side; the user first sees a blank page or a loading screen, and the content is rendered only after the JavaScript has finished downloading and executing. The fetch method in Server Components offers faster first-screen loading and is more SEO-friendly.req.body, use built-in Node.js modules (fs, path), access databases, or make external requests other than fetch. It is recommended to implement complex authentication logic in Route Handlers.revalidate: 60, the page will refresh within 60 seconds at most. On-Demand ISR is an event-driven refresh—the page updates immediately after calling revalidateTag() or revalidatePath(), making it suitable for scenarios where changes to content need to take effect immediately. The two can be used in combination: set a longer revalidate time as a fallback, while using On-Demand ISR to refresh immediately when content changes.Access-Control-Allow-Origin directly in the Route Handler’s response. You can wrap a utility function corsHeaders() that returns a unified CORS response header object, and include it in each Route Handler using Response.json(data, { headers: corsHeaders() }). If the frontend and backend are on the same domain (which is typically the case with Next.js full-stack deployments), there is no need to handle CORS.📖 Summary
- The Server Component retrieves data directly from
await fetchand supports three caching strategies:force-cache(SSG),no-store(SSR), andnext.revalidate(ISR) - Route Handlers are defined in the
route.tsfile located in theapp/api/directory; the exported function names correspond to HTTP methods (GET/POST/PUT/DELETE). - Route Handler supports dynamic routing (
[id]), request body parsing, response status code control, and custom caching policies - ISR configures periodic revalidation via
next.revalidateand implements on-demand refreshes vianext.tags+revalidateTag() - Middleware executes before a request reaches a page; it runs in the Edge Runtime and is suitable for scenarios such as route guards, internationalization, redirects, and setting security headers.
matcherConfigure the scope of Middleware execution to avoid unnecessary performance overhead; you can precisely match or exclude specific routes.- On-Demand ISR is suitable for scenarios where content needs to be refreshed immediately after editing; it is triggered via
revalidateTagorrevalidatePath, with no need to wait for a scheduled expiration. - Principles for selecting data retrieval strategies: Determine a hybrid approach using SSG, ISR, and SSR based on data update frequency and timeliness requirements.
- The configuration for
middleware.tsmust be precise to avoid matching static resources such as_next/staticandfavicon.ico revalidateTagandrevalidatePathare the two core APIs for implementing On-Demand ISR; they are called within a Route Handler or Server Action- All the data retrieval skills covered in this lesson serve as the technical foundation for the subsequent lessons (Deploying CI/CD, Integrating Component Libraries, and Comprehensive Projects).
📝 Exercises
- Create
app/api/products/route.tsin the project to implement a sample product API—GET returns a list of products, and POST creates a new product. Inapp/products/page.tsx, use the Server Componentfetchto call this API and display the product list, and configurerevalidate: 30to implement ISR. Verify that the page does not update when product data is modified within 30 seconds; after 30 seconds, refresh the page to see the new data. - Create
middleware.tsto protect the/dashboard/*route—if the cookie does not containsession_token, redirect to/login. At the same time, automatically redirect logged-in users to/dashboardwhen they access/login. Usematcherto precisely configure routing to match only the required paths, preventing the middleware from executing on resource paths such as_next/static. - Implement an on-demand ISR refresh mechanism: Click the "Refresh Cache" button on the administrator page to call
POST /api/revalidate(withx-revalidate-secretrequest header authentication) and refresh the ISR cache for the product page viarevalidateTag('products'). Verify whether the page updates immediately after the refresh, and compare the refresh speeds between scheduled ISR and on-demand ISR.