---
title: "Vite"
description: "Render Markdown pages in a Vite app with the official plugin."
canonical_url: "https://content.comark.dev/integrations/vite"
---
# Vite

> Render Markdown pages in a Vite app with the official plugin.

A Vite single-page app has no server in production, so this setup is different from the server frameworks: the plugin serves your content in development, and at build time it writes a **snapshot** that the same app reads from a static host. The Content instance runs in the browser. At the end of this guide you have a Markdown file rendered at `/`, edits hot-reloading in the browser, and a static build ready to deploy.

You need a Vite app with Vue or React. Create one with `npm create vite@latest` if you're starting fresh.

::steps{level="3"}
### Install the packages

`comark-content` reads and parses your files. The renderer depends on your UI library.

  :::tabs{.gap-0}
    ::::tab-item{label="Vue" icon="i-logos-vue"}
      :::::code-group
      ```bash [pnpm]
      pnpm add comark-content @comark/vue
      ```

      ```bash [npm]
      npm install comark-content @comark/vue
      ```

      ```bash [yarn]
      yarn add comark-content @comark/vue
      ```

      ```bash [bun]
      bun add comark-content @comark/vue
      ```
      :::::
    ::::

    ::::tab-item{label="React" icon="i-logos-react"}
      :::::code-group
      ```bash [pnpm]
      pnpm add comark-content @comark/react
      ```

      ```bash [npm]
      npm install comark-content @comark/react
      ```

      ```bash [yarn]
      yarn add comark-content @comark/react
      ```

      ```bash [bun]
      bun add comark-content @comark/react
      ```
      :::::
    ::::
  :::

### Write a Markdown file

```md [content/index.md]
---
title: Home
---

# Hello from Markdown

This page is rendered by Comark Content.
```

### Add the plugin

Create the Content instance in `vite.config.ts` and pass it to the plugin. This instance runs in Node, during `vite dev` and `vite build`; it's the one that reads and parses your files:

```ts [vite.config.ts]
import { defineConfig } from 'vite'
import { fileURLToPath } from 'node:url'
import { comarkContent } from 'comark-content'
import fs from 'comark-content/sources/fs'
import comark from 'comark-content/vite'

const content = comarkContent({
  source: fs(fileURLToPath(new URL('./content', import.meta.url))),
})

export default defineConfig({
  plugins: [comark({ content })],
})
```

Everything the plugin does is on by default:

| Option      | Default          | Role                                                                                                |
| ----------- | ---------------- | --------------------------------------------------------------------------------------------------- |
| `server`    | `true`           | Dev middleware forwarding `/api/content/*` to `content.handler()`, plus media files at their path   |
| `watch`     | `true`           | Watches your folder and pushes edits to the browser as `comark:update` / `comark:remove` HMR events |
| `prefix`    | `'/api/content'` | URL prefix the middleware is mounted under                                                          |
| `prerender` | `true`           | Writes the manifest, one snapshot per instance, and media as static files at build time             |
| `types`     | `true`           | Writes `comark-content.d.ts` on dev start and build                                                 |

### Create the browser Content instance

