Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch
Quick Answer
How to fix Nuxt 3 issues — useFetch vs $fetch, server routes in server/api, composable SSR rules, useAsyncData, hydration errors, and Nitro configuration problems.
The Problem
useFetch returns null or undefined on first render:
<script setup>
const { data: users } = await useFetch('/api/users');
console.log(users.value); // null — even after the page loads
</script>Or a server route returns 404 despite the file existing:
GET http://localhost:3000/api/users → 404 Not Found
# server/api/users.ts exists — why doesn't it work?Or hydration errors appear in the console:
[Vue warn]: Hydration text content mismatch in <p>
Server rendered: "2024-01-15"
Client rendered: "2024-03-27"Or a composable works in a component but throws in a page:
[nuxt] A composable that requires access to the Nuxt instance was called outside of a plugin, Nuxt hook, Nuxt middleware, or Vue setup function.Why This Happens
Nuxt 3 blurs the line between server and client, which creates specific constraints:
useFetchdata is aRef, not a plain value —dataisRef<T | null>. Access it with.value. During SSR it’s populated; before hydration completes it may still benullon the client.- Server routes live in
server/api/— Nuxt’s file-based server routing requires files inserver/api/. A file inserver/routes/uses a different pattern. The filename determines the URL. - Hydration mismatches from server/client differences — any value that differs between SSR and client (dates, random IDs,
windowaccess) causes hydration errors. Content must be identical on both sides. - Composables using
useNuxtApp()require a Nuxt context — you can’t call Nuxt composables outside ofsetup(), plugins, or middleware. Calling them in a regular function or afterawaitwithout preserving the async context causes the “outside of setup” error.
Fix 1: Use useFetch and useAsyncData Correctly
<script setup lang="ts">
// useFetch — simplified wrapper around useAsyncData + $fetch
const { data: users, status, error, refresh } = await useFetch('/api/users');
// data is Ref<User[] | null>
// Access the value — always use .value
if (users.value) {
console.log(users.value.length);
}
// Template — Vue auto-unwraps refs
</script>
<template>
<div v-if="status === 'pending'">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
<!-- users is auto-unwrapped in template — no .value needed -->
</ul>
<button @click="refresh()">Reload</button>
</template>useFetch options for common needs:
<script setup lang="ts">
// Pass query params
const page = ref(1);
const { data } = await useFetch('/api/users', {
query: { page, limit: 20 }, // Reactive — refetches when page changes
});
// Transform the response
const { data: userNames } = await useFetch('/api/users', {
transform: (users: User[]) => users.map(u => u.name),
});
// Conditional fetching — skip if no id
const id = computed(() => route.params.id);
const { data: user } = await useFetch(() => `/api/users/${id.value}`, {
watch: [id], // Refetch when id changes
});
// POST request
const { data } = await useFetch('/api/users', {
method: 'POST',
body: { name: 'Alice', email: '[email protected]' },
});
// Lazy fetch — don't block page navigation
const { data, pending } = useLazyFetch('/api/slow-endpoint');
// Page renders immediately; data fills in when ready
// useAsyncData for more control
const { data: config } = await useAsyncData('app-config', async () => {
const result = await $fetch('/api/config');
return result;
}, {
server: true, // Fetch on server only
// server: false, // Fetch on client only
default: () => ({}), // Default value before data loads
});$fetch for programmatic requests (not SSR data loading):
<script setup lang="ts">
// $fetch — for user-triggered requests (form submission, button click)
// NOT for initial page data — use useFetch for that
async function createUser(formData: CreateUserInput) {
try {
const newUser = await $fetch('/api/users', {
method: 'POST',
body: formData,
});
await navigateTo(`/users/${newUser.id}`);
} catch (error) {
console.error(error);
}
}
</script>Fix 2: Create Server Routes Correctly
Nuxt’s server directory structure determines API paths:
server/
├── api/ # /api/* routes
│ ├── users.ts → GET /api/users
│ ├── users.post.ts → POST /api/users
│ ├── users/
│ │ └── [id].ts → GET /api/users/:id
│ └── users/
│ └── [id].delete.ts → DELETE /api/users/:id
├── routes/ # Non-/api/* routes
│ └── sitemap.xml.ts → GET /sitemap.xml
└── middleware/ # Server middleware (runs on every request)
└── auth.ts// server/api/users.ts — GET /api/users
import { defineEventHandler, getQuery } from 'h3';
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { page = 1, limit = 20 } = query;
const users = await db.users.findMany({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
});
return users; // Automatically serialized to JSON
});
// server/api/users.post.ts — POST /api/users
import { defineEventHandler, readBody } from 'h3';
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// Validate
if (!body.name || !body.email) {
throw createError({
statusCode: 400,
statusMessage: 'Name and email are required',
});
}
const user = await db.users.create({ data: body });
setResponseStatus(event, 201);
return user;
});
// server/api/users/[id].ts — GET /api/users/:id
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
const user = await db.users.findUnique({ where: { id } });
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'User not found' });
}
return user;
});
// server/middleware/auth.ts — runs before all routes
export default defineEventHandler(async (event) => {
// Skip auth for public routes
if (event.path.startsWith('/api/public')) return;
const token = getCookie(event, 'session') || getHeader(event, 'authorization');
if (!token) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}
});Fix 3: Fix Hydration Mismatches
Hydration errors happen when the server and client render different HTML:
<!-- WRONG — Date.now() differs between server and client -->
<template>
<p>Current time: {{ new Date().toLocaleTimeString() }}</p>
</template>
<!-- CORRECT — use ClientOnly for client-specific content -->
<template>
<ClientOnly>
<p>Current time: {{ new Date().toLocaleTimeString() }}</p>
<template #fallback>
<p>Loading time...</p>
</template>
</ClientOnly>
</template><!-- WRONG — Math.random() differs each render -->
<script setup>
const id = Math.random().toString(36); // Different on server vs client
</script>
<!-- CORRECT — use useId() for stable IDs -->
<script setup>
const id = useId(); // Consistent between server and client
</script>
<!-- WRONG — window access throws on server -->
<script setup>
const width = window.innerWidth; // ReferenceError on server
</script>
<!-- CORRECT — check if running on client -->
<script setup>
const width = ref(0);
onMounted(() => {
width.value = window.innerWidth; // Only runs client-side
});
</script>Suppress hydration mismatch for known differences:
<template>
<!-- v-if with false suppresses SSR — renders only on client -->
<div v-if="false">SSR placeholder</div>
<div v-else>Client content</div>
<!-- Or use the attribute to suppress the warning -->
<!-- Use only when the mismatch is intentional and harmless -->
<span suppressHydrationWarning>{{ clientOnlyValue }}</span>
</template>Fix 4: Composables and the Nuxt Context
Nuxt composables must be called within the Nuxt lifecycle:
// WRONG — called after an await (async context lost in some cases)
async function fetchAndProcess() {
const data = await fetch('/api/data');
const config = useRuntimeConfig(); // May throw — context lost after await
return processWithConfig(data, config);
}
// CORRECT — capture composable values before awaiting
async function fetchAndProcess() {
const config = useRuntimeConfig(); // Capture in sync context
const data = await fetch('/api/data');
return processWithConfig(data, config);
}
// CORRECT — use in setup() directly
const config = useRuntimeConfig();
const { data } = await useFetch('/api/data', {
// Use config here — still in sync context
headers: { 'X-API-Key': config.apiKey },
});Writing SSR-safe composables:
// composables/useLocalStorage.ts
export function useLocalStorage<T>(key: string, defaultValue: T) {
// process.client is false on server, true in browser
const stored = process.client ? localStorage.getItem(key) : null;
const value = ref<T>(stored ? JSON.parse(stored) : defaultValue);
watch(value, (newValue) => {
if (process.client) {
localStorage.setItem(key, JSON.stringify(newValue));
}
});
return value;
}
// composables/useWindowSize.ts
export function useWindowSize() {
const width = ref(0);
const height = ref(0);
if (process.client) {
width.value = window.innerWidth;
height.value = window.innerHeight;
useEventListener('resize', () => {
width.value = window.innerWidth;
height.value = window.innerHeight;
});
}
return { width, height };
}Fix 5: Runtime Config and Environment Variables
// nuxt.config.ts — define runtime config
export default defineNuxtConfig({
runtimeConfig: {
// Private keys — server only (process.env.NUXT_DATABASE_URL)
databaseUrl: '',
jwtSecret: '',
// Public keys — exposed to client (process.env.NUXT_PUBLIC_API_BASE)
public: {
apiBase: '',
gaId: '',
},
},
});
// .env
NUXT_DATABASE_URL=postgresql://localhost/mydb
NUXT_JWT_SECRET=my-secret
NUXT_PUBLIC_API_BASE=https://api.example.com
NUXT_PUBLIC_GA_ID=G-XXXXXXXXXX<script setup lang="ts">
// Access in components (public only)
const config = useRuntimeConfig();
console.log(config.public.apiBase); // Available client + server
// console.log(config.databaseUrl); // Undefined on client — server only
// Access in server routes (all keys available)
// server/api/data.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event); // Pass event for server context
const db = await connect(config.databaseUrl); // Private key
});
</script>Fix 6: Nuxt Modules and Plugin Setup
// nuxt.config.ts — configure modules
export default defineNuxtConfig({
modules: [
'@nuxtjs/tailwindcss',
'@pinia/nuxt',
'@nuxtjs/i18n',
'nuxt-icon',
],
// Module-specific config
pinia: {
storesDirs: ['./stores/**'], // Auto-import stores
},
i18n: {
locales: ['en', 'ja'],
defaultLocale: 'en',
vueI18n: './i18n.config.ts',
},
});
// plugins/myPlugin.ts — runs on both server and client
export default defineNuxtPlugin((nuxtApp) => {
// Add global properties
nuxtApp.vueApp.config.globalProperties.$format = (date: Date) =>
date.toLocaleDateString();
// Provide to composables
return {
provide: {
format: (date: Date) => date.toLocaleDateString(),
},
};
});
// plugins/clientOnly.client.ts — .client.ts = client only
export default defineNuxtPlugin(() => {
// window/document are safe here
window.addEventListener('offline', () => {
console.log('Network offline');
});
});
// plugins/serverOnly.server.ts — .server.ts = server only
export default defineNuxtPlugin(() => {
// Only runs during SSR
});
// Use provide in components
<script setup>
const { $format } = useNuxtApp();
const formatted = $format(new Date());
</script>Still Not Working?
useFetch fires twice (once server, once client) — this is expected behavior. Nuxt fetches data on the server, serializes it into the HTML payload, and the client reuses it without re-fetching. If you see two actual network requests, check the key option: useFetch with the same key deduplicates; if you’re using useAsyncData without a unique key, it may re-fetch on the client.
Server route works in dev but 404 in production — ensure your production server actually runs Nuxt’s server engine (Nitro). With nuxt generate (static output), server routes don’t exist — they’re only available with nuxt build (server mode). For static hosting, either use client-side fetching or nuxt generate with a separate API server.
Pinia store state lost between pages — Nuxt resets the Pinia store on every server-side render by default. Use pinia.useHydratedStore() or configure the Nuxt Pinia module with persistedstate: true to persist across navigations. For user session data, use useState() instead of Pinia — it serializes state into the Nuxt payload automatically.
For related Vue issues, see Fix: Vue Router Params Not Updating and Fix: Vue Composable Not Reactive.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.
Fix: date-fns Not Working — Wrong Timezone Output, Invalid Date, or Locale Not Applied
How to fix date-fns issues — timezone handling with date-fns-tz, parseISO vs new Date, locale import and configuration, DST edge cases, v3 ESM migration, and common format pattern mistakes.
Fix: HTMX Not Working — hx-get Request Not Firing, Swap Not Updating DOM, or Response Ignored
How to fix HTMX issues — attribute syntax, target and swap strategies, out-of-band swaps, event handling, CSP configuration, response headers, and debugging HTMX requests.