ikke.tokyo logo

Co-locating MDX in TanStack Router

Published Aug 29, 2026

In Next.js, configuring MDX is covered right in the official docs, and once set up, dropping Markdown files into your project is trivial.

TanStack Start doesn't have an official guide for this yet, but with a small Vite config and import.meta.glob, you can get that exact same DX: co-locating .mdx files right next to your route handlers.

Directory Layout

src/routes/blog/
├── index.tsx
├── $slug.tsx
├── hello-world.mdx
└── getting-started.mdx

Here, index.tsx serves the /blog overview, $slug.tsx handles dynamic /blog/$slug posts, and the .mdx files sit right alongside them.

TanStack Router's route generator only picks up .tsx, .ts, .jsx, and .js files. It completely ignores .mdx files, so they won't interfere with your route tree.

1. Configure Vite for MDX & Frontmatter

Install @mdx-js/rollup along with remark plugins to parse frontmatter:

pnpm add -D @mdx-js/rollup remark-frontmatter remark-gfm remark-mdx-frontmatter

Then add them to your vite.config.ts:

import mdx from "@mdx-js/rollup";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import remarkMdxFrontmatter from "remark-mdx-frontmatter";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    mdx({
      remarkPlugins: [remarkGfm, remarkFrontmatter, remarkMdxFrontmatter],
    }),
  ],
});

This turns your YAML frontmatter into a plain JavaScript object exported right alongside the compiled component.

2. Load Posts with import.meta.glob

Create a helper to load all posts and their metadata:

// src/lib/blog.ts
import type { MDXProps } from "mdx/types";
import type { ComponentType } from "react";

export type BlogPostModule = {
  default: ComponentType<MDXProps>;
  frontmatter: {
    title: string;
    description: string;
    date: string;
    slug?: string;
  };
};

// Discover all .mdx files in the blog folder at build time
const blogModules = import.meta.glob<BlogPostModule>("/src/routes/blog/*.mdx", {
  eager: true,
});

const blogModulesById = new Map<string, BlogPostModule>();

export const POSTS = Object.entries(blogModules)
  .map(([path, mod]) => {
    const slug = mod.frontmatter.slug || path.match(/\/blog\/(.+)\.mdx$/)?.[1] || "";
    blogModulesById.set(slug, mod);

    return {
      slug,
      title: mod.frontmatter.title || slug,
      description: mod.frontmatter.description || "",
      date: mod.frontmatter.date,
    };
  })
  .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

export function getPost(slug: string) {
  return POSTS.find((p) => p.slug === slug);
}

export function getPostModule(slug: string) {
  return blogModulesById.get(slug);
}

3. Render the Post in $slug.tsx

Load the metadata in your route loader and render the component:

// src/routes/blog/$slug.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import { getPost, getPostModule } from "#/lib/blog";

export const Route = createFileRoute("/blog/$slug")({
  loader: ({ params }) => {
    const post = getPost(params.slug);
    if (!post) throw notFound();
    return post;
  },
  component: BlogPostPage,
});

function BlogPostPage() {
  const post = Route.useLoaderData();
  const blogModule = getPostModule(post.slug);
  if (!blogModule) throw notFound();

  const Content = blogModule.default;

  return (
    <article>
      <h1>{post.title}</h1>
      <Content />
    </article>
  );
}

To add a new post, drop a .mdx file in src/routes/blog/. Frontmatter, slugs, and static prerendering all just work.