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



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.

100%
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:

TEXT 📖 Display only
Heading: "List of Articles". async data fetching
TSX
// 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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
Heading: "List of Articles". Async data fetching/loading states
TSX
// 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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
Async data fetching/loading states
TSX
// 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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
React Router handles navigation
TSX
// 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:

TEXT 📖 Display only
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:

TEXT 📖 Display only
Heading: "{post.title}". Async data fetching/loading states
TSX
// 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:

TEXT 📖 Display only
ISR: revalidate: 30 → page served from cache, background regenerates after 30s. Always fast, data stays fresh.

❓ FAQ

Q What is the difference between fetch in a Server Component and useEffect’s fetch on the client side?
A 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.
Q What is the difference between a Route Handler and a traditional Express/Koa backend?
A A Route Handler runs on the Next.js server and does not require a separate Node.js server process, making deployment simpler. However, it is better suited for lightweight APIs (BFF—Backend For Frontend); for complex backend logic, it is still recommended to use a separate backend service. The advantage of Route Handlers is that they can share type definitions with frontend code within the same project and are deployed as a single unit.
Q What can and cannot be done in middleware?
A Middleware runs in Edge Runtime (a lightweight JavaScript environment). It can: read/set cookies, redirect URLs, rewrite URLs, set response headers, perform A/B testing, and handle internationalized routing. It cannot: read 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.
Q What is the difference between ISR and On-Demand ISR?
A ISR is a scheduled revalidation—after setting 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.
Q How do I handle CORS (Cross-Origin Resource Sharing) in a Route Handler?
A Simply set CORS headers such as 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


📝 Exercises

  1. Create app/api/products/route.ts in the project to implement a sample product API—GET returns a list of products, and POST creates a new product. In app/products/page.tsx, use the Server Component fetch to call this API and display the product list, and configure revalidate: 30 to 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.
  2. Create middleware.ts to protect the /dashboard/* route—if the cookie does not contain session_token, redirect to /login. At the same time, automatically redirect logged-in users to /dashboard when they access /login. Use matcher to precisely configure routing to match only the required paths, preventing the middleware from executing on resource paths such as _next/static.
  3. Implement an on-demand ISR refresh mechanism: Click the "Refresh Cache" button on the administrator page to call POST /api/revalidate (with x-revalidate-secret request header authentication) and refresh the ISR cache for the product page via revalidateTag('products'). Verify whether the page updates immediately after the refresh, and compare the refresh speeds between scheduled ISR and on-demand ISR.
Web-Tutorial.com

Web-Tutorial Tech Team

A team of developers maintaining programming tutorials. Each tutorial is written and reviewed by developers with expertise in that field. We work to keep our content accurate and reliable — if you spot an issue, please let us know.

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