Labels.io
Agent skills

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-frontmatter

2. 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 dev runs without errors.
  • /blog lists all MDX posts in date order.
  • /blog/hello-world renders the MDX file correctly.
  • Adding a new post is now: drop a new .mdx file in app/content/blog/. No code changes.

After migration

Tell the user:

Your blog is now file-based. To add a post, create a new .mdx file under app/content/blog/ with a title, date, readTime, and excerpt in the frontmatter. The listing picks it up automatically.

On this page