Leadtype
Build

Use the source primitive

createDocsSource() is the framework-neutral way to render leadtype-authored MDX in your own docs app. This page shows the wiring on top of the most common hosts. Fumadocs has its own first-party adapter — see leadtype/fumadocs.

TL;DR

The primitive itself is identical across frameworks:

lib/source.ts
import { createDocsSource } from "leadtype";

export const source = await createDocsSource({
  contentDir: "./content/docs",
  baseUrl: "https://example.com",
});

Wire createMdxSourcePlugins() into your bundler's remark stack, then call source.loadPage(slug) from your framework's page renderer. The "Wire into your framework" section below has minimal setups for each host.

Install

bun add leadtype

Plus an MDX integration for your bundler (@next/mdx, @astrojs/mdx, @mdx-js/rollup, etc.).

Wire into your framework

createMdxSourcePlugins() expands <include> partials and resolves <ExtractedTypeTable> at build time, while leaving every custom tag (<Callout>, <Tabs>, <Steps>, …) as JSX for your runtime components. Set typeTableBasePath to the source root that contains referenced TypeScript files; use path.resolve(process.cwd(), "docs") only when those files intentionally live under your docs folder.

Next App Router

The leadtype/next adapter wraps the common Next wiring so the page, route handler, and search hook each become a one-line export. Use it as the recommended path; fall back to calling createDocsSource() directly only when you need behavior the adapter doesn't expose.

next.config.mjs
import createMDX from "@next/mdx";
import { createMdxSourcePlugins } from "leadtype/mdx";
import path from "node:path";

const typeTableBasePath = path.resolve(process.cwd(), ".c15t");

export default createMDX({
  options: {
    remarkPlugins: [...createMdxSourcePlugins({ typeTableBasePath })],
  },
})({ pageExtensions: ["ts", "tsx", "mdx"] });
app/docs/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import { MDXRemote } from "next-mdx-remote-client/rsc";
import {
  createGenerateStaticParams,
  createLoadPageData,
} from "leadtype/next";
import { source } from "@/lib/source";
import { mdxComponents } from "@/lib/mdx-components";

const loadPageData = createLoadPageData({ source });
export const generateStaticParams = createGenerateStaticParams({ source });

export default async function DocsPage({
  params,
}: { params: Promise<{ slug?: string[] }> }) {
  const page = await loadPageData((await params).slug);
  if (!page) return notFound();

  return (
    <article>
      <h1>{page.title}</h1>
      {page.description ? <p>{page.description}</p> : null}
      <MDXRemote source={page.markdown} components={mdxComponents} />
    </article>
  );
}

Add a sibling route.ts to serve raw markdown and content negotiation:

app/docs/[[...slug]]/route.ts
import { createDocsRouteHandler } from "leadtype/next";
import manifest from "@/generated/agent-readability.json" with { type: "json" };

export const GET = createDocsRouteHandler({
  manifest: { ...manifest, version: 1 } as const,
});

Pair with useLeadtypeSearch from leadtype/next/client when building a search input — see the search recipe.

TanStack Start

vite.config.ts
import mdx from "@mdx-js/rollup";
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { createMdxSourcePlugins } from "leadtype/mdx";
import remarkFrontmatter from "remark-frontmatter";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    {
      ...mdx({
        providerImportSource: "@mdx-js/react",
        remarkPlugins: [remarkFrontmatter, ...createMdxSourcePlugins()],
      }),
      enforce: "pre",
    },
    tanstackStart(),
    viteReact({ include: /\.(mdx|[jt]sx?)$/ }),
  ],
});
src/routes/docs/$.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import { type ComponentType, lazy, Suspense, useMemo } from "react";
import docsPages from "@/generated/docs-pages.json";

type DocsPage = { slug: string[]; globKey: string; urlPath: string };

const pagesBySlug = new Map<string, DocsPage>(
  (docsPages as DocsPage[]).map((p) => [p.slug.join("/"), p])
);

// import.meta.glob keys are POSIX paths; the build-time manifest writes
// matching keys so each slug maps to its lazy-loaded MDX module.
const mdxModules = import.meta.glob<{ default: ComponentType }>(
  "../../../content/docs/**/*.mdx"
);

const TRAILING_SLASH = /\/+$/;

function resolvePage(splat: string | undefined): DocsPage | null {
  if (!splat) return null;
  return pagesBySlug.get(splat.replace(TRAILING_SLASH, "")) ?? null;
}

function MissingMdxModule({ urlPath }: { urlPath: string }) {
  return (
    <div>
      MDX module not found for <code>{urlPath}</code>. Re-run your docs
      manifest script after adding new pages.
    </div>
  );
}

export const Route = createFileRoute("/docs/$")({
  beforeLoad: ({ params }) => {
    if (!resolvePage(params._splat)) throw notFound();
  },
  component: DocsCatchAllRoute,
});

function DocsCatchAllRoute() {
  const { _splat } = Route.useParams();
  // beforeLoad guarantees this is non-null when the component renders.
  const page = resolvePage(_splat) as DocsPage;

  // `lazy()` must run outside the render body — calling it inline returns
  // a fresh lazy component every render, defeats Suspense caching, and
  // remounts the MDX page on every parent re-render. Memoize on the
  // globKey so the cached lazy component is reused across renders. Guard
  // the lookup in case the manifest references a file the glob didn't
  // pick up (stale manifest, file deleted between builds, …).
  const MdxComponent = useMemo(() => {
    const loader = mdxModules[page.globKey];
    if (!loader) {
      return () => <MissingMdxModule urlPath={page.urlPath} />;
    }
    return lazy(loader);
  }, [page.globKey, page.urlPath]);

  return (
    <Suspense fallback={null}>
      <MdxComponent />
    </Suspense>
  );
}

