MDX
Render MDX with your own components, on any server, Cloudflare Workers included.
tomekit reads an MDX file's frontmatter like any other file. Your bundler compiles the rest, and each page imports only the body it renders. Nothing is compiled or evaluated at runtime, so it works where eval is blocked, eg on Cloudflare Workers.
Setup
Add MDX to Vite, with remark-frontmatter so the frontmatter isn't rendered as text, and wrap it in cached so the next build compiles only the files that changed:
pnpm add -D @mdx-js/rollup remark-frontmatter @types/mdximport mdx from "@mdx-js/rollup";
import viteReact from "@vitejs/plugin-react";
import remarkFrontmatter from "remark-frontmatter";
import { cached, tomekit } from "tomekit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
cached({ enforce: "pre", ...mdx({ remarkPlugins: [remarkFrontmatter] }) }),
tomekit(),
viteReact({ include: /\.(mdx|js|jsx|ts|tsx)$/ }),
],
});Including .mdx in the React plugin makes an edit to a body update the page in place, keeping component state. cached is optional. Without it, every build compiles every file. See Caching.
The config
Load the .mdx files and return each file as the body with fileModule. Its type parameter is what the file exports:
import type { MDXModule } from "mdx/types";
import { defineConfig, directory, fileModule } from "tomekit";
import { z } from "zod";
export default defineConfig({
collections: {
posts: {
loader: directory("content/posts", { files: "**/*.mdx" }),
schema: z.strictObject({ title: z.string() }),
transform: ({ file }) => ({ body: fileModule<MDXModule>(file.path) }),
},
},
});The document's body is then the file's path, eg "content/posts/hello.mdx", typed as Module<MDXModule>. It's a string, so it passes through a server function or a loader like the rest of the document.
fileModulecan only be the body. Anywhere else, the build fails.- The path is relative to the project root. A file that doesn't exist fails the build.
transformcan still readbody, the MDX text, eg to collect headings intometadata.
Rendering
Pass the body to importModule from tomekit/content-modules. It imports the compiled file, which the bundler puts in its own chunk, so a page loads only its own post. It holds no documents, so it's safe in browser code.
import { createFileRoute } from "@tanstack/react-router";
import type { MDXModule } from "mdx/types";
import { Suspense, use } from "react";
import type { Module } from "tomekit";
import { importModule } from "tomekit/content-modules";
import { Chart } from "#/components/chart";
import { getPost } from "#/server/posts";
export const Route = createFileRoute("/posts/$slug")({
loader: ({ params }) => getPost({ data: params.slug }),
component: Post,
});
function Body({ body }: { body: Module<MDXModule> }) {
const { default: Content } = use(importModule(body));
return <Content components={{ Chart }} />;
}
function Post() {
const { body, metadata } = Route.useLoaderData();
return (
<article>
<h1>{metadata.title}</h1>
<Suspense>
<Body body={body} />
</Suspense>
</article>
);
}getPost is the server function from TanStack Start. The page renders on the server, and the browser loads the same chunk to hydrate it. importModule returns the same promise for a body every time, so use() suspends only until the first import finishes.
An MDX file can also import components itself, with paths relative to the file:
---
title: Hello
---
import { Chart } from "../../src/components/chart";
<Chart />Caching
cached from tomekit/vite wraps one plugin and keeps what its transform returns for each file in .tomekit/cache/plugins. The next vite build or dev server reuses a file's result instead of compiling it again. The plugin's other hooks run as before.
A result is reused only while all of these stay the same:
- the file's path, and its text as the plugin receives it
vite.config.tsand every local file it imports, since the plugin's options, eg remark plugins, live there- the options passed to Vite on the command line or to
build(), eg--baseor--sourcemap - your lockfile, so installing or upgrading a package compiles everything again
- the version of tomekit
Builds and dev, each --mode and each Vite environment, eg client and ssr, keep their own results, since MDX compiles differently for each. An edit to a body in dev still updates the page in place: it changes the file's text, so the file compiles again.
A build saves the results when it ends, also when it fails. The dev server saves a second after it compiles a file, and when it closes, eg on q or a SIGTERM. Ctrl+C stops it without saving, so a file it compiled in its last second compiles again next time. The first save in each build or dev server drops the results of deleted files.
When nothing is cached
cached keeps nothing on disk, and says so once in the terminal, when:
- there is no lockfile from the root up, since a package upgrade would go unnoticed
- Vite runs without a config file (
configFile: false), since nothing on disk stands in for the plugin's options - Vite loads the config with
--configLoader native, which doesn't report every file the config imports
A file compiles every time, also with one message, when its transform uses its plugin context for something a stored result can't repeat, eg this.emitFile, this.addWatchFile or this.warn, or returns a field other than code, map, moduleType and moduleSideEffects. The other files are still cached.
What the cache can't see
The plugin's output must depend only on the file and on the code that runs. tomekit can't see:
- an environment variable your Vite config reads to build the plugin's options. Pass it through
--modeinstead, or delete.tomekit/cache/pluginsafter changing it. - a package linked from your workspace, since the lockfile stays the same.
- a file the plugin reads itself while compiling, eg with
node:fs.
Deleting .tomekit is always safe: the next build compiles everything again.
Two MDX setups
The plugin's name picks its cache files, so two cached plugins with the same name fail at startup with DuplicateCachedPluginError. Give one its own name:
plugins: [
cached({ enforce: "pre", ...mdx({ include: "content/docs/**" }) }),
cached({ enforce: "pre", ...mdx({ include: "content/blog/**" }), name: "mdx-blog" }),
tomekit(),
],cached takes one plugin with a transform hook. Anything else, eg a list of plugins, throws UncacheablePluginError.
Syntax errors
The bundler compiles the MDX, so a syntax error shows in Vite's overlay in dev and fails vite build, pointing at the file.
Register
tomekit/register also sends tomekit/content-modules to the generated .tomekit/content-modules.js. A script that only reads metadata works as it is. To render a body, Node has to load .mdx files too, eg with @mdx-js/node-loader.