nextjs-data-fetching

Data Fetching in Next.JS

  • 5 min

If you have developed SPA applications with React for a while, you probably have the “Fetch-on-Render” pattern burned into your memory.

Every time we needed data from an API, we mechanically repeated:

  1. Create states for data, loading, and error.
  2. Write a useEffect with an empty dependency array.
  3. Perform the fetch inside.
  4. Handle cleanup to avoid race conditions.
  5. Conditionally render in JSX (if (loading) return <Spinner />).

In Next.js, thanks to Server Components, we can throw all that code away. The new paradigm is “Fetch-on-Server”.

Since our components run on the server, we can request the data directly there, wait for it to arrive, and then render the final HTML.

Asynchronous Components

The modern Next.js syntax breaks a rule that was sacred in classic React: Components can now be async.

// src/app/usuarios/page.tsx

// 1. Convert the component to async
export default async function UsuariosPage() {
  
  // 2. Wait (await) for the data directly in the component body
  // This execution happens on the SERVER.
  const res = await fetch('https://jsonplaceholder.typicode.com/users');
  
  if (!res.ok) {
    // This will automatically trigger the nearest error.tsx file
    throw new Error('Failed to load users');
  }

  const users = await res.json();

  // 3. Render the data directly.
  // No need to check if users is null, because await blocks execution.
  return (
    <main>
      <h1>Users</h1>
      <ul>
        {users.map((user: any) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </main>
  );
}

Copied!

It’s clean. It’s readable. It’s standard JavaScript.

Caching and Revalidation

Next.js extends the native fetch API to give us granular control over how this data is cached. The practical recommendation is to be explicit: if you want caching, say it; if you want fresh data on every request, say that too.

If the data changes infrequently, you can ask Next.js to cache it:

fetch('https://api.com/data', { cache: 'force-cache' })
Copied!

Next.js can reuse that response and serve the page very quickly. For content like documentation, posts, or catalogs that change rarely, this is exactly what we want.

If we need real-time data (e.g., stock quotes or logged-in user data).

fetch('https://api.com/data', { cache: 'no-store' })
Copied!

This tells Next.js: “Never store this. Request it again every time a user visits the site”. It’s useful for personalized data, private dashboards, or information that shouldn’t be cached.

The perfect middle ground. “Cache this, but update it every X seconds”.

fetch('https://api.com/data', { next: { revalidate: 60 } })
Copied!

The page will be fast, but if a user visits and more than 60 seconds have passed, Next.js can regenerate the content.

Streaming and Suspense (loading.tsx)

You might be wondering: if we use await on the server… does the user stare at a blank screen until the API responds?

If we do nothing… yes. But Next.js has an automatic Streaming system based on files.

If you create a file called loading.tsx in the same folder as your page (page.tsx), Next.js will automatically wrap your page in a <Suspense> component.

The user accesses the URL.

Next.js sends the Layout and the Loading component (a skeleton or spinner) immediately.

On the server, page.tsx continues waiting for the API.

When the API responds, Next.js sends the rest of the HTML and replaces the spinner with the actual content.

All of this without us writing a single line of state management logic.

// src/app/usuarios/loading.tsx
export default function Loading() {
  return <p className="text-gray-500">Loading users... ⏳</p>;
}
Copied!

Error Handling (error.tsx)

Similarly, if the fetch request fails (or we throw a throw new Error), Next.js will look for the nearest error.tsx file.

This file acts as a React Error Boundary. It must be a Client Component ('use client') because it needs interactivity to, for example, attempt to recover from the error.

// src/app/usuarios/error.tsx
'use client' // Mandatory for error boundaries

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div className="alert alert-error">
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Retry</button>
    </div>
  );
}
Copied!

Granularity is key here. If you have an error in /users, only that part of the page breaks. The layout (Navbar, Sidebar) will continue to work perfectly because the error is contained within the route segment.

When to use external libraries?

With this new model, libraries like SWR or React Query lose prominence, but they don’t disappear.

  • For server data (Server Components): Use the native fetch as we’ve seen. It’s best.
  • For client data (Client Components): If you need to fetch data from an interactive component (e.g., a real-time search field that filters as you type), then you should use 'use client' and libraries like SWR or TanStack Query to manage state, debouncing, and caching in the browser.