MDX source folders are purpose-built for content: documentation, blogs, marketing pages, and any site where prose matters more than interactivity. Pages are authored in MDX (Markdown with JSX), rendered to static HTML on the server with Preact, and delivered with minimal client-side JavaScript by default.
The same directory-based routing, nested layouts, and type-safe navigation used by React, SolidJS, and Vue source folders apply for MDX as well.
π οΈ Enabling the Generator β
MDX generator automatically enabled when creating a source folder and selecting MDX as the framework. To add one to an existing folder:
import {
// ...
mdxGenerator,
} from "@kosmojs/dev";
import frontmatterPlugin from "remark-frontmatter";
import mdxFrontmatterPlugin from "remark-mdx-frontmatter";
export default defineConfig({
// ...
generators: [
// ...
mdxGenerator({
remarkPlugins: [frontmatterPlugin, mdxFrontmatterPlugin]
}),
],
});π Writing Pages β
Pages are .mdx or .md files in your pages/ directory. Standard markdown syntax works alongside JSX components:
---
title: Blog
description: Latest posts and updates.
---
import Alert from "./Alert.tsx"
# Welcome to the Blog
Regular markdown works as expected - **bold**, *italic*, `code`,
[links](/about), and everything else.
<Alert type="info">
JSX components work inline with markdown content.
</Alert>
## Recent Posts
- First post about KosmoJS
- Getting started with MDXFrontmatter is defined in YAML between --- fences. It drives <head> injection and is accessible to layouts via props.
π§© Using Components β
Import Preact components directly into MDX files. TypeScript, props, hooks - everything works in the .tsx file. The MDX file stays focused on content:
import type { JSX } from "preact";
export default function Alert(props: {
type: "info" | "warning" | "error";
children: JSX.Element;
}) {
return (
<div class={`alert alert-${props.type}`}>
{props.children}
</div>
);
}import Alert from "./Alert.tsx"
<Alert type="warning">
Keep TypeScript in `.tsx` files - MDX only supports plain JavaScript.
</Alert>Global Component Overrides β
Every markdown element (# heading, `code`, [link](url)) compiles to a JSX call. Override any of them globally via the component map in components/mdx.tsx:
import Link from "./Link";
export const components = {
Link,
// custom heading with anchor links
h1: (props) => (
<h1 id={props.children?.toString().toLowerCase().replace(/\s+/g, "-")}>
{props.children}
</h1>
),
// syntax-highlighted code blocks
pre: (props) => <pre class="code-block" {...props} />,
};These overrides apply to all MDX pages via the MDXProvider. Individual pages can still import and use additional components directly.
π Nested Layouts β
Layouts work identically to other frameworks - a layout.mdx file wraps all pages and nested layouts within its folder:
pages/
βββ index/
β βββ index.mdx β wrapped by root layout
βββ docs/
β βββ layout.mdx β wraps all docs/* pages
β βββ links/
β β βββ index.mdx β wrapped by root + docs layout
β βββ guide/
β βββ layout.mdx β wraps all docs/guide/* pages
β βββ setup/
β βββ index.mdx β wrapped by root + docs + guide layoutFor /docs/guide/setup the render order is:
App.mdx (root layout)
βββ pages/docs/layout.mdx
βββ pages/docs/guide/layout.mdx
βββ pages/docs/guide/setup/index.mdxWriting Layouts β
Layouts receive props.children (the wrapped content) and props.frontmatter (from the matched page):
<nav>
<a href="/">Home</a>
<a href="/docs">Docs</a>
</nav>
<main>
{props.children}
</main>
<footer>
Built with KosmoJS
</footer>Access the page's frontmatter for dynamic head content or conditional rendering:
<div class="page-wrapper">
{props.frontmatter.title && (
<header>
<h1>{props.frontmatter.title}</h1>
</header>
)}
{props.children}
</div>Layouts must be .mdx files - .md files cannot render {props.children}.
Global Layout via App.mdx β
App.mdx at the source folder root wraps every page - the right place for truly global concerns like site-wide navigation, footer, or analytics scripts:
src/content/
βββ App.mdx β wraps everything
βββ pages/
βββ layout.mdx
βββ index/
βββ index.mdxπ£οΈ Route Parameters β
MDX pages support the same parameter syntax as other source folders:
pages/
blog/
post/
[slug]/
index.mdx β /blog/post/:slug
{category}/
index.mdx β /blog/:category (optional)
{tag}/
index.mdx β /blog/:category/:tag (both optional)Access parameters inside a component using useParams():
import { useParams } from "_/use";
export default function PostHeader() {
const { slug } = useParams();
return <h1>{slug}</h1>;
}---
title: Blog Post
---
import PostHeader from "./PostHeader.tsx"
<PostHeader />useRoute() provides the full route context including name, params, and frontmatter:
import { useRoute } from "_/use";
export default function Breadcrumb() {
const { name, params, frontmatter } = useRoute();
return <nav>...</nav>;
}Important: hooks must be called inside a component's render function, not at module scope.
export const params = useParams()in an MDX file runs on import and will fail.
π Type-Safe Navigation β
The generator produces a typed Link component at components/Link.tsx:
import Link from "~/components/Link"
Navigate to the <Link to={["blog/[slug]", "hello-world"]}>first post</Link>
or go <Link to={["index"]}>home</Link>.The to prop accepts the same typed tuple as other frameworks - route name followed by parameters. TypeScript enforces correct parameter types at compile time.
Tip: When
Linkis enabled incomponents/mdx.tsx(the default), it can be used in pages without import - it is a global component provided viaMDXProvider.
π₯ Frontmatter & Head Injection β
Frontmatter drives <head> content automatically. The SSR server reads title, description, and the head array from frontmatter and injects them into the HTML template:
---
title: Getting Started
description: Set up your first MDX source folder.
head:
- - meta
- name: keywords
content: mdx, kosmojs, getting started
- - link
- rel: canonical
href: https://kosmojs.dev/docs/getting-started
---Produces:
<head>
<title>Getting Started</title>
<meta name="description" content="Set up your first MDX source folder.">
<meta name="keywords" content="mdx, kosmojs, getting started">
<link rel="canonical" href="https://kosmojs.dev/docs/getting-started">
</head>This follows the same convention used by VitePress - no new syntax to learn.
ποΈ Application Structure β
The MDX generator produces the same foundational files as other frameworks, maintaining a consistent project structure:
src/content/
βββ App.mdx β global layout
βββ router.tsx β Preact router using createRouter
βββ index.html β HTML shell with placeholders
βββ components/
β βββ Link.tsx β typed navigation component
β βββ mdx.tsx β MDXProvider component overrides
βββ entry/
β βββ client.tsx β minimal client entry (no hydration)
β βββ server.ts β SSR rendering with Preact
βββ pages/
βββ *.mdx β content pagesRouter Configuration β
The MDX router uses createRouter to resolve routes at render time.
import { createRouter } from "_/mdx";
import routerFactory from "_/router";
import App from "./App.mdx";
import { components } from "./components/mdx"
export default routerFactory((routes) => {
const router = createRouter(routes, App, { components });
return {
async clientRouter() {
return router.resolve();
},
async serverRouter(url) {
return router.resolve(url);
},
};
});Client/Server Entry β
Both client and server entries follows the same renderFactory pattern as React/Solid/Vue.
- Client entry either render the whole page on dev or hydrate the rendered SSR page.
- Server entry factory return
renderToStringwith{ head, html }.
import { hydrate, render } from "preact";
import renderFactory, { createRoutes } from "_/entry/client";
import routerFactory from "../router";
const routes = createRoutes();
const { clientRouter } = routerFactory(routes);
const root = document.getElementById("app");
if (root) {
renderFactory(() => {
return {
async mount() {
const page = await clientRouter();
render(page.component, root);
},
async hydrate() {
const page = await clientRouter();
hydrate(page.component, root);
},
};
});
} else {
console.error("β Root element not found!");
}import { renderToString } from "preact-render-to-string";
import { renderHead } from "_/mdx";
import renderFactory, { createRoutes } from "_/entry/server";
import routerFactory from "../router";
const routes = createRoutes();
const { serverRouter } = routerFactory(routes);
export default renderFactory(() => {
return {
async renderToString(url, { assets }) {
const page = await serverRouter(url);
const head = assets.reduce(
(head, { tag }) => `${head}\n${tag}`,
renderHead(page?.frontmatter),
);
const html = page ? renderToString(page.component) : "";
return { html, head };
},
};
});π¦ Static Site Generation β
MDX source folders support SSG for deploying to CDNs without a running server. The build process renders every route to static HTML files.
For routes with dynamic parameters, use staticParams to declare the variants:
---
title: Documentation
staticParams:
- [getting-started]
- [routing]
- [validation]
---
# {useParams().slug}The build generates a separate HTML file for each entry:
dist/ssg/
βββ index.html
βββ docs/
β βββ getting-started/index.html
β βββ routing/index.html
β βββ validation/index.html
βββ assets/
βββ index-abc123.js
βββ index-def456.cssStatic routes (no parameters) render automatically with no additional configuration.
Important: Dynamic routes without
staticParamsare skipped from SSG build, that's it, no static files generated for dynamic routes withoutstaticParams.
π‘ When to Use MDX vs Frameworks β
| Use Case | MDX | React / SolidJS / Vue |
|---|---|---|
| Documentation sites | β | β Overkill |
| Marketing / landing pages | β | β Overkill |
| Blog with static content | β | β Overkill |
| Interactive dashboards | β | β |
| Apps with client-side state | β | β |
| Forms with real-time validation | β | β |
The rule is simple: if the source folder is primarily content with occasional interactive components, use MDX. If it is primarily interactive with occasional content, use React/Vue/Solid.
β οΈ Common Pitfalls β
- No TypeScript in MDX. Keep typed code in
.tsxfiles and import into MDX. MDX only supports plain JavaScript expressions. - Hooks at module scope.
export const x = useHook()runs on import, not during render. Always call hooks inside component functions. - Curly braces in prose.
{...spread}in markdown text is parsed as a JSX expression. Use backticks for code containing curly braces:`{...spread}`. - Layouts must be
.mdx. Plain.mdfiles cannot render{props.children}and will not work as layouts.