Add a blog
The blog ships with an inline TypeScript approach: posts live as objects inside app/routes/blog.$slug.tsx. That is fine for the first few posts. Once the user outgrows it (wants images, embedded components, frontmatter, or just cleaner writing), migrate to file-based MDX.
When to use this
Trigger phrases: "convert the blog to MDX", "I want real blog files", "move blog posts to files", "this string-in-TypeScript thing is silly".
For simply adding a new blog post, do not use this kit skill. Add a new entry to the posts record in app/routes/blog.$slug.tsx and to the listing array in app/routes/blog.index.tsx. Pattern-match the existing entries.
Migration steps
1. Install dependencies
pnpm add @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter2. Add the MDX plugin to Vite
In vite.config.ts:
import mdx from '@mdx-js/rollup'
import remarkFrontmatter from 'remark-frontmatter'
import remarkMdxFrontmatter from 'remark-mdx-frontmatter'
export default defineConfig({
plugins: [
mdx({
remarkPlugins: [remarkFrontmatter, remarkMdxFrontmatter],
}),
// ...keep the existing plugins after this
],
})3. Move posts to MDX files
Create app/content/blog/. For each post currently in the inline posts record, create a file like app/content/blog/<slug>.mdx with YAML frontmatter:
---
title: Hello, world
date: 2026-04-24
readTime: 3 min read
excerpt: Welcome post and a tour.
---
## First heading
Body text. Images, components, and all standard MDX features work here.4. Create a loader
app/lib/blog.ts:
type PostModule = {
frontmatter: {
title: string
date: string
readTime: string
excerpt: string
}
default: React.ComponentType
}
const posts = import.meta.glob<PostModule>('../content/blog/*.mdx', {
eager: true,
})
export function getAllPosts() {
return Object.entries(posts)
.map(([path, module]) => {
const slug = path.split('/').pop()!.replace('.mdx', '')
return { slug, ...module.frontmatter }
})
.sort((a, b) => b.date.localeCompare(a.date))
}
export function getPost(slug: string) {
const entry = Object.entries(posts).find(([path]) =>
path.endsWith(`/${slug}.mdx`),
)
if (!entry) return null
const [, module] = entry
return {
slug,
frontmatter: module.frontmatter,
Component: module.default,
}
}5. Swap the routes to use the loader
app/routes/blog.index.tsx:
import { getAllPosts } from '~/lib/blog'
export const Route = createFileRoute('/blog/')({
loader: () => ({ posts: getAllPosts() }),
component: BlogIndexPage,
// ...existing head
})Use Route.useLoaderData() inside the component instead of the inline array.
app/routes/blog.$slug.tsx:
import { getPost } 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()
return (
<Section>
<Container size="sm">
<header>{/* title + date from post.frontmatter */}</header>
<article><post.Component /></article>
</Container>
</Section>
)
}6. Delete the inline posts records
Remove the posts object from app/routes/blog.$slug.tsx and the listing array from app/routes/blog.index.tsx. The MDX loader replaces both.
7. Verify
pnpm devruns without errors./bloglists all MDX posts in date order./blog/hello-worldrenders the MDX file correctly.- Adding a new post is now: drop a new
.mdxfile inapp/content/blog/. No code changes.
After migration
Tell the user:
Your blog is now file-based. To add a post, create a new
.mdxfile underapp/content/blog/with a title, date, readTime, and excerpt in the frontmatter. The listing picks it up automatically.