In-depth

The Problem & Solution

Why standard Nuxt data fetching leaks server code into the client bundle during SSG, and how Nuxt Prerender Kit eliminates it.

The Problem

Static site generation (SSG) creates an inherent tension: you need server-only code — database clients, CMS SDKs, filesystem access — at build time, but that code must never ship to the browser.

Consider a naive approach using useAsyncData with a server import:

import { db } from '~/server/db'

const { data } = await useAsyncData('posts', () => db.getPosts())

Even though the handler only runs on the server, the static import is still bundled into the client — and because it's a static import, the browser will download the module too. The bundler sees a top-level import and assumes the code depends on it, so it includes ~/server/db and all its dependencies in the client bundle:

  Source Code                         Client Bundle
┌──────────────────────┐       ┌────────────────────────────┐
│ import { db } from   │       │ import { db } from         │
│   '~/server/db'      │  ──▶  │   '~/server/db'   <-- !!   │
│                      │       │                            │
│ useAsyncData(...)    │       │ useAsyncData(...)          │
└──────────────────────┘       └────────────────────────────┘

The result: broken builds, bloated bundles, or leaked server internals.

Why Built-in Solutions Fall Short

useAsyncData

No compile-time elimination — server imports are still bundled into and downloaded by the client.

import.meta.env.SSR / process.server

Runtime guards only. The code is bundled but conditionally skipped, so server modules still end up in the client.

Experimental extractAsyncDataHandlers

extractAsyncDataHandlers extracts inline useAsyncData handlers into separate chunks so they can be tree-shaken from the client bundle on prerendered sites. This is the closest built-in alternative, but it still falls short:

  • Handlers are extracted, not eliminated. The handler is moved into a separate chunk that the client still dynamically imports. Server code inside the handler (and any static imports it uses) remains downloadable by the browser.
  • Static imports are still shared. If you import { db } from '~/server/db' at the top of your file, the server module is bundled into the extracted chunk and downloaded by the client.
  • Inline handlers only. It cannot extract a variable reference like useAsyncData('key', myHandler) — only inline arrow/function expressions are supported.

How Nuxt Prerender Kit Solves It

You could achieve similar dead-code elimination yourself by wrapping useAsyncData with an import.meta.prerender guard:

const { data } = await useAsyncData('posts',
  import.meta.prerender
    ? async () => {
        const { db } = await import('~/server/db')
        return db.getPosts()
      }
    : () => { throw new Error('prerender only') }
)
// + null/error checking every time…

But you'd have to repeat this boilerplate for every data-fetching call, and it's easy to get wrong. usePrerenderData wraps this into a single composable — you write the clean version, and a Vite plugin produces the guarded version automatically.

Step 1 — You write

Wrap your server-only logic in usePrerenderData with a dynamic import inside the handler:

const { data } = await usePrerenderData('posts', async () => {
  const { db } = await import('~/server/db')
  return db.getPosts()
})

Step 2 — Vite plugin transforms

At build time, a Vite plugin detects usePrerenderData calls and wraps the handler with an import.meta.prerender guard:

  usePrerenderData('key',
+   import.meta.prerender // Will be replaced with `false` in client build time
      ? handler
+     : __neverReachable_prerender()
  )

Step 3 — Bundler eliminates dead code

Nuxt produces two bundles — server and client. The import.meta.prerender flag behaves differently in each:

  Transformed Code
  usePrerenderData('key',
    import.meta.prerender ? handler : __neverReachable())
         │
         ├─── Server bundle
         │    import.meta.prerender is a runtime value (set by Nitro)
         │
         │    ├── Prerender (build time): true
         │    │   → handler runs, data saved to payload ✅
         │    │
         │    └── SSR request (wrong usage): false
         │        → __neverReachable() throws error ❌
         │
         └─── Client bundle
              import.meta.prerender = false (compile-time constant)
              → handler + dynamic import removed by DCE ✅

In the client bundle, import.meta.prerender is statically replaced with false at compile time. The bundler sees false ? handler : ... and removes the entire handler branch — including the dynamic import('~/server/db') — through Dead Code Elimination (DCE). The remaining branch, __neverReachable(), returns a placeholder function that useAsyncData receives as the handler but never calls — on prerendered pages, the data is already in the payload.

Step 4 — Result

Zero server code in the client bundle. The entire handler branch is removed by DCE:

  usePrerenderData('posts',
-   async () => {
-     const { db } = await import('~/server/db')
-     return db.getPosts()
-   }
+   __neverReachable_prerender()
  )

Because import('~/server/db') lives inside the dead-code branch, the bundler never even resolves the module — ~/server/db and all its dependencies are completely excluded from the client bundle.

This is why you should always use dynamic imports (import()) instead of static imports (import ... from) to reference server-only code inside the handler.

Copyright © 2026