Introduction

When performing Static Site Generation (SSG) with Nuxt 4, there are cases where you want to load data from local JSON files to generate static pages. However, unlike Next.js’s getStaticProps, it is not straightforward, and there are several pitfalls to watch out for.

This article introduces the correct approach discovered through trial and error.

The Problem: Why Simple fs Reading Does Not Work

The First Approach We Tried (Failed)

// This does not work
const fetchLocalData = async (filePath: string) => {
  if (import.meta.server) {
    const fs = await import('fs');
    const path = await import('path');
    const fullPath = path.resolve(process.cwd(), 'public/data', filePath);
    const data = fs.readFileSync(fullPath, 'utf-8');
    return JSON.parse(data);
  }
  // Client-side
  const response = await fetch(`/data/${filePath}`);
  return await response.json();
};

This approach has the following problems:

  1. process.cwd() differs across build environments: The working directory differs between local development and build environments such as Vercel
  2. Files are not found during Nitro pre-rendering: During SSG, Nitro operates in its own context
  3. Without useAsyncData, the code also runs on the client side: This defeats the purpose of SSG

Solution: Nitro Storage API + Server API Routes + useAsyncData

Architecture

[During SSG Build]
Page Component
    | useAsyncData
Server API Route (/api/local-data/[...path])
    | useStorage (Nitro Storage API)
public/data/*.json

[Generated HTML]
Data is embedded in _payload.json
-> No need to load JSON on the client side

Step 1: Configure serverAssets in nuxt.config.ts

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    prerender: {
      crawlLinks: true,
      failOnError: false,
    },
    // Mount public/data as server assets via the Nitro Storage API
    serverAssets: [{
      baseName: 'data',
      dir: './public/data'
    }],
  },
  routeRules: {
    '/**': { prerender: true },
  },
});

Step 2: Create a Server API Route (Nitro Storage + fs Fallback)

// server/api/local-data/[...path].ts
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';

export default defineEventHandler(async (event) => {
  const pathParam = getRouterParam(event, 'path');
  if (!pathParam) {
    throw createError({ statusCode: 400, message: 'Path is required' });
  }

  const filePath = Array.isArray(pathParam) ? pathParam.join('/') : pathParam;

  // Security: prevent path traversal
  if (filePath.includes('..')) {
    throw createError({ statusCode: 400, message: 'Invalid path' });
  }

  // Use the Nitro Storage API
  // Access the 'data' storage defined in serverAssets in nuxt.config.ts
  const storage = useStorage('assets:data');

  // Convert the file path to a storage key (replace / with :)
  const storageKey = filePath.replace(/\//g, ':');

  // Attempt to retrieve via the Storage API
  if (await storage.hasItem(storageKey)) {
    const data = await storage.getItem(storageKey);
    return data;
  }

  // Fallback: read directly via fs for development environments
  const fsPath = resolve(process.cwd(), 'public/data', filePath);
  if (existsSync(fsPath)) {
    const data = readFileSync(fsPath, 'utf-8');
    return JSON.parse(data);
  }

  throw createError({ statusCode: 404, message: `File not found: ${filePath}` });
});

Benefits of using the Nitro Storage API:

  • Stable operation in build environments such as Vercel
  • Avoids path separator issues across different operating systems
  • Nitro optimizes file access according to the environment

Why the fs fallback is needed:

In development mode (npm run dev), there are cases where serverAssets does not fully work, so direct fs reading is added as a fallback. During builds, the Storage API takes priority.

Step 3: Wrap in a Composable

// composables/useLocalData.ts
export const useLocalData = () => {
  const fetchLocalData = async (filePath: string) => {
    try {
      if (import.meta.server) {
        // During SSG/SSR: use the Server API
        return await $fetch(`/api/local-data/${filePath}`);
      } else {
        // Client-side: fetch directly from public
        // On static hosting (S3, Netlify, etc.), the Server API does not exist
        const response = await fetch(`/data/${filePath}`);
        if (!response.ok) return null;
        return await response.json();
      }
    } catch (error) {
      console.error(`Error loading: ${filePath}`, error);
      return null;
    }
  };

  return { fetchLocalData };
};

Why the client-side fallback is important:

When deploying a static site generated with SSG to S3, Netlify, or similar services, the Server API routes do not exist. Therefore, during client-side navigation, JSON must be fetched directly from /data/.

Step 4: Use useAsyncData in the Page Component

script setup lang="ts">
const { fetchLocalData } = useLocalData();

// Always wrap with useAsyncData
const { data: pageData } = await useAsyncData('my-page-data', async () => {
  const result = await fetchLocalData('jsonapi/node/posts.json');
  return result;
});

// Access safely with computed
const posts = computed(() => pageData.value?.data || []);
script>

template>
  div v-for="post in posts" :key="post.id">
    {{ post.title }}
  div>
template>

Important Points

1. useAsyncData Is Required

If you await without useAsyncData, the code will also execute on the client side.

// Bad example
const data = await fetchLocalData('posts.json');

// Correct example
const { data } = await useAsyncData('key', () => fetchLocalData('posts.json'));

2. Keys Must Be Unique

The first argument (key) of useAsyncData must be unique across pages and components.

// Use a unique key for each page
const { data } = await useAsyncData(`post-${route.params.id}`, async () => {
  return await fetchLocalData(`posts/${route.params.id}.json`);
});

3. Access Safely with computed

Since the return value of useAsyncData is a Ref, it is safer to wrap it with computed when using it in templates or logic.

const { data: pageData } = await useAsyncData(...);

// Wrap with computed
const title = computed(() => pageData.value?.title || '');
const items = computed(() => pageData.value?.items || []);

4. Watch Out for Variable Scope

Variables defined inside the useAsyncData callback cannot be used outside the callback.

// Bad example
const { data } = await useAsyncData('key', async () => {
  const types = ['a', 'b']; // Defined inside the callback
  return await fetchData();
});
const items = ['x', 'y', ...types]; // Error: types is not defined

// Correct example
const types = ['a', 'b']; // Defined outside the callback
const { data } = await useAsyncData('key', async () => {
  return await fetchData();
});
const items = ['x', 'y', ...types]; // OK

Results

With static sites generated using this method:

  1. Data is embedded in HTML: Fast initial display
  2. Data is stored in _payload.json: Used during client-side navigation
  3. No requests to the original JSON files occur: Reduced network load
.output/public/
├── posts/
│   ├── index.html          # Pre-rendered HTML
│   └── _payload.json       # Serialized data (useAsyncData results)
└── data/
    └── jsonapi/
        └── node/
            └── posts.json  # Original JSON file (not needed after SSG)

Alternative Approaches

For Small JSON Files: Direct Import

If the file size is small (under 50 KB) and dynamic paths are not needed, direct import is the simplest approach.

// For small static JSON files
import config from '~/assets/data/config.json';

// TypeScript type inference also works
console.log(config.title);

When Content Management Is the Primary Goal: Nuxt Content

For sites that handle markdown or JSON content, the Nuxt Content module is a good fit.

// Using Nuxt Content
const { data } = await useAsyncData('posts', () =>
  queryContent('posts').find()
);

Summary

To combine SSG with local JSON in Nuxt 4:

  1. Use the Nitro Storage API to load JSON via Server API routes
  2. Always wrap with useAsyncData
  3. Access data safely with computed
  4. Remember to implement a client-side fallback

With this approach, all data is prefetched at build time, preventing additional API calls on the client side.

References