Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 026 / 100

Data fetching in the App Router: lessons from production

Fetch where you render.

Expona Admin8 min read

The App Router in Next.js 13+ flipped the script on how we fetch data. Instead of orchestrating requests from a client-side loader or a central page-level component, you now fetch data where you render it—directly inside async Server Components. This shift, paired with built-in deduplication and parallel request strategies, eliminates waterfalls, reduces boilerplate, and makes your data flow transparent.

Async Server Components — Await data directly in the component

The core idea is simple: Server Components in the App Router are async by default. You can await inside them without wrapping in useEffect or a data-fetching hook. This is a breath of fresh air if you've spent years managing loading and error states manually.

tsx
// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    cache: 'force-cache', // ISR equivalent
  });
  if (!res.ok) throw new Error('Failed to fetch products');
  return res.json();
}

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <div>
      <h1>Our Products</h1>
      <ProductGrid products={products} />
    </div>
  );
}

No loading state flickering. No race conditions. The page simply doesn't render until the data arrives. For users, this means they see a fully hydrated page—no flash of empty content.

The cache option controls revalidation. 'force-cache' acts like old-school ISR, 'no-store' hits your API every request, and revalidate: 3600 (in seconds) sets a custom TTL. Next.js handles cache headers intelligently; you're not manually juggling stale-while-revalidate anymore.

Parallel, not waterfall — Start independent requests together with Promise.all

A common trap I see is sequential fetching: load user data, then load their orders, then load order items. That stacks latency. If each call takes 200ms, you're looking at 600ms for the full page.

Instead, fire independent requests in parallel using Promise.all or Promise.allSettled (if one failure shouldn't block others).

tsx
// app/dashboard/page.tsx
async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`);
  return res.json();
}

async function getAnalytics(id: string) {
  const res = await fetch(`https://api.example.com/analytics/${id}`);
  return res.json();
}

async function getNotifications(id: string) {
  const res = await fetch(`https://api.example.com/notifications/${id}`);
  return res.json();
}

export default async function DashboardPage({
  searchParams,
}: {
  searchParams: { userId: string };
}) {
  const userId = searchParams.userId;

  // Fire all three at once
  const [user, analytics, notifications] = await Promise.all([
    getUser(userId),
    getAnalytics(userId),
    getNotifications(userId),
  ]);

  return (
    <div>
      <UserHeader user={user} />
      <AnalyticsBoard data={analytics} />
      <NotificationsList items={notifications} />
    </div>
  );
}

This cuts your time to first byte by two-thirds if all three endpoints have identical latency. Real-world gains vary, but the principle is iron-clad: parallelize everything that doesn't depend on prior results.

If one request is critical and others are optional, consider Promise.allSettled and handle failures gracefully:

tsx
const results = await Promise.allSettled([
  getUser(userId),
  getAnalytics(userId),
  getNotifications(userId), // might fail; we don't want it to block the page
]);

const user = results[0].status === 'fulfilled' ? results[0].value : null;
const analytics = results[1].status === 'fulfilled' ? results[1].value : null;
const notifications = results[2].status === 'fulfilled' ? results[2].value : null;

Deduplication — Use React cache() so repeated calls in one render hit the source once

Here's a subtle but powerful pattern: imagine your dashboard fetches the current user in the top-level page component, and three child components also need that user data. Without deduplication, you'd make four identical API calls in a single render cycle. That's waste.

React's cache() function (new in Next.js 13.2+) memoizes async function results for the duration of a single render pass. Call the same cached function a hundred times; it only executes once.

tsx
// lib/queries.ts
import { cache } from 'react';

export const getUser = cache(async (id: string) => {
  const res = await fetch(`https://api.example.com/users/${id}`, {
    cache: 'force-cache',
  });
  return res.json();
});

// app/dashboard/page.tsx
import { getUser } from '@/lib/queries';
import { UserHeader } from '@/components/UserHeader';
import { UserSettings } from '@/components/UserSettings';
import { UserActivity } from '@/components/UserActivity';

export default async function DashboardPage({
  searchParams,
}: {
  searchParams: { userId: string };
}) {
  const userId = searchParams.userId;

  // Even though three components below call getUser(userId),
  // only one fetch happens
  return (
    <div>
      <UserHeader userId={userId} />
      <UserSettings userId={userId} />
      <UserActivity userId={userId} />
    </div>
  );
}

