Blog
Oct 7, 2026-8 MIN READ
Why Nuxt 4 Fetches Data on the Client Even When SSR Is Enabled

Why Nuxt 4 Fetches Data on the Client Even When SSR Is Enabled

Understand how Nuxt 4 handles SSR, hydration, and client navigation—and why server: true does not make useFetch server-only.

Mohammadreza Khalili

Mohammadreza Khalili

You enable SSR in Nuxt 4, add server: true to useFetch, and expect every request to run on the server. Then you navigate to the page through a NuxtLink and notice an API request in your browser’s Network panel.

This behavior is expected. Nuxt uses different execution paths for a full page load and navigation within an already running application. Understanding those paths makes data fetching, loading states, and server boundaries much easier to reason about.

Consider this page:

<!-- app/pages/product.vue -->
<script setup lang="ts">
const { data: product, error } = await useFetch('/api/product', {
  server: true,
})
</script>

<template>
  <p v-if="error">Unable to load the product.</p>
  <article v-else-if="product">
    <h1>{{ product.name }}</h1>
    <p>{{ product.description }}</p>
  </article>
</template>

The same code can participate in server rendering or execute during browser navigation. Its execution environment depends on how the user reaches the page.

When someone opens /product directly or refreshes the browser, the browser requests a new HTML document. In a dynamically rendered SSR route, Nuxt runs the page on the server, retrieves its required data, and returns rendered HTML.

After receiving that HTML, the browser loads the application’s JavaScript. Vue then connects the application to the existing markup and makes it interactive. This process is called hydration. Nuxt calls this combination of server rendering and browser interactivity universal rendering. Nuxt rendering documentation

The initial request follows this sequence:

Browser requests /product
        ↓
Nuxt executes the page on the server
        ↓
useFetch retrieves the product
        ↓
Nuxt sends rendered HTML and the data payload
        ↓
The browser hydrates the page

The data payload matters. useFetch transfers its server result to the browser so the browser can reuse that result during hydration. Although the page’s setup code participates in hydration, that does not require another identical API request for data already provided by the server. Nuxt useFetch documentation

Navigation through an internal NuxtLink takes a different path.

Suppose the user is already on /, and the page contains:

<NuxtLink to="/product">View product</NuxtLink>

The Nuxt application is already running in the browser. Instead of requesting a replacement HTML document, NuxtLink uses Vue Router to navigate within that application. The destination page’s code executes in the browser, and its useFetch call can start a request there. NuxtLink documentation

User clicks the internal link
        ↓
Vue Router changes the route
        ↓
The product page executes in the browser
        ↓
useFetch requests /api/product
        ↓
The browser renders the destination page

There may still be network activity: Nuxt may need page code or data. What changes is that navigation does not normally replace the entire HTML document.

This explains the meaning of server: true. It allows the fetch to run during SSR; it does not make the composable exclusively executable on the server. Setting server: false disables its server-side execution. Neither setting turns browser navigation into a new SSR document request. Nuxt data-fetching documentation

Another useful distinction is where a request starts versus where its handler runs.

During client navigation, this call starts in the browser:

await useFetch('/api/product')

However, /api/product is a server endpoint. Its handler still runs on the Nuxt server. Nuxt registers files in server/api as API handlers, independently of where the caller executes. Nuxt server directory documentation

Browser
   ↓
Nuxt API handler
   ↓
Database or external service

This is the appropriate boundary for database access and integrations that require private credentials. For example, define private configuration:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    catalogApiBase: '',
    catalogApiToken: '',
  },
})

Then use it in a server handler:

// server/api/product.get.ts
export default defineEventHandler(async (event) => {
  const config = useRuntimeConfig(event)

  const product = await $fetch<{
    id: number
    name: string
    description: string
  }>('/products/42', {
    baseURL: config.catalogApiBase,
    headers: {
      Authorization: 'Bearer ' + config.catalogApiToken,
    },
  })

  return {
    id: product.id,
    name: product.name,
    description: product.description,
  }
})

Configure these values through NUXT_CATALOG_API_BASE and NUXT_CATALOG_API_TOKEN. Runtime configuration outside public is available only on the server. The handler returns the fields the page needs, while the credential stays in the server environment. Data returned by the handler is accessible to its caller, so private configuration and response data require separate consideration. Nuxt runtime configuration documentation

The page can now use the same endpoint during SSR and client navigation. During SSR, Nuxt can invoke internal API handlers directly rather than making an additional HTTP round trip to itself. During browser navigation, the browser sends an HTTP request to that endpoint. The handler remains server-side in both cases. Nuxt $fetch documentation

Execution location also differs from whether navigation waits for data.

For essential page content, a typical blocking fetch is:

const { data: product } = await useFetch('/api/product')

For content that can appear after navigation, use a lazy fetch and display a loading state:

<script setup lang="ts">
const { data: product, status, error } = await useFetch(
  '/api/product',
  { lazy: true },
)
</script>

<template>
  <ProductSkeleton
    v-if="status === 'idle' || status === 'pending'"
  />
  <p v-else-if="error">Unable to load the product.</p>
  <ProductDetails v-else-if="product" :product="product" />
</template>

lazy: true allows client navigation to proceed without waiting for the result. It does not make a normal SSR response stream past the pending request: the server still waits during initial rendering. Removing await does not, by itself, bypass SSR’s waiting behavior.

For a fetch that should start only in the browser, combine:

const { data, status, error } = await useFetch('/api/product', {
  server: false,
  lazy: true,
})

On an initial load, this fetch starts after hydration. Handling both idle and pending lets the page show a placeholder before and during the request. Nuxt data-fetching documentation

For ordinary page components, the behavior can be compared as follows:

  • await useFetch(...): Direct visit: Fetches during SSR and waits. Internal navigation: Fetches in the browser and waits.

  • await useFetch(..., { lazy: true }): Direct visit: Fetches during SSR and waits. Internal navigation: Allows navigation while fetching.

  • await useFetch(..., { server: false, lazy: true }): Direct visit: Sends the page without this data; fetches after hydration. Internal navigation: Fetches in the browser without blocking navigation.

Existing data, cached results, or generated payloads can change whether a fresh request is necessary. Nuxt can also prefetch page components and generated-page payloads through internal links, so a navigation does not always produce a new API request. Nuxt routing documentation

You can verify the distinction in your own application by temporarily logging the page’s execution environment:

<script setup lang="ts">
console.log(
  'Product page setup:',
  import.meta.server ? 'server' : 'client',
)

const { data } = await useFetch('/api/product')
</script>

Open the page directly and inspect both the server logs and browser console. Then navigate to it from another page using NuxtLink. During a full load, setup participates in SSR and hydration; during ordinary internal navigation, the destination page executes in the browser.

In the Network panel, inspect document requests separately from fetch requests. A refresh requests a new document. Internal navigation usually requests any necessary resources and data without replacing that document.

If a particular link should trigger a full document navigation, you can explicitly bypass Vue Router:

<NuxtLink to="/product" external>
  View product
</NuxtLink>

The external prop makes the link behave as a standard anchor. For an SSR-enabled destination, that document request returns through the server-rendering path, subject to the route’s caching or prerendering configuration. NuxtLink documentation

When designing a page, decide separately where its HTML is produced, where its data request begins, and where its backend logic executes. Those decisions explain why a Nuxt application can deliver server-rendered HTML on a direct visit, navigate between pages in the browser, and keep database access and private integrations entirely on the server.

© 2026 Mohamadreza. All rights reserved.