Skip to content

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

FixDevs ·

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:

  • 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.

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.

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