Skip to content

Fix: Nuxt Not Working — useFetch Returns Undefined, Server Route 404, or Hydration Mismatch

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 that don’t exist in plain Vue or in client-only frameworks. The first instinct when a page is broken is to “check the page” — open the component, log values, tweak the template — but most Nuxt bugs aren’t in the page at all. They’re in the layer between the page and the Nitro server: auto-imports, the Nuxt context boundary, hydration serialization, or the file-based server route resolution.

Auto-imports in particular cause more silent failures than any other Nuxt feature. A composable in composables/ is auto-registered; the same function in utils/ is auto-imported as a value but not as a composable; the same function in a sibling directory may not be imported at all unless nuxt.config.ts widens the scan path. When TypeScript reports the symbol as missing, you may end up adding an explicit import that conflicts with the auto-import and produces a confusing duplicate-symbol error at build time, or worse, silently shadows the real composable with an unrelated one.

The other big source of Nuxt bugs is Pinia store hydration. The store’s state must serialize cleanly into the Nuxt payload to survive the server-to-client jump, and any non-serializable value (a class instance, a Date, a Map) silently turns into {} on the client without an error.

  • useFetch data is a Ref, not a plain valuedata is Ref<T | null>. Access it with .value. During SSR it’s populated; before hydration completes it may still be null on the client.
  • Server routes live in server/api/ — Nuxt’s file-based server routing requires files in server/api/. A file in server/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, window access) causes hydration errors. Content must be identical on both sides.
  • Composables using useNuxtApp() require a Nuxt context — you can’t call Nuxt composables outside of setup(), plugins, or middleware. Calling them in a regular function or after await without preserving the async context causes the “outside of setup” error.

Diagnostic Timeline

A typical Nuxt “it just doesn’t work” investigation usually moves through these checkpoints:

Minute 0 — first guess: check the page. You open the page component, log data.value, and it’s null after hydration. You assume the page is wrong, swap useFetch for $fetch, and now the page renders but flickers as the data arrives on the client. The page was never the problem — useFetch was correctly populating during SSR, but a stale auto-imports cache was pointing at an old version of the composable that didn’t return what you expected.

Minute 5 — wipe the auto-imports cache. Stop the dev server, delete .nuxt/ entirely (rm -rf .nuxt), and restart. Auto-imports are generated into .nuxt/imports.d.ts and .nuxt/types/, and they go stale when you move or rename files. Many bugs labeled “useFetch returns undefined” or “composable not defined” are really the cache pointing at a deleted file.

Minute 9 — check nuxt.config.ts TypeScript paths. If you have a tsconfig.json extends chain or custom compilerOptions.paths, Nuxt’s generated .nuxt/tsconfig.json may not include the directories you expect. Run nuxi prepare and look at the generated tsconfig to confirm composables/, utils/, and server/utils/ are all in the include list. Custom srcDir or layered Nuxt projects need explicit imports.dirs config.

Minute 14 — Pinia store hydration drops state. If you’re using @pinia/nuxt, open the rendered HTML and search for __NUXT__. The Pinia state should be inside that JSON blob. If it’s missing, the store was created outside the Nuxt context (often in a plain utility module). If it’s there but values are null on the client, the store contains non-serializable types — classes, Map, Set, or circular references. Replace those with plain objects before storing.

Minute 20 — server route 404 is really a Nitro mismatch. If server/api/users.ts returns 404 in production but works in dev, your deployment preset is wrong. Static presets (nuxt generate) don’t ship server routes at all. Confirm with nuxi info that you’re using node-server, cloudflare-pages, or another preset that actually runs Nitro. For Cloudflare Pages, server routes ship as Pages Functions and require the project to be linked to a Pages deployment, not a Workers deployment.

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.

definePageMeta ignored after refactordefinePageMeta is a compile-time macro that only works when called directly in the top level of <script setup> inside pages/. If you move it into a helper function or into a layout, the macro is silently dropped and your page meta (middleware, layout, name) reverts to defaults. Same with useHead if you assign it to a variable that’s exported from a non-component module — Nuxt only runs the head update when it’s called from a component setup.

navigateTo from a button click does nothingnavigateTo returns a promise that you must await, but when called from a regular event handler with no async wrapper, it resolves silently if no navigation actually happens. Common cause: external: true is missing for cross-origin redirects, or middleware blocks the navigation and Nuxt swallows the rejection. Wrap the call in an async handler and log the resolved value to confirm the route engine even attempted the navigation.

Dev server fast refresh kills the Nitro layer — editing server/api/ or server/middleware/ triggers a partial reload, and certain plugins (Prisma, database clients) hold sockets that don’t get torn down. Subsequent requests hang or return “client is closed.” Restart the dev server entirely when changing files under server/, or wrap your database client in nitroApp.hooks.hookOnce('close', ...) to release connections on hot reload.

For related Vue and SSR issues, see Fix: Vue Router Params Not Updating, Fix: Vue Composable Not Reactive, Fix: Pinia Store Not Working, and Fix: Nitro Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles