Skip to main content

Nextra

A Nextra site is a Next.js app, so ondocs is the same three pieces as in Next.js: an index built by the CLI from your content folder, route handlers for the ask and MCP endpoints, and the <AskDialog /> component. Nextra has no llms.txt of its own, so the CLI writes it, with a Markdown copy of each page. This page follows Nextra 4 with the docs theme and a content folder served under /docs (contentDirBasePath: '/docs'). In a project with a src folder, the folders are src/content and src/app.

Install for Nextra​

npm i ondocs ai @ai-sdk/openai @radix-ui/react-dialog cmdk

Nextra 4.6.1's starter fails next build with zod 4.6 ("Invalid input: expected nonoptional, received undefined"), with or without ondocs. Pin zod 4.1.13 for nextra and nextra-theme-docs only, so ondocs keeps the zod it needs. With npm, in package.json:

{
"overrides": {
"nextra": { "zod": "4.1.13" },
"nextra-theme-docs": { "zod": "4.1.13" }
}
}

With pnpm, in pnpm-workspace.yaml:

overrides:
nextra>zod: 4.1.13
nextra-theme-docs>zod: 4.1.13

Index your content​

Build the index before every build, and commit ask-index.json:

{
"scripts": {
"index": "ondocs index content --base-url /docs -e openai:text-embedding-3-small --dimensions 512 --llms-txt public",
"prebuild": "npm run index"
}
}

--base-url is your contentDirBasePath; leave it out when the content folder is served at the root. The CLI finds next.config.mjs, sees nextra in package.json, and follows Nextra's rules:

  • index.mdx is its folder's page: content/index.mdx is /docs.
  • Every other file is a page of its own, as Nextra serves it, page.mdx and _-prefixed files included.
  • Folders keep their names in the URL, (group) folders too: content/(group)/setup.mdx is /docs/(group)/setup.
  • _meta files order the sidebar and can hide a page from it, but the page is still served, so it is still indexed. Leave one out with ask: false in its frontmatter or with --ignore.
  • A heading written ## Use Docker [#docker] is cited at #docker, as Nextra anchors it.

Without an API key, replace -e openai:text-embedding-3-small --dimensions 512 with -e none to build a keyword-only index, and leave embeddingModel out of the ask handler below.

Mount the route handlers​

The ask endpoint and the MCP endpoint are two route handlers that import the same index:

// app/api/ask/route.js
import { openai } from '@ai-sdk/openai';
import { createAskHandler, memoryRateLimit } from 'ondocs/server';
import index from '../../../ask-index.json';

export const POST = createAskHandler({
index,
model: openai('gpt-5.4-mini'),
embeddingModel: openai.embedding('text-embedding-3-small'),
siteName: 'Acme Docs',
rateLimit: memoryRateLimit({ limit: 10, windowMs: 60_000 }),
});
// app/api/mcp/route.js
import { createMcpHandler } from 'ondocs/server';
import index from '../../../ask-index.json';

const handler = createMcpHandler({ index, siteName: 'the Acme docs' });

export const POST = handler;
export const GET = handler;
export const DELETE = handler;
export const OPTIONS = handler;

In a project with a src folder, the routes are in src/app/api and import ../../../../ask-index.json. Set OPENAI_API_KEY in your deployment's environment. The MCP endpoint calls no model, so agents can search the docs with no key at all: see MCP server.

Render the dialog​

Render the dialog from a client component. Nextra's search box takes ⌘K, so shortcut="i" gives the dialog ⌘I. The docs theme switches between light and dark with next-themes, which sets a dark class on <html> that the dialog does not read, so the component passes it the theme from useTheme, which nextra-theme-docs exports:

// components/ask.jsx
'use client';
import { useEffect, useState } from 'react';
import { AskDialog } from 'ondocs/react';
import 'ondocs/react/styles.css';
import 'ondocs/embed/launcher.css';
import { useTheme } from 'nextra-theme-docs';
import { useRouter } from 'next/navigation';

export function Ask() {
const router = useRouter();
const { resolvedTheme } = useTheme();
// next-themes knows the theme only once the page runs; until then, follow the system's.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
return (
<AskDialog
launcher
shortcut="i"
theme={mounted ? (resolvedTheme === 'dark' ? 'dark' : 'light') : undefined}
suggestions={['How do I install it?']}
onNavigate={(url, event) => {
event.preventDefault();
router.push(url);
}}
/>
);
}

Then render <Ask /> inside the theme's <Layout> in app/layout.jsx, after {children}, where next-themes is set up. launcher shows a floating "Ask AI" button, and onNavigate sends citations through the Next.js router instead of a full page load.

llms.txt and Markdown pages​

--llms-txt public writes llms.txt, llms-full.txt and a Markdown copy of each page at its URL plus .md into public/, which Next.js serves as they are: /docs/get-started.md for /docs/get-started, and /docs.md for /docs. A copy keeps the page's Markdown, without MDX imports and components. Name the site and point agents at the MCP endpoint with --llms-title "Acme Docs" --site-url https://docs.acme.dev --mcp-url https://docs.acme.dev/api/mcp; see llms.txt and Markdown pages. The files are written again on every build, so you can leave them out of git.

To link each page to its copy from its <head>, add it to the metadata of the catch-all page that renders your content:

// app/docs/[[...mdxPath]]/page.jsx
export async function generateMetadata(props) {
const params = await props.params;
const { metadata } = await importPage(params.mdxPath);
const markdown = ['/docs', ...(params.mdxPath ?? [])].join('/') + '.md';
return { ...metadata, alternates: { types: { 'text/markdown': markdown } } };
}

Pages in the app folder​

If your pages are page.mdx files in app rather than in a content folder, index app, without --base-url. The CLI reads it as Next.js routes it: only page.mdx files are pages, (group) folders are left out of URLs, and _private and [dynamic] folders are skipped, so app/guides/(setup)/install/page.mdx is /guides/install.

Keep the index fresh in CI​

Add the check to CI so a pull request that changes your content without rebuilding the index fails:

npx ondocs index content --base-url /docs --check # exit 1 if stale

The check never calls a model and needs no API key.