How to Fix: API is failing in server components but working in client components

7 min read

Why the API works in Client Components but fails in Server Components in Next.js

This bug usually means the request is being executed in two very different runtimes. In a Client Component, the browser makes the call with browser context, cookies, and a public origin. In a Server Component, the request runs on the server during render, where relative URLs, missing headers, authentication state, caching behavior, or environment variables can behave differently.

If you are reproducing this from the linked repository, the likely issue is that the API call inside the Server Component is not being made with a fully qualified server-safe request setup. A fetch that succeeds in the browser can fail on the server because the server does not automatically behave like the current tab in the browser.

Understanding the Root Cause

In Next.js App Router, Server Components run on the server, not in the browser. That changes several important details:

  • Relative API paths may not resolve the same way they do in the browser.
  • Cookies and auth headers are not automatically forwarded unless you explicitly pass them.
  • Environment variables differ between server and client. Values prefixed with NEXT_PUBLIC_ are available in the browser, while server-only variables are not.
  • CORS may look like the problem, but server-side failures are often actually caused by bad base URLs, missing credentials, or upstream API restrictions.
  • Next.js fetch caching can change request timing and behavior, especially when using dynamic data.

A common pattern behind this exact symptom is one of these:

  1. The client uses /api/... in the browser and it works, but the server fetch needs an absolute URL or a properly resolved internal route.
  2. The API depends on request cookies or authorization headers, but the Server Component does not forward them.
  3. The API route or backend expects a browser-originated request, while the server call comes from the Node.js runtime.
  4. The component is statically rendered or cached, but the API requires per-request dynamic data.

So the root cause is not simply that Server Components cannot call APIs. They can. The issue is that a server-side fetch must be written with server runtime rules in mind.

Step-by-Step Solution

The safest fix is to make the API request explicitly server-aware.

1. Prefer absolute URLs for external APIs

If you are calling an external backend, define a server environment variable and use it in the Server Component.

const API_BASE_URL = process.env.API_BASE_URL;
async function getData() {
  const res = await fetch(`${API_BASE_URL}/endpoint`, {
    cache: 'no-store'
  });

  if (!res.ok) {
    throw new Error(`API request failed: ${res.status}`);
  }

  return res.json();
}

export default async function Page() {
  const data = await getData();

  return <div>{JSON.stringify(data)}</div>;
}

Why this helps:

  • The server no longer guesses the origin.
  • You avoid relying on browser URL resolution.
  • cache: ‘no-store’ ensures the request is executed per request when debugging or when data is dynamic.

2. If you call your own Next.js route handler, build the full origin on the server

If the API being called is your own internal route, do not assume the same relative path behavior as the browser. Build the URL from request headers.

import { headers } from 'next/headers';
async function getInternalData() {
  const headerStore = headers();
  const host = headerStore.get('host');
  const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https';
  const baseUrl = `${protocol}://${host}`;

  const res = await fetch(`${baseUrl}/api/test`, {
    cache: 'no-store'
  });

  if (!res.ok) {
    throw new Error(`Internal API failed: ${res.status}`);
  }

  return res.json();
}

This is useful when the server needs the correct request origin during rendering.

3. Forward cookies or auth headers when required

If the API works in the client because the browser automatically sends session cookies, then the server version must forward them manually.

import { cookies, headers } from 'next/headers';
async function getProtectedData() {
  const cookieStore = cookies();
  const sessionCookie = cookieStore.toString();
  const authHeader = headers().get('authorization');

  const res = await fetch(`${process.env.API_BASE_URL}/protected-endpoint`, {
    headers: {
      Cookie: sessionCookie,
      ...(authHeader ? { Authorization: authHeader } : {})
    },
    cache: 'no-store'
  });

  if (!res.ok) {
    throw new Error(`Protected API failed: ${res.status}`);
  }

  return res.json();
}

Without this, authenticated APIs often fail in Server Components while appearing fine in Client Components.

