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 thesignal.abortedguard works independently of it - The traps in composing
fetchoptions: the order of spreading, and that spreading aHeadersgives you nothing - Vite's
import.meta.env— what is exposed,.envprecedence, and the hole in its types - That
process.envis replaced with{}in client builds, and why secrets must not go inVITE_* - 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.
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.
// 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.
| Key | Contents |
|---|---|
MODE | The running mode ('development' / 'production', etc.) |
BASE_URL | The base path the app is served from |
PROD | Whether this is a production build |
DEV | Whether this is development |
SSR | Whether 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.
/// <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 use | What it does |
|---|---|
$fetch | Just makes an HTTP request. State does not carry over |
useAsyncData | Wraps any async work and hands the result to the client through the payload |
useFetch | A 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.
| Name | Contents |
|---|---|
data | A ref holding the result |
status | A ref of 'idle' / 'pending' / 'success' / 'error' |
error | A ref holding the error on failure |
refresh / execute | Functions to refetch |
clear | A 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.
$fetchis the preferred way to make HTTP calls in Nuxt instead of@nuxt/httpand@nuxtjs/axiosthat 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 thesignal. Thesignal.abortedguard, 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
AbortControllerdedicated to that run sits in the closure. In a function called imperatively, hold the previous controller,abort()it, and create a new one. Replacingsignal.abortedwith a generation counter cannot reject the cleanup, leaving anAbortErrorinerrorafter unmount - Spread the caller's
optionsfirst when composing. Spreading it last overwrites the wholeheadersproperty and loses theContent-Typeyou assembled. Spreading aHeadersalso gives you nothing, so gather headers intonew Headers(options?.headers). Neither shows on calls withoutoptions.headers - In Vite, read from
import.meta.env.process.envis replaced with{}only when building a client-targeted app for production.process.env.X || fallbackraises no error and ships with the fallback. It is not replaced in the dev server, SSR builds, or library builds (process.env.NODE_ENVis replaced in the dev server too) - What
import.meta.envexposes is whatever matchesenvPrefix(defaultVITE_), plus the built-inMODE/BASE_URL/PROD/DEV/SSR. Changing the prefix changes the range, and values passed throughdefineare 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 .envstrength 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 asany, so neither typos nor wrong types are caught. Closing it needs both thestrictImportMetaEnvdeclaration inViteTypeOptionsand the keys listed inImportMetaEnv. DeclaringstrictImportMetaEnvalone without listing the keys makesVITE_*references fail (the five built-in keys are declared separately and survive). Thecreate-vuescaffold does not include it - In Nuxt, calling
$fetchinside setup fetches twice under SSR (with or withoutawait). What carries the result over isuseAsyncDataand its shorthanduseFetch. Inside handlers and server routes, use$fetchdirectly - Read
useFetchstate throughstatus('idle'/'pending'/'success'/'error').pendingis returned too, but the official examples usestatus.awaitis 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
keyto share $fetchdoes not forward cookies during SSR. Go throughuseRequestFetch()to forward them. Nuxt uses it automatically whenuseFetchis given a relative URL- Read Nuxt config with
useRuntimeConfig(); directly under the root is server-only and underpublicalso reaches the client. Environment overrides areNUXT_plus uppercase, with both key boundaries and case changes turned into_(public.apiBase→NUXT_PUBLIC_API_BASE). Values are type-converted bydestr, so a string that looks like a number becomes one - Nuxt plugins run on both sides by default (a
.client/.serversuffix narrows them). WritinglocalStorageorwindowin an interceptor falls over on the server. Write plugins withdefineNuxtPlugin(), and useapp/utils/or a composable to merely hold a shared instance
- Vite — Env Variables and Modes — the
VITE_prefix,.envprecedence, andstrictImportMetaEnv - Nuxt — Data Fetching — choosing between
useFetch/useAsyncData/$fetch - Nuxt —
$fetch— double fetching and header forwarding under SSR - Nuxt —
useRuntimeConfig— public / private and theNUXT_prefix
What to read next
- Nuxt basics — the foundation the composables here run on
- Authentication and deployment — where the token lives, and production configuration