Framework integration
Wire docvia into SvelteKit, Next.js, React with Vite, TanStack Start, or a server, with in-process compilation and a production build.
On this page
docvia runs in-process inside your bundler. The Vite plugin and the Next.js
wrapper rewrite defineDocs() calls at build time and compile page bodies on
demand, so there is no separate build step, no .docvia/ folder, and dev
recompiles only the page you edit. Imports are plain package subpaths
(@docvia/core/source, @docvia/core/source/macro), identical on Vite and Next.js.
The pattern is the same everywhere:
- Author a
docvia.config.tswith the renderer that matches your framework. - Add the framework's docvia plugin or wrapper.
- Declare a collection with
defineDocs()and wrap it inloader(). - Load pages with
source.getPage()and render them with the framework renderer.
flowchart TD
Q{"What are you building?"}
Q -- "SvelteKit" --> V["@docvia/build/vite"]
Q -- "React + Vite / TanStack Start" --> V
Q -- "Next.js" --> N["@docvia/build/next"]
Q -- "Node server, no bundler" --> S["@docvia/core/ssr"]
Q -- "Something else" --> C["docvia build<br/>+ a RendererAdapter"]
V --> RS["core/svelte<br/>or core/react"]
N --> RR["core/react"]
S --> RC["renderer-core"] Every setup shares the same two macro files:
import { loader } from "@docvia/core/source";
import { defineDocs } from "@docvia/core/source/macro";
import { z } from "zod";
const docs = defineDocs({
dir: "content/docs",
// Optional: extra frontmatter fields, any Standard Schema library.
docs: { schema: z.object({ author: z.string().optional() }) },
});
export const source = loader({ baseUrl: "/docs", source: docs.toDocviaSource() });
import { defineRegistry } from "@docvia/core/source/macro";
// Components from `components` in docvia.config.ts.
export const registry = defineRegistry();
dir is relative to the project root and must be a string literal. The
@docvia/core/source page documents every loader()
method.
SvelteKit
SvelteKit runs on Vite, so the integration is the single docvia() plugin
from @docvia/build/vite. The snippets below
target SvelteKit 3 and TypeScript 6.
1. Install
pnpm add -D @docvia/build
pnpm add @docvia/core
2. Configure docvia
Use the Svelte renderer, taking care to use the /node subpath, which is the
build-time entry point. Add @docvia/plugin-shiki for syntax highlighting.
// docvia.config.ts
import { defineConfig } from "@docvia/build/vite";
import { createSvelteRenderer } from "@docvia/core/svelte/node";
import { shiki } from "@docvia/plugin-shiki";
export default defineConfig({
renderer: createSvelteRenderer(),
plugins: [shiki({ theme: "github-dark" })],
});
3. Add the Vite plugin
Called with no arguments, docvia() loads docvia.config.* from the Vite root
(docvia(config) also works). SvelteKit 3 has no svelte.config.js: its
options go into sveltekit({ ... }).
// vite.config.ts
import { docvia } from "@docvia/build/vite";
import adapter from "@sveltejs/adapter-auto";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit({ adapter: adapter() }), docvia()],
});
Your workflow stays pnpm dev and pnpm build. Nothing is generated on disk,
so svelte-check needs no extra sync step.
4. Declare the collection
Put the two macro files from above in src/lib/source.ts and
src/lib/registry.ts, with dir pointing at your Markdown (for example
"src/docs").
5. Consume pages in a route
Load the page on the server and render it with the Renderer component from
@docvia/core/svelte.
// src/routes/docs/[...slug]/+page.server.ts
import { error } from "@sveltejs/kit";
import { source } from "$lib/source";
import type { PageServerLoad } from "./$types";
export const load: PageServerLoad = async ({ params }) => {
const page = source.getPage(params.slug?.split("/").filter(Boolean));
if (!page) error(404, "Page not found");
const { content, headings } = await page.data.load();
return { page: { title: page.data.title, content, headings } };
};
<!-- src/routes/docs/[...slug]/+page.svelte -->
<script lang="ts">
import { Renderer } from "@docvia/core/svelte";
import { registry } from "$lib/registry";
import type { PageProps } from "./$types";
let { data }: PageProps = $props();
</script>
<article>
<Renderer nodes={data.page.content} {registry} />
</article>
Return source.pageTree from +layout.server.ts for the sidebar. The
apps/web app in
this repository is a complete working example: the site you are reading is
compiled by docvia.
Next.js
For Next.js, @docvia/build/next does the
wiring for both webpack and Turbopack.
1. Install
pnpm add -D @docvia/build
pnpm add @docvia/core react react-dom
2. Configure docvia
// docvia.config.ts
import { defineConfig } from "@docvia/build/next";
import { createReactRenderer } from "@docvia/core/react";
import { shiki } from "@docvia/plugin-shiki";
export default defineConfig({
renderer: createReactRenderer(),
plugins: [shiki({ theme: "github-dark" })],
});
3. Wrap the Next config
// next.config.ts
import { withDocvia } from "@docvia/build/next";
const withDocs = withDocvia();
export default withDocs({ reactStrictMode: true });
withDocvia rewrites macro files named source.{ts,tsx,js,mjs} or
registry.{ts,tsx,js}. Override the list with
withDocvia({ macroFiles: ["docs-source.ts"] }), and the config location with
configPath.
4. Consume pages in a route
// app/docs/[[...slug]]/page.tsx
import { Renderer } from "@docvia/core/react";
import { notFound } from "next/navigation";
import { registry } from "@/lib/registry";
import { source } from "@/lib/source";
export function generateStaticParams() {
return source.generateParams();
}
export default async function DocPage({
params,
}: {
params: Promise<{ slug?: string[] }>;
}) {
const { slug } = await params;
const page = source.getPage(slug);
if (!page) notFound();
const { content } = await page.data.load();
return <Renderer nodes={content} registry={registry} />;
}
Renderer carries no "use client" directive, so it renders as a React
Server Component. For deferred islands, pass manifest from load() to
hydrate from @docvia/core/react/client in a client component.
React with Vite
Add the same docvia() plugin next to @vitejs/plugin-react:
// vite.config.ts
import { docvia } from "@docvia/build/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), docvia()], // loads ./docvia.config.ts
});
source is safe to import from client code: the frontmatter index is inlined,
and each page body is its own lazy chunk fetched by page.data.load().
import { Renderer } from "@docvia/core/react";
import { use } from "react";
import { registry } from "./lib/registry";
import { source } from "./lib/source";
export function DocPage({ slugs }: { slugs: string[] }) {
const page = source.getPage(slugs);
if (!page) return <p>Not found</p>;
const { content } = use(page.data.load());
return <Renderer nodes={content} registry={registry} />;
}
TanStack Start
TanStack Start runs on Vite, so add docvia() to its Vite config. Route loaders
run in the browser too, so load the page in a server function: that keeps
lib/source.ts, and the index of every page, out of the browser bundle, and the
page JS stays the same size however many pages you have.
// src/routes/docs/$.tsx
import { Renderer } from "@docvia/core/react";
import { createFileRoute, notFound } from "@tanstack/react-router";
import { createServerFn } from "@tanstack/react-start";
import { registry } from "@/lib/registry";
import { source } from "@/lib/source";
const getPage = createServerFn({ method: "GET" })
.inputValidator((slugs: string[]) => slugs)
.handler(async ({ data: slugs }) => {
const page = source.getPage(slugs);
if (!page) throw notFound();
const { content, toc } = await page.data.load();
return { title: page.data.title, content, toc };
});
export const Route = createFileRoute("/docs/$")({
loader: ({ params }) =>
getPage({ data: (params._splat ?? "").split("/").filter(Boolean) }),
component: DocPage,
});
function DocPage() {
const { content } = Route.useLoaderData();
return <Renderer nodes={content} registry={registry} />;
}
Server-side rendering
Inside a framework app you need nothing extra. page.data.load() runs at
request time on Node, Cloudflare Workers, and other edge runtimes: there is no
node:fs and no Markdown parsing on the request path. Page bodies stay lazy
chunks, so a Worker's cold start loads only the frontmatter index, not every
page.
For a non-framework Node server that renders per request, use
@docvia/core/ssr. It renders one document per request and
caches rendered pages in an in-memory LRU keyed by content hash.
createDocviaSSR takes a generic content source. A live CompileService
already satisfies the shape, so pass it directly, or pass a
(collection, slug) => IR function:
import { createDocviaSSR } from "@docvia/core/ssr";
const ssr = createDocviaSSR({ provider: service }); // or (collection, slug) => IR
const page = await ssr.render("docs", "getting-started");
Standalone preview
Without a bundler, docvia build writes a module graph to outDir, and
docvia preview serves it over sirv:
docvia preview --out .docvia --port 4173
This is a sanity check for the standalone output, not a runtime. For an actual site, use one of the integrations above.
Legacy config collections (
collections,sourceDir,outDirindocvia.config.ts, imported throughvirtual:docvia/sourceordocvia/source) still work, but new apps should usedefineDocs().
Choosing an approach
| Your app | Integration | Renderer |
|---|---|---|
| SvelteKit | @docvia/build/vite (docvia()) | @docvia/core/svelte |
| Next.js (webpack or Turbopack) | @docvia/build/next (withDocvia()) | @docvia/core/react |
| React with Vite | @docvia/build/vite (docvia()) | @docvia/core/react |
| TanStack Start | @docvia/build/vite (docvia()) | @docvia/core/react |
| Plain Vite (Svelte) | @docvia/build/vite (docvia()) | @docvia/core/svelte |
| Request-time SSR without a bundler | @docvia/core/ssr | @docvia/core/render |
| Any other framework | docvia build + @docvia/core/source | write a RendererAdapter |