4. Mark the page as dynamic if request-specific data is needed

If the page depends on per-request headers, cookies, or uncached API responses, make sure Next.js does not treat it as static.

export const dynamic = 'force-dynamic';

Use this in the route segment when data must be rendered fresh for each request.

5. Avoid unnecessary self-fetching when possible

If your Server Component is calling your own Next.js API route just to get data from the same app, the better solution is often to move the shared logic into a server utility and call it directly.

// lib/data.ts
export async function getDataDirectly() {
  // database query or backend call
  return { message: 'ok' };
}
// app/page.tsx
import { getDataDirectly } from '@/lib/data';

export default async function Page() {
  const data = await getDataDirectly();
  return <div>{data.message}</div>;
}

This removes an entire network hop and avoids origin, header, and caching confusion.

6. Add debugging output to confirm what fails

When reproducing this issue, log the exact response status and execution context.

async function getData() {
  const url = `${process.env.API_BASE_URL}/endpoint`;
  console.log('Fetching on server:', url);

  const res = await fetch(url, { cache: 'no-store' });
  console.log('Status:', res.status);

  const text = await res.text();
  console.log('Body:', text);

  if (!res.ok) {
    throw new Error('Server component API request failed');
  }

  return JSON.parse(text);
}

This quickly reveals whether the issue is caused by a 401, 404, 500, invalid JSON, or a bad URL.

import { headers, cookies } from 'next/headers';

export const dynamic = 'force-dynamic';

async function getData() {
  const headerStore = headers();
  const host = headerStore.get('host');
  const protocol = process.env.NODE_ENV === 'development' ? 'http' : 'https';
  const baseUrl = `${protocol}://${host}`;

  const res = await fetch(`${baseUrl}/api/test`, {
    headers: {
      Cookie: cookies().toString()
    },
    cache: 'no-store'
  });

  if (!res.ok) {
    throw new Error(`Fetch failed with status ${res.status}`);
  }

  return res.json();
}

export default async function Page() {
  const data = await getData();

  return <div>{JSON.stringify(data)}</div>;
}

If the route is external, replace the computed baseUrl with process.env.API_BASE_URL.

Common Edge Cases

  • Using localhost in production: A URL like http://localhost:3000/api/test works locally but fails after deployment. Use a deployment-safe base URL.
  • Missing cookies on the server: If login state exists only in the browser request, protected APIs may return 401 in Server Components.
  • Static optimization: If a page is accidentally statically rendered, the fetch may not execute with live request context.
  • Reading browser-only globals: Code that relies on window, document, or browser storage will fail in a Server Component path.
  • Invalid environment setup: The client may use NEXT_PUBLIC_ variables, but the server expects a different env variable that is missing.
  • Self-fetching recursion or misrouting: Calling an internal route from the same app can trigger unexpected behavior if middleware, rewrites, or auth checks intercept the request.
  • JSON parsing errors: The endpoint may return HTML or an error page on the server, causing res.json() to fail even though the browser request looked correct.

FAQ

Why does the same fetch work in a Client Component?

Because the browser automatically provides the current origin, sends relevant cookies, and runs the request in a user session context. A Server Component does not automatically inherit all of that behavior.

Should I call my own /api routes from a Server Component?

You can, but it is often better to call the shared server logic directly. Internal self-fetching adds overhead and introduces origin, auth, and caching issues that are unnecessary inside the same app.

Do I always need cache: 'no-store'?

No. Use it when the response is request-specific, authenticated, or changing frequently. For stable data, you can use Next.js caching features intentionally. During debugging, no-store helps remove cache-related confusion.

The practical fix for this issue is to treat the Server Component request as a true server-side fetch: use the correct absolute URL, forward auth context when needed, and disable caching or static behavior when the data must be dynamic. Once those pieces are aligned, the API will behave consistently across both server and client components.

Leave a Reply

Your email address will not be published. Required fields are marked *