// components/UserHeader.tsx
export async function UserHeader({ userId }: { userId: string }) {
  const user = await getUser(userId); // Cache hit
  return <h1>{user.name}</h1>;
}

// components/UserSettings.tsx
export async function UserSettings({ userId }: { userId: string }) {
  const user = await getUser(userId); // Cache hit
  return <div>Settings for {user.email}</div>;
}

// components/UserActivity.tsx
export async function UserActivity({ userId }: { userId: string }) {
  const user = await getUser(userId); // Cache hit
  return <div>Activity for {user.name}</div>;
}

This is different from HTTP caching. The fetch-level cache option controls how long data lives in Next.js's static cache layer. React's cache() is request-time deduplication. They work together: if your fetch is cached by Next.js and you navigate to the page again, React never calls the function. If you're within the same request, React deduplicates.

Fetch close to usage — Each component fetches what it needs — no giant page-level loaders

The old patterns encouraged centralizing all data fetching at the page level, then passing props down through a tree. This creates tight coupling: a deep child's data requirement ripples up to the root.

The App Router encourages the opposite: let each Server Component fetch its own dependencies. A sidebar that needs a user's menu doesn't need to wait for the main content's heavy analytics payload.

tsx
// app/posts/[id]/page.tsx
async function getPost(id: string) {
  const res = await fetch(`https://api.example.com/posts/${id}`);
  return res.json();
}

export default async function PostPage({ params }: { params: { id: string } }) {
  const post = await getPost(params.id);

  return (
    <div>
      <PostHeader post={post} />
      <PostContent post={post} />
      <Sidebar postId={params.id} /> {/* Sidebar fetches its own data */}
    </div>
  );
}

// components/Sidebar.tsx
async function getRelatedPosts(postId: string) {
  const res = await fetch(
    `https://api.example.com/posts/${postId}/related`,
    { cache: 'force-cache' }
  );
  return res.json();
}

export async function Sidebar({ postId }: { postId: string }) {
  const relatedPosts = await getRelatedPosts(postId);

  return (
    <aside>
      <h3>Related</h3>
      {relatedPosts.map((p) => (
        <a key={p.id} href={`/posts/${p.id}`}>
          {p.title}
        </a>
      ))}
    </aside>
  );
}

Now the Sidebar fetches independently. If you're streaming (via Suspense boundaries), the main post might render while related posts load in the background. You get fine-grained control over what blocks the page and what doesn't.

Common mistakes

Mistake 1: Forgetting that Server Components are async by default. Trying to fetch inside a Client Component and not realizing you need to move the fetch to a Server Component wrapper or a Route Handler. If you see "await is only valid in async functions," check if you accidentally marked the component with 'use client'.

Mistake 2: Sequencing requests when they could be parallel. Writing const user = await getUser(); const orders = await getOrders(user.id); when in reality, orders depend on the user ID. Fine. But if both are independent, always use Promise.all.

Mistake 3: Not leveraging cache() for shared data. Especially common when several components load the same reference data (site settings, feature flags, current user). Wrap these in cache() early, save request cycles.

Mistake 4: Over-caching static fetches. If your data changes daily, cache: 'force-cache' might leave stale data live for days. Use revalidate or no-store appropriately. ISR isn't magic; it's a deliberate tradeoff.

Mistake 5: Ignoring error boundaries. If a Server Component's fetch throws, the entire page errors unless you wrap it in an Error Boundary. Not every component needs an error boundary, but critical data fetches should have a fallback.

Key takeaways

  • Async Server Components are the default. Await data directly in the component, no loading states needed unless you want granular streaming.
  • Parallelize independent requests. Use Promise.all to fetch multiple unrelated datasets at once; you'll cut page render time significantly.
  • Cache at two levels: HTTP caching (Next.js fetch options) for long-term persistence, React cache() for request-time deduplication of identical calls.
  • Fetch close to where you use the data. No more God components fetching everything. Let each Server Component own its dependencies; use Suspense to stream secondary content.
  • Combine parallel fetching and deduplication. Fire requests in parallel, wrap shared queries in cache(), and you'll have a lean, fast data-fetching layer.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles