Skip to main content

HTTP requests and data fetching — deciding where fetch logic lives

Chapter 8 gathered data fetching into a composable, and chapter 9 showed that placing it under server-side rendering returns HTML in the loading state. This chapter settles two outstanding items: handling routes where the abort does not arrive, and machinery for filling in data on the server.

It also covers switching endpoints. More than how you write fetch, that is where accidents happen — where the URLs and keys live.

What you'll learn in this chapter​

  • The condition under which abort() stops a request, and why the signal.aborted guard works independently of it
  • The traps in composing fetch options: the order of spreading, and that spreading a Headers gives you nothing
  • Vite's import.meta.env — what is exposed, .env precedence, and the hole in its types
  • That process.env is replaced with {} in client builds, and why secrets must not go in VITE_*
  • Choosing between Nuxt's $fetch / useFetch / useAsyncData, and the condition under which cookies are not sent during SSR
  • Reading Nuxt config values with useRuntimeConfig()

When cancellation takes effect​

The composable in chapter 8 called controller.abort() in watchEffect's cleanup and checked controller.signal.aborted before writing. Those two play different roles.

abort() actually stops a request only on routes where the fetching side honours the signal. Swap in your own wrapper and forget to pass the signal, and the request does not stop.

// receives the signal but forgets to pass it on
const request = (url: string, signal: AbortSignal) => fetch(url)

Even so, the stale result is not written. abort() sets signal.aborted to true on the spot, so a run returning later trips the guard. That the guard works independently of whether the request stopped is its advantage.

Without a per-run AbortController, nothing is rejected​

What makes rejection possible is that an AbortController dedicated to that run sits in the closure. watchEffect calls the cleanup on every re-run, so this shape arises naturally.

It does not in a function you call imperatively. Press the button twice and have the first response arrive later, and the stale result overwrites the fresh one.

const data = shallowRef<string | null>(null)
let current: AbortController | null = null

const load = (path: string) => {
current?.abort() // cancel the previous one
const controller = new AbortController()
current = controller
return request(path, controller.signal).then((v) => {
if (controller.signal.aborted) return
data.value = v
})
}

Hold the previous controller, abort() it, and create a new one, and you have the same shape as watchEffect.

Do not replace it with a generation counter​

Counting "which run is this?" and comparing is another way to write it, but using it in place of signal.aborted breaks.

// as a replacement, it cannot reject the cleanup
if (mine !== generation) return

Cleanup runs when the component unmounts too, and abort() makes fetch throw an AbortError. But the generation has not advanced, so it passes the check and the AbortError lands in error. A component no longer on screen ends up in an error state. Checking signal.aborted rejects that case too.

The order for composing options​

When mixing per-call options with shared headers, getting the spread order wrong loses the headers entirely.

// broken — Content-Type disappears whenever options.headers exists
fetch(url, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
})

The trailing ...options overwrites the whole headers property, so what you assembled is thrown away whenever options has headers. Pass { Authorization: 'Bearer x' } and the only header actually sent is Authorization.

Nothing shows when options.headers is absent. It breaks for the first time on the day you write a call that passes headers, which makes it a hard kind of mistake to trace.

Fixing the order is not enough with spread​

Spreading options first avoids the overwrite, but that alone leaves a route where the contents vanish. RequestInit's headers accepts a Headers and [key, value][] besides a plain object. Spreading a Headers gives you nothing — its contents are not laid out as properties. Type checking passes, so it silently disappears on the day you pass one.

Gathering into a Headers preserves all three shapes. The useApi built from chapter 8's useFetch takes options?: RequestInit as its second parameter, renames url to path, and imports apiBaseUrl. On top of that, replace these two places.

src/composables/useApi.ts
function mergeHeaders(init?: HeadersInit) {
// spreading an object loses the contents of a Headers, so gather into a Headers
const headers = new Headers(init)
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
return headers
}

Checking has before setting is to respect a Content-Type the caller specified. Sending FormData correctly means not setting a Content-Type, so that case needs separate handling, such as the caller passing an empty string. Pass it to fetch in this shape.