Generate docs-pages.json at build time by calling createDocsSource().listPages() from a build script and writing each page's slug, urlPath, and globKey (path relative to the catch-all route, POSIX separators).

Use createLoadPageData from leadtype/tanstack-start when you want adapter helpers for TanStack Router route params and page data. TanStack AI answer streaming lives separately under leadtype/search/tanstack.

Nuxt

nuxt.config.ts
import { createMdxSourcePlugins } from "leadtype/mdx";

export default defineNuxtConfig({
  modules: ["@nuxtjs/mdc"],
  mdc: { remarkPlugins: [...createMdxSourcePlugins()] },
});

Use createLoadPageData and the Nitro route handlers from leadtype/nuxt for page data, generated markdown, and Agent Readability artifacts. For search, use leadtype/search/vue with the generated JSON files.

Astro Content Collections

astro.config.mjs
import { defineConfig } from "astro/config";
import mdx from "@astrojs/mdx";
import { createMdxSourcePlugins } from "leadtype/mdx";

export default defineConfig({
  integrations: [mdx({ remarkPlugins: [...createMdxSourcePlugins()] })],
});

Use Astro's native content collection schema for typed frontmatter. Call source.loadPage() from leadtype only when you need programmatic include resolution, search, or navigation. See Astro's content collections docs for the routing pattern.

SvelteKit + mdsvex

svelte.config.js
import { mdsvex } from "mdsvex";
import { createMdxSourcePlugins } from "leadtype/mdx";

export default {
  extensions: [".svelte", ".svx", ".mdx"],
  preprocess: mdsvex({ remarkPlugins: [...createMdxSourcePlugins()] }),
};

Use createLoadPageData, createEntries, and createDocsServerHandler from leadtype/sveltekit for page data, prerender entries, and markdown or JSON artifact responses. For search, use leadtype/search/svelte with the generated JSON files.

TanStack Start, Nuxt, Astro, and SvelteKit expose adapter helpers for native route data and handler shapes. The open follow-up work is to keep expanding those helpers without adding rendered UI components.

Implement the tag components

Import prop types from leadtype/mdx and implement against your framework:

lib/mdx-components.tsx
import type { CalloutProps, TabsProps, StepProps } from "leadtype/mdx";

export const mdxComponents = {
  Callout: ({ variant, title, children }: CalloutProps & { children?: React.ReactNode }) => (
    <aside data-variant={variant ?? "info"}>
      {title ? <strong>{title}</strong> : null}
      <div>{children}</div>
    </aside>
  ),
  // ... Tabs, Tab, Steps, Step, Cards, Card, TypeTable, etc.
};

The full tag inventory and intersection patterns for React, Vue, Svelte, Solid, and Astro live in leadtype/mdx.

Build the sidebar from navigation

components/sidebar.tsx
import type { DocsNavigationGroup, DocsNavigationPage } from "leadtype/llm/readability";
import { source } from "@/lib/source";

export async function Sidebar({ currentUrlPath }: { currentUrlPath: string }) {
  const navigation = await source.getNavigation();
  return (
    <nav>
      {navigation.groups.map((group: DocsNavigationGroup) => (
        <section key={group.slug}>
          <h2>{group.title}</h2>
          {group.pages.map((page: DocsNavigationPage) => (
            <a
              key={page.urlPath}
              href={page.urlPath}
              aria-current={page.urlPath === currentUrlPath ? "page" : undefined}
            >
              {page.title}
            </a>
          ))}
        </section>
      ))}
    </nav>
  );
}

Each page carries a toc field (DocsTableOfContentsItem[]) you can render as an "On this page" rail.

Match heading slugs

source.loadPage().toc uses slugifyDocsHeading to derive anchor IDs. Your rendered heading components need matching id attributes:

import { slugifyDocsHeading } from "leadtype/llm/readability";

function textFromChildren(children: unknown): string {
  if (typeof children === "string" || typeof children === "number") {
    return String(children);
  }
  if (Array.isArray(children)) return children.map(textFromChildren).join("");
  // (recurse into React elements as needed)
  return "";
}

const Heading2 = ({ children, id, ...props }: React.HTMLAttributes<HTMLHeadingElement>) => {
  const headingId = id ?? slugifyDocsHeading(textFromChildren(children));
  return <h2 id={headingId || undefined} {...props}>{children}</h2>;
};

Wire Heading2 (and h3, etc.) into your mdxComponents map. Authors can still pin an explicit id on a heading.

Build a search index

app/api/search/route.ts
import { source } from "@/lib/source";

const bundle = await source.buildSearchIndex();

export async function GET() {
  return Response.json(bundle.index);
}

For provider-specific search (Vercel AI, TanStack, Cloudflare), feed the bundle into a leadtype/search/* adapter — see Add search.

Troubleshooting

  • <include> tags survive into the rendered output. You forgot to add createMdxSourcePlugins() to your MDX compiler's remark plugin list.
  • <ExtractedTypeTable> renders unresolved. The source preset converts these to <TypeTable> only when extractTypeFromFile succeeds. Make sure typescript is installed in the docs app and typeTableBasePath resolves to the project that contains the type.
  • TOC links don't scroll. Rendered heading IDs don't match. Wire slugifyDocsHeading into your heading components.
  • Sidebar order doesn't match llms.txt. Your app and the CLI are loading different docs.config.ts files. Centralize the config and import it in both.

Reference