Skip to main content

Build-Time Data Loading

VitePress provides a powerful data loader feature that loads arbitrary data at build time, serializes it as JSON, and makes it available to your pages and components.

Overview

Data loaders enable you to:
  • Fetch data from remote APIs
  • Generate metadata from local files
  • Parse content collections (blog posts, docs, etc.)
  • Build search indexes
  • Create dynamic navigation
Data loading happens only at build time. The resulting data is serialized as JSON and inlined in the client bundle.

Basic Usage

A data loader file must end with .data.js or .data.ts and export a default object with a load() method.
1

Create Data Loader

// posts.data.ts
export default {
  load() {
    return {
      posts: [
        { title: 'First Post', slug: 'first-post' },
        { title: 'Second Post', slug: 'second-post' }
      ]
    }
  }
}
2

Import in Markdown

<script setup>
import { data } from './posts.data.js'
</script>

# All Posts

<ul>
  <li v-for="post in data.posts" :key="post.slug">
    {{ post.title }}
  </li>
</ul>
3

Import in Components

<script setup>
import { data } from './posts.data.js'

console.log(data.posts)
</script>

<template>
  <div v-for="post in data.posts" :key="post.slug">
    <h3>{{ post.title }}</h3>
  </div>
</template>
The data loader itself does not export data. VitePress calls the load() method behind the scenes and implicitly exposes the result via the data named export.

Async Data Loading

Data loaders support async operations:
// remote-posts.data.ts
export default {
  async load() {
    const response = await fetch('https://api.example.com/posts')
    const posts = await response.json()
    
    return {
      posts,
      fetchedAt: new Date().toISOString()
    }
  }
}

Loading Local Files

When loading from local files, use the watch option to enable hot module replacement:
// blog-posts.data.ts
import fs from 'node:fs'
import { parse } from 'csv-parse/sync'

export default {
  watch: ['./data/*.csv'],
  load(watchedFiles) {
    // watchedFiles is an array of absolute paths
    return watchedFiles.map((file) => {
      const content = fs.readFileSync(file, 'utf-8')
      return parse(content, {
        columns: true,
        skip_empty_lines: true
      })
    })
  }
}
  • The watch option accepts glob patterns
  • Patterns are relative to the data loader file
  • The load() function receives absolute paths of matched files
  • File changes trigger hot updates in development
Since data loaders run only at build time, you can import Node.js APIs and npm packages without shipping them to the client!

createContentLoader Helper

For content-focused sites, VitePress provides createContentLoader to simplify loading markdown files:

Basic Usage

// posts.data.ts
import { createContentLoader } from 'vitepress'

export default createContentLoader('posts/*.md')
<script setup>
import { data as posts } from './posts.data.js'
</script>

<template>
  <article v-for="post of posts" :key="post.url">
    <h2>
      <a :href="post.url">{{ post.frontmatter.title }}</a>
    </h2>
    <p>{{ post.frontmatter.description }}</p>
  </article>
</template>

Content Loader Options

Customize what data is loaded and how it’s transformed:
// posts.data.ts
import { createContentLoader } from 'vitepress'

export default createContentLoader('posts/*.md', {
  includeSrc: true,   // Include raw markdown source
  render: true,       // Include full rendered HTML
  excerpt: true,      // Include excerpt (content before first ---)
  
  transform(rawData) {
    // Sort by date, newest first
    return rawData
      .sort((a, b) => {
        return +new Date(b.frontmatter.date) - +new Date(a.frontmatter.date)
      })
      .map((post) => ({
        title: post.frontmatter.title,
        url: post.url,
        excerpt: post.excerpt,
        date: post.frontmatter.date,
        author: post.frontmatter.author
      }))
  }
})
Be cautious about data size. The loaded data is inlined as JSON in the client bundle. Use transform to filter and reduce data before serialization.

Custom Excerpt Separator

Control how excerpts are extracted:
export default createContentLoader('posts/*.md', {
  excerpt: true  // Uses '---' as separator
})

TypeScript Support

Use defineLoader for type-safe data loaders:
// posts.data.ts
import { defineLoader } from 'vitepress'

export interface Post {
  title: string
  url: string
  date: string
}

export interface Data {
  posts: Post[]
}

declare const data: Data
export { data }

export default defineLoader({
  watch: ['./posts/*.json'],
  async load(files): Promise<Data> {
    const posts = files.map(file => {
      return JSON.parse(fs.readFileSync(file, 'utf-8'))
    })
    
    return { posts }
  }
})
Now imports are fully typed:
import { data } from './posts.data.js'
// data is typed as { posts: Post[] }

Accessing Site Config

Access VitePress configuration inside data loaders:
import type { SiteConfig } from 'vitepress'

export default {
  async load() {
    const config: SiteConfig = (globalThis as any).VITEPRESS_CONFIG
    
    console.log(config.site.title)
    console.log(config.site.base)
    
    return {
      siteTitle: config.site.title
    }
  }
}
Implementation reference: src/node/contentLoader.ts:80-84

Using in Build Hooks

Data loaders can be used in build hooks to generate additional files:
// .vitepress/config.ts
import { createContentLoader } from 'vitepress'
import { writeFileSync } from 'fs'
import { Feed } from 'feed'

export default {
  async buildEnd() {
    const posts = await createContentLoader('posts/*.md', {
      excerpt: true,
      render: true
    }).load()
    
    // Generate RSS feed
    const feed = new Feed({
      title: 'My Blog',
      description: 'My awesome blog',
      link: 'https://example.com'
    })
    
    for (const post of posts) {
      feed.addItem({
        title: post.frontmatter.title,
        link: `https://example.com${post.url}`,
        description: post.excerpt,
        date: new Date(post.frontmatter.date)
      })
    }
    
    writeFileSync('dist/feed.rss', feed.rss2())
  }
}

Real-World Examples

Blog Post Index

// blog.data.ts
import { createContentLoader } from 'vitepress'

interface Post {
  title: string
  url: string
  date: string
  excerpt: string
  author: string
  tags: string[]
}

declare const data: Post[]
export { data }

export default createContentLoader('blog/*.md', {
  excerpt: true,
  transform(raw): Post[] {
    return raw
      .map(({ url, frontmatter, excerpt }) => ({
        title: frontmatter.title,
        url,
        excerpt,
        date: frontmatter.date,
        author: frontmatter.author,
        tags: frontmatter.tags || []
      }))
      .sort((a, b) => +new Date(b.date) - +new Date(a.date))
  }
})
Use in a page:
<script setup>
import { data as posts } from './blog.data.js'
import { computed } from 'vue'

const latestPosts = computed(() => posts.slice(0, 5))
</script>

<template>
  <div class="blog-index">
    <h1>Latest Posts</h1>
    <article v-for="post in latestPosts" :key="post.url">
      <h2><a :href="post.url">{{ post.title }}</a></h2>
      <div class="meta">
        <span>{{ post.date }}</span>
        <span>by {{ post.author }}</span>
      </div>
      <div class="excerpt" v-html="post.excerpt"></div>
      <div class="tags">
        <span v-for="tag in post.tags" :key="tag">{{ tag }}</span>
      </div>
    </article>
  </div>
</template>

API Documentation Index

// api-index.data.ts
import { createContentLoader } from 'vitepress'

interface APIEntry {
  name: string
  type: 'function' | 'class' | 'interface'
  url: string
  description: string
}

export default createContentLoader('api/**/*.md', {
  transform(raw): APIEntry[] {
    return raw
      .map(({ url, frontmatter }) => ({
        name: frontmatter.name,
        type: frontmatter.type,
        url,
        description: frontmatter.description
      }))
      .sort((a, b) => a.name.localeCompare(b.name))
  }
})

Team Members

// team.data.ts
import { defineLoader } from 'vitepress'
import fs from 'fs'

interface Member {
  name: string
  role: string
  avatar: string
  github: string
}

export default defineLoader({
  watch: ['team/*.json'],
  load(files): Member[] {
    return files
      .map(file => JSON.parse(fs.readFileSync(file, 'utf-8')))
      .sort((a, b) => a.name.localeCompare(b.name))
  }
})

Performance Considerations

1

Minimize Data Size

Only include necessary fields in your transform:
transform(raw) {
  return raw.map(({ url, frontmatter }) => ({
    title: frontmatter.title,  // ✅ Only what's needed
    url
  }))
  // ❌ Don't include: src, html, full frontmatter
}
2

Use Excerpts Wisely

Full HTML rendering is expensive. Only enable for content that needs it:
createContentLoader('posts/*.md', {
  excerpt: true,  // ✅ Lightweight
  render: false   // ❌ Heavy, use sparingly
})
3

Cache External Requests

Cache API responses during development:
const cache = new Map()

export default {
  async load() {
    if (cache.has('posts')) {
      return cache.get('posts')
    }
    
    const data = await fetchPosts()
    cache.set('posts', data)
    return data
  }
}
4

Paginate Large Datasets

For large collections, implement pagination:
transform(raw) {
  const pageSize = 10
  const pages = []
  
  for (let i = 0; i < raw.length; i += pageSize) {
    pages.push(raw.slice(i, i + pageSize))
  }
  
  return pages
}

Troubleshooting

Data Not Updating

If changes aren’t reflected:
  1. Ensure watch patterns are correct
  2. Restart the dev server
  3. Check file paths are relative to the data loader

Large Bundle Size

If your bundle is too large:
  1. Use transform to reduce data
  2. Disable render and includeSrc if not needed
  3. Split data into multiple loaders
  4. Consider dynamic imports for large datasets

Type Errors

For TypeScript issues:
  1. Use defineLoader for type inference
  2. Declare the data export explicitly
  3. Ensure return types match declarations
See the official VitePress test suite for more examples: __tests__/e2e/data-loading/

Build docs developers (and LLMs) love