fetch(`${apiBaseUrl}${toValue(path)}`, {
// spread options first; putting it last overwrites the whole headers property
...options,
signal: controller.signal,
headers: mergeHeaders(options?.headers),
})

apiBaseUrl is prefixed to keep the endpoint in one place. That is the next section.

Switching the endpoint with environment variables​

In Vite you read from import.meta.env. Writing process.env breaks differently in development and in production.

src/config/api.ts
// read the base URL in one place; do not scatter import.meta.env across callers
export const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '/api'
# .env
VITE_API_BASE_URL=https://api.example.com

process.env is replaced with {} only when building a client-targeted app for production. It is not replaced in the dev server, in SSR-targeted builds, or in library builds (build.lib) — and keepProcessEnv lets you change that explicitly.

So process.env.API_BASE_URL breaks differently in different situations. In development that line fails in a browser that has no process, while a production build turns it into {}.API_BASE_URL, so it ships with the fallback endpoint and no error at all. The latter is the nastier one.

process.env.NODE_ENV alone is handled separately, and its replacement covers the dev server in addition to everywhere process.env as a whole is replaced. The value is the NODE_ENV of the environment Vite ran in, or the mode if unset.

Only VITE_-prefixed variables are exposed​

The documentation states "Variables prefixed with VITE_ will be exposed in client-side source code after Vite bundling". Variables without the prefix are unreadable through import.meta.env. That default VITE_ can be changed with the envPrefix option, so the accurate condition is not "it is safe because it has no prefix" but "it is not exposed because it does not match envPrefix". Values passed explicitly through define are exposed too.

import.meta.env has five built-in values readable without a prefix.

KeyContents
MODEThe running mode ('development' / 'production', etc.)
BASE_URLThe base path the app is served from
PRODWhether this is a production build
DEVWhether this is development
SSRWhether it is running on the server

Do not put secrets in VITE_*​

Quoting the documentation's warning directly:

VITE_* variables should not contain sensitive information such as API keys. The values of these variables are bundled into your source code at build time.

They are baked into the source at build time, so even with .env in .gitignore they are readable in the shipped JS. In the build output the reference import.meta.env.VITE_API_BASE_URL is gone and the value itself sits there as a string literal. What the dev server inserts is not literal substitution but an object assignment to import.meta.env. Either way the value reaches the browser. It is not a place for API keys or private keys. Communication that needs a secret should go through a single server-side route that holds the key.

.env precedence​

When the same key exists in several files, the strength order is as follows, with the right being stronger.

.env → .env.local → .env.[mode] → .env.[mode].local

One thing is stronger still. Environment variables already present when Vite runs are not overwritten by the .env files. Pass VITE_API_BASE_URL=... in CI and it takes precedence over the repository's .env.production.

There is a hole in the types​

The type of import.meta.env extends Record<string, any>, so undeclared keys pass through as any.

// neither is a type error
const typo: string = import.meta.env.VITE_API_BAES_URL
const wrong: boolean = import.meta.env.VITE_API_BASE_URL

To close it, declare strictImportMetaEnv in ViteTypeOptions and list the keys you use yourself.

src/env.d.ts
/// <reference types="vite/client" />

interface ViteTypeOptions {
strictImportMetaEnv: unknown
}

interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}

With that in, the typo stops with Property 'VITE_API_BAES_URL' does not exist on type 'ImportMetaEnv'. Did you mean 'VITE_API_BASE_URL'?, and the mistyped assignment stops too. The five built-in keys remain readable without declaring them. The scaffold create-vue produces does not include this declaration, so adding it is your call.

Filling in data on the server​

As seen in chapter 9, in plain Vue SSR renderToString does not wait for a fetch started inside watchEffect. The HTML the server returns contains the loading state.

Nuxt solves this with dedicated composables. There is one axis for choosing between them: whether the result fetched on the server carries over to the client.

What you useWhat it does
$fetchJust makes an HTTP request. State does not carry over
useAsyncDataWraps any async work and hands the result to the client through the payload
useFetchA shortcut for useAsyncData + $fetch

awaiting $fetch directly inside setup fetches twice: once on the server and once on the client during hydration. The documentation explains that "$fetch does not transfer state from the server to the client".

// runs twice under SSR
const twice = await $fetch('/api/item')

// runs once on the server, with the result handed to the client in the payload
const { data: viaAsyncData } = await useAsyncData('item', () => $fetch('/api/item'))

const { data } = await useFetch('/api/item') // the short way to write the same thing

$fetch on its own is fine where hydration does not run it twice: in a button handler, in submission logic, or inside a server route (defineEventHandler). What to avoid is calling $fetch in a component's setup body. Nuxt runs setup on both the server and the client, so the request goes out twice even without await (wrapping it in import.meta.client narrows it to one, but then the server's HTML carries no data).

Read the return value through status​

Among the values useFetch / useAsyncData return, status is the one to read for state.

NameContents
dataA ref holding the result
statusA ref of 'idle' / 'pending' / 'success' / 'error'
errorA ref holding the error on failure
refresh / executeFunctions to refetch
clearA function resetting data to its initial value and status to 'idle'

pending (a Ref<boolean>) is returned too, but the official examples use status. data / status / error are refs, so .value is needed inside <script setup>.

await is not required. The documentation says "On the server, Nuxt waits for the promise to resolve before rendering in either case". Server-side rendering waits either way, so all await changes is whether client-side navigation is blocked. If you do not want it blocked, passing lazy: true states it explicitly.

The same URL is not shared​

Without a key, useFetch generates one automatically per call site. As a result, calling it from another component with the same URL and options shares no state, and each fires its own request. Multiple instances of the same component do share, because the call site is the same.

To share deliberately, state a key.

// pass the same key in both components and the request fires once
const { data } = await useFetch('/api/random', { key: 'random' })

Cookies are not sent during SSR​

Call $fetch from the browser and cookies go along, but during server-side rendering they do not. The documentation cites server-side request forgery (SSRF) and misuse of authentication as the reasons. Hit an authenticated API on the server and you get the logged-out response.

To forward them, go through useRequestFetch().

const requestFetch = useRequestFetch()
const { data } = await useAsyncData(() => requestFetch('/api/cookies'))

When useFetch is called with a relative URL, Nuxt uses useRequestFetch automatically on the server. Headers that should not be forwarded, such as host, are stripped. That is where it diverges from calling $fetch directly.

Nuxt config values go through useRuntimeConfig()​

In Nuxt you use runtimeConfig rather than import.meta.env. The difference is that the exposure is decided by the structure.

export default defineNuxtConfig({
runtimeConfig: {
// directly under the root is server-only
apiSecret: '',
public: {
// under public it also reaches the client
apiBase: '/api',
},
},
})

Read it by calling useRuntimeConfig(). In a server route, pass the event.

// from a component or composable
useRuntimeConfig().public.apiBase

// from a server route (passing event applies environment-variable overrides)
export default defineEventHandler((event) => {
const secret = useRuntimeConfig(event).apiSecret
return $fetch('https://api.example.com/me', {
headers: { Authorization: `token ${secret}` },
})
})

The rule for overriding through environment variables is "an uppercase name starting with NUXT_, with both key boundaries and case changes turned into _". public.apiBase becomes NUXT_PUBLIC_API_BASE, and apiSecret becomes NUXT_API_SECRET.

Values are converted to JavaScript types automatically by destr. NUXT_MY_VAR=4848e0 becomes the number 4848, so to keep it a string, include double quotes in the value itself.

Everything under public reaches the client, exactly like Vite's VITE_*. Keep secrets directly under the root and read them only from server-side routes.

Before adding axios​

In Nuxt, $fetch is what the documentation recommends.

$fetch is the preferred way to make HTTP calls in Nuxt instead of @nuxt/http and @nuxtjs/axios that are made for Nuxt 2

$fetch is ofetch placed on the global, and hitting your own server route during SSR calls the corresponding function directly rather than going over HTTP.

If you use axios anyway, the two things that catch people out when putting it in a Nuxt plugin are these.

First, where it runs. Files in app/plugins/ run on both the server and the client by default. To restrict to one side, add a .client / .server suffix. So writing localStorage or window inside an interceptor falls over the moment a request goes out on the server.

// returns undefined on the server; reading it bare gives a ReferenceError
const token = typeof localStorage === 'undefined' ? null : localStorage.getItem('token')

Through globalThis.localStorage the value is undefined, so you get a TypeError. Both stop the render. If you need credentials on the server in the first place, forward cookies with useRequestFetch().

The other is how it is written. Plugins are written with defineNuxtPlugin(). Put a file in app/plugins/ that merely export defaults an axios instance and Nuxt tries to register it as a plugin. To just hold a shared instance, use app/utils/ or a composable.

export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
const api = $fetch.create({ baseURL: config.public.apiBase })
return { provide: { api } }
})

Summary​

  • abort() stops a request only on routes where the fetching side honours the signal. The signal.aborted guard, meanwhile, works independently of whether the request stopped — abort() sets the flag on the spot. Even if your own wrapper forgets to pass the signal, the stale result is not written
  • Rejection is possible because an AbortController dedicated to that run sits in the closure. In a function called imperatively, hold the previous controller, abort() it, and create a new one. Replacing signal.aborted with a generation counter cannot reject the cleanup, leaving an AbortError in error after unmount
  • Spread the caller's options first when composing. Spreading it last overwrites the whole headers property and loses the Content-Type you assembled. Spreading a Headers also gives you nothing, so gather headers into new Headers(options?.headers). Neither shows on calls without options.headers
  • In Vite, read from import.meta.env. process.env is replaced with {} only when building a client-targeted app for production. process.env.X || fallback raises no error and ships with the fallback. It is not replaced in the dev server, SSR builds, or library builds (process.env.NODE_ENV is replaced in the dev server too)
  • What import.meta.env exposes is whatever matches envPrefix (default VITE_), plus the built-in MODE / BASE_URL / PROD / DEV / SSR. Changing the prefix changes the range, and values passed through define are exposed too. The presence or absence of a prefix is not itself a safety argument
  • Do not put API keys in VITE_*. The values are baked into the source at build time and are readable in the shipped JS
  • .env strength runs .env → .env.local → .env.[mode] → .env.[mode].local, and environment variables already present at run time are stronger than any of them
  • There is a hole in import.meta.env's types. By default undeclared keys pass as any, so neither typos nor wrong types are caught. Closing it needs both the strictImportMetaEnv declaration in ViteTypeOptions and the keys listed in ImportMetaEnv. Declaring strictImportMetaEnv alone without listing the keys makes VITE_* references fail (the five built-in keys are declared separately and survive). The create-vue scaffold does not include it
  • In Nuxt, calling $fetch inside setup fetches twice under SSR (with or without await). What carries the result over is useAsyncData and its shorthand useFetch. Inside handlers and server routes, use $fetch directly
  • Read useFetch state through status ('idle' / 'pending' / 'success' / 'error'). pending is returned too, but the official examples use status. await is not required; all it changes is whether client-side navigation is blocked
  • Without a key, one is generated per call site, so the same URL called from another component is not shared. State a key to share
  • $fetch does not forward cookies during SSR. Go through useRequestFetch() to forward them. Nuxt uses it automatically when useFetch is given a relative URL
  • Read Nuxt config with useRuntimeConfig(); directly under the root is server-only and under public also reaches the client. Environment overrides are NUXT_ plus uppercase, with both key boundaries and case changes turned into _ (public.apiBase → NUXT_PUBLIC_API_BASE). Values are type-converted by destr, so a string that looks like a number becomes one
  • Nuxt plugins run on both sides by default (a .client / .server suffix narrows them). Writing localStorage or window in an interceptor falls over on the server. Write plugins with defineNuxtPlugin(), and use app/utils/ or a composable to merely hold a shared instance
Related references