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

> Render Markdown pages in a Next.js app with Comark Content.

With the App Router, server components read the Content instance **in-process**: import it, `await content.get()`, render. No API route and no client are needed until a client component has to fetch content. 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 Next.js app that uses the App Router. Create one with `npx create-next-app@latest` if you're starting fresh. The paths below assume the `src/` directory option; without it, drop the `src/` prefix and use `@/lib/content` as shown.

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

`comark-content` reads and parses your files. `@comark/react` renders the result. `server-only` makes sure the instance never ends up in a client bundle.

  :::code-group
  ```bash [pnpm]
  pnpm add comark-content @comark/react server-only
  ```

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

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

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

### Write a Markdown file

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

# Hello from Markdown

This page is rendered by Comark Content.
```

### Create the Content instance

Mark the module [`server-only`](https://nextjs.org/docs/app/building-your-application/rendering/composition-patterns#keeping-server-only-code-out-of-the-client-environment) so importing it from a client component is a build error rather than a leaked parser:

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

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

### Render a page

A server component is an async function, so it can read the instance directly:

```tsx [src/app/page.tsx]
import { MarkdownDocument } from '@comark/react'
import { notFound } from 'next/navigation'
import { content } from '@/lib/content'

export default async function Page() {
  const page = await content.get('/')
  if (!page) notFound()

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

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 `generateMetadata()`.

Map Markdown elements and components to your own React components through the `components` prop; see the [React renderer](https://comark.dev/rendering/react).

### See edits while you develop

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

```ts [src/lib/content.ts]
if (process.env.NODE_ENV !== 'production') {
  const scope = globalThis as typeof globalThis & { __comarkWatching__?: boolean }
  if (!scope.__comarkWatching__) {
    scope.__comarkWatching__ = true
    void content.watch()
  }
}
```

The flag on `globalThis` matters: the dev server re-evaluates this module, and each evaluation would otherwise start another watcher on the same files. 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.
::

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

## Render every page

A catch-all route next to the `page.tsx` you already have renders every other path in your content folder, and `generateStaticParams` prerenders them from `list()`:

```tsx [src/app/[...slug\\]/page.tsx]
import { MarkdownDocument } from '@comark/react'
import { notFound } from 'next/navigation'
import { content } from '@/lib/content'

export async function generateStaticParams() {
  const pages = await content.list()
  return pages
    .filter((page) => page.path !== '/') // `/` is served by src/app/page.tsx
    .map((page) => ({ slug: page.path.split('/').filter(Boolean) }))
}

type Props = { params: Promise<{ slug: string[] }> }

export async function generateMetadata({ params }: Props) {
  const { slug } = await params
  const page = await content.get(`/${slug.join('/')}`)
  return { title: page?.data.title }
}

export default async function Page({ params }: Props) {
  const { slug } = await params
  const page = await content.get(`/${slug.join('/')}`)
  if (!page) notFound()

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

The second `content.get()` in the component is served from memory; the first call parsed the page.

Two things to keep in mind:

- Use `[...slug]`, not the optional `[[...slug]]`. An optional catch-all at the root conflicts with `src/app/page.tsx`.
- If a dedicated route such as `src/app/blog/[slug]/page.tsx` owns part of your content, filter those paths out of `generateStaticParams` too. Next picks the more specific route at request time, but without the filter it prerenders those pages twice.

## Generate types

Type `content.get()` from your frontmatter by generating source types:

```bash [Terminal]
npx comark-content prepare
```

The output lands at the project root, which the standard Next `tsconfig.json` already covers through its `**/*.ts` include. Add `comark-content.d.ts` to `.gitignore` and regenerate it in your scripts:

```json [package.json]
{
  "scripts": {
    "dev": "comark-content prepare && next dev",
    "build": "comark-content prepare && next build"
  }
}
```

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

## Read from client components

Client components can't import a `server-only` module. When one needs content, expose the instance on a route handler and read it with the browser client. [`content.handler()`](https://content.comark.dev/reference/content/handler) already speaks web-standard `Request`/`Response`:

```ts [src/app/api/content/[...path\\]/route.ts]
import { content } from '@/lib/content'

export const GET = (request: Request) => content.handler(request)
```

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

export const contentClient = createContentClient()
// contentClient.get('/'), contentClient.list(), contentClient.navigation()
```

The client has the same read methods as the server instance. [Client and handler](https://content.comark.dev/advanced/client-and-handler) explains how the two meet.

## Deploy

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

- **A Node host that deploys your whole project** keeps working with `fs('./content')`. With `output: 'standalone'`, add `./content/**/*` to `outputFileTracingIncludes` so the folder is copied. [Deploy with content files](https://content.comark.dev/deployment/with-content-files) has the checklist.
- **Serverless platforms**, including Vercel, trace only the files your routes import. `content/` is read at runtime, so it isn't deployed. Parse it at build time and trace the result instead. [Deploy with a content snapshot](https://content.comark.dev/deployment/with-a-snapshot#nextjs) 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-nextjs-icon" title="Next.js example" to="https://content.comark.dev/examples/framework/next"}
  The full runnable app: YAML, JSON, Markdown, and json-render pages with HeroUI.
  :::

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

  :::card{icon="i-lucide-list-tree" title="Build navigation" to="https://content.comark.dev/guide/navigation"}
  Turn your folders into a sidebar tree.
  :::
::


## Sitemap

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