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.
Enabling MDX
MDX is enabled automatically when you create a source folder and select MDX as the framework. To add it to an existing folder:
import { defineConfig } from "@kosmojs/dev";
export default defineConfig({
frontend: {
stack: "mdx",
base: "/docs",
ssr: true,
ssg: true,
},
});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 in pages and layouts via useFrontmatter() (or useRoute().frontmatter).
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:
// pages/blog/Alert.tsx
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>
);
}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.ts:
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.
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 the wrapped content as props.children. Everything else - the page's frontmatter, loader data - is read with hooks, so props carries only what a layout composes around:
<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 with useFrontmatter() for dynamic head content or conditional rendering:
import { useFrontmatter } from "_/use";
export const Header = () => {
const frontmatter = useFrontmatter();
return frontmatter.title ? (
<header>
<h1>{frontmatter.title}</h1>
</header>
) : null;
};
<div class="page-wrapper">
<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.mdxRoute 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() from _/use. .mdx is not TypeScript, so it takes no type argument and the params come back untyped - move anything that needs the typed form into a .tsx component and import it:
import { useParams } from "_/use";
export const Post = () => {
const { slug } = useParams();
return <p>Reading: {slug}</p>;
};
# Blog post
<Post />Optional parameters come back possibly-undefined, and a splat parameter comes back as an array of segments or undefined:
import { useParams } from "_/use";
export const Filters = () => {
const { category, tag } = useParams();
return <p>{category ?? "all"} / {tag ?? "all"}</p>;
};
<Filters />Note the call sits inside a component, not at module scope - see the warning below.
useRoute() provides the full route context including name, params, frontmatter, and loader data:
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.
Data Fetching
Pages can fetch data during render via a loader export - a function that runs before the page is rendered, on both the server and the client. It receives the resolved route as first argument, and its return value is read inside the page with the useLoaderData() hook - props stays entirely yours.
import f from "_/fetch";
import { useLoaderData } from "_/use";
export const loader = f["users"].GET;
export const Message = () => {
const data = useLoaderData();
return <p>The message is: {data.msg}</p>;
};
# Welcome
<Message />loader fetches through the same client used elsewhere in the project, so a request made during SSR is captured and replayed on hydration instead of firing twice - no extra wiring needed on the page.
Loaders with Route Parameters
loader runs before the page tree exists, so it can't use useParams()/useRoute() - those are hooks, and hooks only work while Preact is actually rendering a component. Instead, loader receives the resolved route object directly as first argument:
// the object passed to `loader` - a subset of the route context:
// no frontmatter (loaded with the module by then, but not passed to loaders)
// and no loaderData (not resolved yet at loader time)
type LoaderRoute = {
name: string;
params: Record<string, string | Array<string>>;
paramsEntries: [keys: Array<string>, values: Array<unknown>];
searchParams: Record<string, unknown>;
};paramsEntries is a [keys, values] tuple, both in the same order the route declares its parameters - the same order GET expect:
import f from "_/fetch";
import { useLoaderData } from "_/use";
export const { GET } = f["blog/[slug]"];
export const loader = ({ paramsEntries }) => {
const [keys, params] = paramsEntries;
return GET(params);
};
export const Title = () => {
const data = useLoaderData();
return <h1>{data.title}</h1>;
};
<Title />Pass params straight to a parametrized endpoint when only positional values are needed; keys is there alongside it for cases like building a request body or a cache key from the parameter names themselves. Either way, there's no need to reconstruct an array from params/useParams() by hand.
Don't reach for
Object.keys(route.params)/Object.values(route.params)as a substitute. It happens to work today because JS object key order usually follows insertion order, but that's an implicit contract, not a guarantee tied to how the route declares its parameters - it can silently break for multi-param or splat routes, or if matching internals ever change.paramsEntriesderives its order from the route's own declared parameter list, so it's correct by construction instead of by coincidence.
Important:
loaderruns during resolution, before the component tree is built, so it never has access touseParams(),useRoute(), or any other hook - only to theRouteobject passed as its argument. Hooks remain the right tool inside actual components;loaderis a pre-render step, not a rendered component.
Navigation with Link
There is Link component at components/Link.tsx for your convenience:
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 tuple as other frameworks - route name followed by parameters. Link.tsx is type-checked, but the call site in an .mdx page is not, so a wrong route name or a missing parameter surfaces at runtime rather than at build time. Wrap navigation in a .tsx component where that matters.
Tip: When
Linkis enabled incomponents/mdx.ts(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
MDX folders get the same seeded foundation files as other frameworks, keeping project structure consistent:
src/content/
├── app.mdx ← global layout
├── router.ts ← routes wired into the native router
├── index.html ← HTML shell with placeholders
├── components/
│ ├── Link.tsx ← navigation component
│ └── mdx.ts ← MDXProvider component overrides
├── entry/
│ ├── client.ts ← minimal client entry
│ └── server.ts ← SSR rendering with Preact
└── pages/
└── *.mdx ← content pages, optionally exporting `loader`Router Configuration
router.ts follows the same routerFactory pattern as every other framework - the only MDX-specific part is the components map handed to createRouters alongside the app, so the MDXProvider overrides apply to every page.
import routerFactory, { createRouters } from "_/router";
import app from "./app.mdx";
import { components } from "./components/mdx"
export default routerFactory((routes) => {
const { clientRouter, serverRouter } = createRouters(routes, { app, components });
return {
clientRouter() {
return clientRouter()
},
serverRouter(url) {
return serverRouter(url)
},
};
});Client/Server Entry
Both client and server entries follow the same renderFactory pattern as React/Solid/Vue.
- Client entry either renders the whole page on dev or hydrates the rendered SSR page.
- Server entry factory returns
renderToStringwith{ head, html }. MDX renders static content, so it implements onlyrenderToString- it is the one framework that omitsrenderToStream.
// entry/client.ts
import renderFactory, {
createRoutes,
hydrate,
mount,
} from "_/entry/client";
import routerFactory from "../router";
const routes = createRoutes();
const { clientRouter } = routerFactory(routes);
const root = document.getElementById("app");
if (root) {
renderFactory(() => {
return {
hydrate() {
return hydrate(() => clientRouter(), root);
},
mount() {
return mount(() => clientRouter(), root);
},
};
});
} else {
console.error("❌ Root element not found!");
}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. loadercan't use hooks.useParams(),useRoute(), and any other hook only work inside a rendered component.loaderruns before the tree exists - use theRouteobject passed as its argument (params,paramsEntries,searchParams) instead.- 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.