The app can't import the instance from `vite.config.ts`; that one lives in Node. Instead, the app creates its own instance that reads the [snapshot](https://content.comark.dev/sources/snapshot) the plugin serves in dev and writes at build. Same API, no server.

  :::tip{to="https://content.comark.dev/sources/snapshot#smaller-bundles-with-comark-contentruntime"}
  Import from `comark-content/runtime` for a smaller bundle (\~46 KB vs \~295 KB). The runtime entry excludes the parser, which a snapshot-only app doesn't need.
  :::

  :::tabs{.gap-0}
    ::::tab-item{label="Vue" icon="i-logos-vue"}
    ```ts [src/composables/useContent.ts]
    import { ref } from 'vue'
    import { comarkContent, type ContentFile } from 'comark-content/runtime'
    import snapshot from 'comark-content/sources/snapshot'

    export const content = comarkContent({
      source: snapshot('/api/content/snapshot/default.json'),
    })

    // Bumps whenever an HMR event has patched the content.
    export const version = ref(0)

    if (import.meta.hot) {
      import.meta.hot.on('comark:update', async ({ file }: { file: ContentFile }) => {
        await content.update(file)
        version.value++
      })
      import.meta.hot.on('comark:remove', async ({ key }: { key: string }) => {
        await content.remove(key)
        version.value++
      })
    }
    ```
    ::::

    ::::tab-item{label="React" icon="i-logos-react"}
    ```ts [src/content.ts]
    import { comarkContent, type ContentFile } from 'comark-content/runtime'
    import snapshot from 'comark-content/sources/snapshot'

    export const content = comarkContent({
      source: snapshot('/api/content/snapshot/default.json'),
    })

    // A tiny external store so components re-read after an HMR event.
    let version = 0
    const listeners = new Set<() => void>()

    export const getContentVersion = () => version
    export function subscribeToContent(listener: () => void) {
      listeners.add(listener)
      return () => listeners.delete(listener)
    }

    function notify() {
      version++
      for (const listener of listeners) listener()
    }

    if (import.meta.hot) {
      import.meta.hot.on('comark:update', async ({ file }: { file: ContentFile }) => {
        await content.update(file)
        notify()
      })
      import.meta.hot.on('comark:remove', async ({ key }: { key: string }) => {
        await content.remove(key)
        notify()
      })
    }
    ```
    ::::
  :::

The `import.meta.hot` block is what makes edits appear without a reload; the next step uses it.

### Render a page

  :::tabs{.gap-0}
    ::::tab-item{label="Vue" icon="i-logos-vue"}
    ```vue [src/App.vue]
    <script setup lang="ts">
    import { ref, watchEffect } from 'vue'
    import { MarkdownDocument } from '@comark/vue'
    import type { ContentFile } from 'comark-content'
    import { content, version } from './composables/useContent'

    const page = ref<ContentFile | null>(null)

    // Runs once on mount, and again whenever `version` bumps after an edit.
    watchEffect(async () => {
      version.value
      page.value = (await content.get('/')) ?? null
    })
    </script>

    <template>
      <MarkdownDocument v-if="page" :value="page" />
    </template>
    ```
    ::::

    ::::tab-item{label="React" icon="i-logos-react"}
    ```tsx [src/App.tsx]
    import { useEffect, useState, useSyncExternalStore } from 'react'
    import { MarkdownDocument } from '@comark/react'
    import type { ContentFile } from 'comark-content'
    import { content, getContentVersion, subscribeToContent } from './content'

    export default function App() {
      const version = useSyncExternalStore(subscribeToContent, getContentVersion, getContentVersion)
      const [page, setPage] = useState<ContentFile | null>(null)

      // Runs once on mount, and again whenever `version` bumps after an edit.
      useEffect(() => {
        content.get('/').then((page) => setPage(page ?? null))
      }, [version])

      return page && <MarkdownDocument value={page} />
    }
    ```
    ::::
  :::

Start the dev server and open the URL it prints:

```bash [Terminal]
pnpm dev
```

You see the heading and paragraph from `content/index.md`.

### See edits while you develop

Edit `content/index.md` and save. The plugin's watcher notices, sends a `comark:update` event, and the handler in your browser instance patches the page in place and bumps `version`. The component re-reads and the new text appears, with no reload. Nothing else to set up.
::

You have a working setup. Everything below is optional; pick what you need.

## Two loaders for large sites

`snapshot()` accepts a second loader for the manifest, the index without bodies:

```ts
snapshot('/api/content/snapshot/default.json', '/api/content/manifest.json')
```

| Tier                   | Size              | Serves                             |
| ---------------------- | ----------------- | ---------------------------------- |
| `manifest.json`        | 5–150 KB          | `list()`, `navigation()`, `stat()` |
| `snapshot/<name>.json` | every parsed body | `get()`, search indexing           |

With both, the first paint downloads the index only and bodies arrive on the first `get()`. With the snapshot alone the first read downloads everything, which is one request fewer and fine for a small site. Pass one loader until the snapshot grows large enough to hurt.

## Generate types

The plugin writes `comark-content.d.ts` on dev start and at build; there's nothing to run. Add the file to `.gitignore` and check that your `tsconfig.json` includes it. Pass `types: false` to disable, or `types: { outDir }` to write it elsewhere. See [Add TypeScript types](https://content.comark.dev/guide/typescript).

