---
title: "Nuxt"
description: "Render Markdown pages in a Nuxt app with Comark Content."
canonical_url: "https://content.comark.dev/integrations/nuxt"
---
# Nuxt

> Render Markdown pages in a Nuxt app with Comark Content.

In Nuxt, the Content instance lives in `server/`, one catch-all route exposes it under `/api/content`, and pages read it through the browser client. At the end of this guide you have a Markdown file rendered at `/`, edits showing up as you save, and a clear path to deployment.

You need a Nuxt 4 app. Create one with `npm create nuxt@latest` if you're starting fresh. The file paths below use Nuxt 4's `app/` directory; in a Nuxt 3 app, drop the `app/` prefix.

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

`comark-content` reads and parses your files. `@comark/vue` renders the result.

  :::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
  ```
  :::

### Write a Markdown file

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

# Hello from Markdown

This page is rendered by Comark Content.
```

### Create the Content instance

Files under `server/utils/` are auto-imported in server code, so every route can use `content` without an import:

```ts [server/utils/content.ts]
import { comarkContent } from 'comark-content'
import fs from 'comark-content/sources/fs'

export const content = comarkContent({
  source: fs('./content'),
})
```

### Mount the handler

Browser code can't read the filesystem, so a catch-all API route exposes the instance. [`content.handler()`](https://content.comark.dev/reference/content/handler) takes a web-standard `Request`, which `toWebRequest()` produces from the h3 event:

```ts [server/api/content/[...path\\].ts]
// defineEventHandler, content, and toWebRequest are auto-imported

export default defineEventHandler((event) => content.handler(toWebRequest(event)))
```

### Create the client

[`createContentClient()`](https://content.comark.dev/reference/client/create-content-client) calls that route with the same `get()`, `list()`, and `navigation()` methods as the server instance. Pass Nuxt's `$fetch` so that during server-side rendering the call goes straight to the handler, and the data is reused on the client without a second request:

```ts [app/utils/client-content.ts]
import { createContentClient } from 'comark-content/client'

export const clientContent = createContentClient({
  fetch: $fetch,
})
```

### Render a page

Read the page in `useAsyncData` and hand it to `<MarkdownDocument>`:

```vue [app/pages/index.vue]
<script setup lang="ts">
import { MarkdownDocument } from '@comark/vue'

const { data: page } = await useAsyncData('home', () => clientContent.get('/'))

if (!page.value) {
  throw createError({ statusCode: 404, message: 'Page not found' })
}
</script>

<template>
  <MarkdownDocument :value="page" />
</template>
```

Start the dev server and open <http://localhost:3000>:

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

You see the heading and paragraph from `content/index.md`. `page.data.title` holds `Home`, ready for `useSeoMeta()`.

Map Markdown elements and components to your own Vue components through the `components` prop; see [Comark's Vue renderer](https://comark.dev/rendering/vue).

### See edits while you develop

Edit `content/index.md` and reload: the text hasn't changed. The instance parsed the file once and keeps the result in memory. Start a watcher in development so saves update it:

```ts [server/plugins/content-watch.ts]
export default defineNitroPlugin(async () => {
  if (import.meta.dev) {
    await content.watch()
  }
})
```

Save the Markdown file again and reload. The change is there. [Watch content changes](https://content.comark.dev/guide/watch) explains what the watcher does and how to react to change events.
::

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

## Generate types

Type `clientContent.get()` from your frontmatter by generating source types into `shared/`, then add the output to `.gitignore`:

```bash [Terminal]
npx comark-content prepare -o shared/comark-content.d.ts
```

The command scans common locations, including `server/utils/`, to find the file that exports your instance.

Write it to `shared/` rather than the project root: Nuxt type-checks the app and the server as separate projects, and both include `shared/**/*.d.ts`, so pages and server routes narrow from the same declarations. Wire it into your scripts so it never goes stale:

```json [package.json]
{
  "scripts": {
    "dev": "comark-content prepare -o shared/comark-content.d.ts && nuxt dev"
  }
}
```

See [Add TypeScript types](https://content.comark.dev/guide/typescript) for what the generated file contains.

## Render every page

One catch-all page renders any path in your content folder:

```vue [app/pages/[...slug\\].vue]
<script setup lang="ts">
import { MarkdownDocument } from '@comark/vue'

const route = useRoute()
const { data: page } = await useAsyncData(route.path, () => clientContent.get(route.path))

if (!page.value) {
  throw createError({ statusCode: 404, message: 'Page not found' })
}
</script>

<template>
  <MarkdownDocument :value="page" />
</template>
```

For a sidebar, fetch `clientContent.navigation()` once in `app.vue` and render the tree; see [Build navigation](https://content.comark.dev/guide/navigation).

## Prerender content pages

To prerender every content page during `nuxt build` without `nitro.prerender.crawlLinks`, import the instance in your config and add each path:

```ts [nuxt.config.ts]
import { content } from './server/utils/content'

export default defineNuxtConfig({
  hooks: {
    'prerender:routes': async (ctx) => {
      const pages = await content.list()
      for (const page of pages) {
        ctx.routes.add(page.path)
      }
    },
  },
})
```

## Deploy

What you do depends on whether the production server can read `content/`:

- **A Node host that deploys your whole project** (a VPS, a container, most `node-server` deployments) keeps working with `fs('./content')`. Make sure the folder is present next to `.output/` and that the process starts from the project root. [Deploy with content files](https://content.comark.dev/deployment/with-content-files) has the checklist.
- **Serverless and edge presets** bundle only imported code, so `nuxt build` leaves `content/` behind. Parse it at build time and bundle the result as a Nitro server asset. [Deploy with a content snapshot](https://content.comark.dev/deployment/with-a-snapshot#nuxt) is the step-by-step recipe.
- **Content in a GitHub repository** is read at request time from any host. [Keep remote content up to date](https://content.comark.dev/deployment/remote-content) covers refreshing it.

## Going further

::card-group{cols="2"}
  :::card{icon="i-logos-nuxt-icon" title="Nuxt example" to="https://content.comark.dev/examples/framework/nuxt"}
  The full runnable app: YAML, JSON, Markdown, and json-render pages with Nuxt UI.
  :::

  :::card{icon="i-logos-nuxt-icon" title="Nuxt x Better Auth" to="https://content.comark.dev/examples/framework/nuxt-better-auth"}
  A private, auth-gated content area on top of the same wiring.
  :::

  :::card{icon="i-lucide-plug" title="Client and handler" to="https://content.comark.dev/advanced/client-and-handler"}
  How the handler and client pair works, and how plugins extend it.
  :::

  :::card{icon="i-lucide-search" title="Full-text search" to="https://content.comark.dev/plugins/built-in/full-text-search"}
  Add ranked search over your pages with one plugin.
  :::
::


## Sitemap

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