## Search in the browser

Full-text search needs parsed bodies and a database. The snapshot carries the bodies, and the database can be WASM SQLite, so search runs client-side with no server:

```ts [src/composables/useContent.ts]
import { comarkContent } from 'comark-content/runtime'
import snapshot from 'comark-content/sources/snapshot'
import sqliteFullTextSearch from 'comark-content/plugins/sqlite-full-text-search'
import sqliteWasm from 'comark-content/database/sqlite-wasm'

export const content = comarkContent({
  source: snapshot('/api/content/snapshot/default.json', '/api/content/manifest.json'),
  plugins: [sqliteFullTextSearch({ database: sqliteWasm() })],
})

const hits = await content.search('streaming markdown', { limit: 8 })
```

Vite needs one line so the `.wasm` file is fetched instead of pre-bundled:

```ts [vite.config.ts]
export default defineConfig({
  optimizeDeps: { exclude: ['@sqlite.org/sqlite-wasm'] },
})
```

Indexing is lazy: the first `search()` builds the index from the snapshot, which means the first search also pays for downloading the bodies. Trigger it when the input gains focus if you want it warm.

SQLite WASM costs roughly 200 KB gzipped. Load it behind a dynamic import when search isn't on the critical path. [`sqlQuery`](https://content.comark.dev/plugins/built-in/sql-query) works the same way with `sqliteWasm()`.

## Which plugins go where

The build-time instance in `vite.config.ts` and the browser instance are two different instances, and plugins are not interchangeable between them. Parsers, `file:parsed` transforms and markdown plugins run on the build-time instance; index builders like search run in the browser.

::note{to="https://content.comark.dev/sources/snapshot#plugin-compatibility"}
Both mistakes fail quietly. See plugin compatibility for the full table and the two failure modes.
::

## What a browser instance gives up

A browser instance is not a smaller server instance:

- **No `watch()` and no origin to refresh from.** A new build is the refresh.
- **The whole corpus is public.** Anything in the snapshot is downloadable, so exclude drafts at build time with [`exclude`](https://content.comark.dev/sources/filesystem#options-exclude) or a [`schemaValidation`](https://content.comark.dev/plugins/built-in/schema-validation) filter.
- **Bodies cost bandwidth.** The manifest tier defers that cost; it doesn't remove it.

If those tradeoffs don't work, keep a server and read the same snapshot there. The instance is identical, and [`withSnapshot()`](https://content.comark.dev/sources/snapshot#withsnapshot) keeps an origin for refreshes; see [Deploy with a content snapshot](https://content.comark.dev/deployment/with-a-snapshot).

## Deploy

`vite build` writes the snapshot, the manifest, and any media into `dist/` alongside your app, at the same `/api/content/…` URLs the dev middleware served. Deploy `dist/` to any static host and the browser instance keeps working: there is no server to configure.

Content changes ship with a new build. If you'd rather update content without rebuilding, you need a server; see [Choose how to deploy](https://content.comark.dev/deployment) for the server-side options and [Deploy with a content snapshot](https://content.comark.dev/deployment/with-a-snapshot) for the same snapshot technique on a Node runtime.

## Going further

::card-group{cols="2"}
  :::card{icon="i-logos-vue" title="Vite + Vue example" to="https://content.comark.dev/examples/vite/vue"}
  The full runnable app: content edits hot-reload in the browser.
  :::

  :::card{icon="i-logos-react" title="Vite + React example" to="https://content.comark.dev/examples/vite/react"}
  The same wiring with React and a `useSyncExternalStore`-based refresh.
  :::

  :::card{icon="i-lucide-shield-check" title="Add TypeScript types" to="https://content.comark.dev/guide/typescript"}
  How the generated `comark-content.d.ts` narrows `get()` and `query()`.
  :::

  :::card{icon="i-lucide-database" title="Database in the browser" to="https://content.comark.dev/reference/database#sqlite-wasm"}
  Add SQL queries and full-text search client-side with `sqlite-wasm`.
  :::
::


## Sitemap

See the full [sitemap](https://content.comark.dev/sitemap.md) for all pages.
