--- name: react-vite-markdown-blog description: Add a file-based markdown blog to a React + Vite site — one `.md` file per post in `content/blog/`, no CMS, no database, no admin UI. Posts have frontmatter (title, date, excerpt, cover, tags, draft), render at `/blog` (index) and `/blog/:slug` (post), and are read at build time via `import.meta.glob`. Use when the user wants a blog on a React/Vite site without the weight of a CMS, or asks for "a markdown blog like Astro/Jekyll/Hugo but staying in React." --- # React + Vite Markdown Blog > **Disclaimer:** This skill was built and tested on Replit. The core (React + Vite + markdown) is portable, but any deployment-specific details assume Replit's static hosting. If you're shipping elsewhere (Vercel, Netlify, Cloudflare Pages, your own server), consult your host's docs for the equivalent build and routing setup. No guarantees outside the original environment. A file-based blog for a React + Vite SPA: drop a `.md` file in `content/blog/`, it shows up. No CMS, no DB, no admin UI. Suits one-author sites with low-frequency posting (anywhere from once a week to once a quarter). ## When to use - User wants to add a blog to an existing React + Vite site and explicitly does *not* want a CMS or database. - User says things like "I just want to write markdown" or "like Jekyll but in React." - User is the only author and is comfortable committing files to git. **Do not use** if the user needs multi-author workflows with permissions, in-browser editing, scheduled publishing, or comments. Recommend a CMS (Sanity, Decap, Contentful) or a different framework instead. ## What you build 1. A `content/blog/` directory at the **monorepo root** (not inside the web artifact) containing one folder per post. 2. Each post is `content/blog//index.md` with frontmatter + body. A sibling `cover.png` (or similar) is referenced from frontmatter. 3. A blog index route at `/blog` showing teaser cards (title, date, excerpt, cover, tags) in reverse chronological order. 4. A post route at `/blog/:slug` rendering the markdown with site typography. 5. Frontmatter validation that fails the build loudly when a post is malformed. 6. A `draft: true` flag that hides a post in production but shows it in dev. 7. A short `content/blog/README.md` documenting the format for the author. ## Step-by-step ### 1. Install dependencies In the web artifact: ```bash pnpm --filter add marked gray-matter zod ``` Optional, for syntax highlighting in code blocks: `highlight.js` or `shiki`. ### 2. Pick the location convention Use **`content/blog//index.md`** (folder-per-post), not flat files: - Pros: each post has its own asset folder for images, cover, attachments. Slug = folder name. Easy to delete a post by deleting its folder. - Cons: slightly more nesting in the file tree. The folder lives at the monorepo root so it can be shared across artifacts (web today, mobile companion tomorrow) without circular imports. ### 3. Define the frontmatter schema In `artifacts//src/lib/blog.ts`, define and validate with Zod: ```ts import { z } from "zod"; export const PostFrontmatter = z.object({ title: z.string().min(1), slug: z.string().min(1).optional(), // defaults to folder name date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), excerpt: z.string().min(1), coverImage: z.string().optional(), tags: z.array(z.string()).default([]), draft: z.boolean().default(false), author: z.string().optional(), }); export type PostFrontmatter = z.infer; ``` Throw on validation failure — never silently skip a malformed post. ### 4. Load posts at build time with `import.meta.glob` ```ts import matter from "gray-matter"; const modules = import.meta.glob("/content/blog/*/index.md", { query: "?raw", import: "default", eager: true, }); export const allPosts = Object.entries(modules) .map(([path, raw]) => { const slug = path.match(/\/content\/blog\/([^/]+)\/index\.md$/)![1]; const { data, content } = matter(raw as string); const fm = PostFrontmatter.parse({ slug, ...data }); return { ...fm, body: content, path }; }) .filter((p) => import.meta.env.DEV || !p.draft) .sort((a, b) => b.date.localeCompare(a.date)); ``` **Important:** Vite resolves `import.meta.glob` from the project root by default, so `/content/blog/*/index.md` works when content lives at the monorepo root. Verify by checking `vite.config.ts` — if `root` is set to the artifact directory, adjust the glob or add a Vite alias. ### 5. Render markdown safely Use `marked` with a custom renderer that: - Resolves image paths relative to the post's folder (so `![alt](cover.png)` in `content/blog/my-post/index.md` resolves correctly). - Detects bare YouTube/Vimeo URLs on their own line and replaces them with responsive iframe embeds. - Adds `target="_blank" rel="noopener"` to external links. Wrap the rendered HTML in a `
` container and style it with Tailwind Typography or hand-written CSS that matches the site's existing type system. ### 6. Build the index and post pages `/blog` route (`BlogIndex.tsx`): - Reads `allPosts`, renders a grid of teaser cards using the site's existing card style. - Each card: cover image (with a tasteful fallback if missing), title, date, excerpt, tags. - Reverse chronological order. Drafts already filtered out by the loader in production. `/blog/:slug` route (`BlogPost.tsx`): - Looks up the post by slug. 404 if not found. - Renders title, date, cover, then the rendered markdown body. - Includes a "← All posts" link back to `/blog`. ### 7. Wire up navigation Add a "Blog" link to the site header (and mobile menu if there is one). Keep it next to the existing nav items — don't make the blog feel like an afterthought. ### 8. Author-facing README Create `content/blog/README.md` documenting: - The folder convention (`content/blog//index.md`). - Every frontmatter field with an example. - How to add a cover image (drop it in the post's folder, reference filename only). - How to mark a draft (`draft: true`). - Date format requirement (`YYYY-MM-DD`). - A reminder that edits auto-save in Replit, the dev preview hot-reloads, and publishing to production requires a redeploy. ### 9. Seed post Ship one real post so the user can copy it as a template. Use it to exercise the pipeline: at least one `##` and `###` heading, a bulleted list, a numbered list, a blockquote, a fenced code block, an inline link, a cover image, and tags. ## SEO note If the site also uses the `react-vite-seo-prerender` skill, the prerender step needs to read the same post list to enumerate routes (`/blog/:slug`) and to write each post into `sitemap.xml` / `llms.txt`. Create a tiny Node-side mirror at `scripts/blog-source.mjs` that reads the same `content/blog/*/index.md` files with `gray-matter` and applies the same draft filter. **Both readers must agree on what counts as "published"** — diverging filters means the build crashes when the prerender tries to crawl a route the React app doesn't render. Each post page should also call `useSeo()` with: - `title`, `description: excerpt`, `canonicalPath: "/blog/" + slug`, `ogImage: coverImage` - A `BlogPosting` JSON-LD object with `headline`, `datePublished`, `author`, `image`. ## Common pitfalls - **Forgetting to filter drafts in production.** Use `import.meta.env.DEV` as the gate. If you forget, drafts ship live. - **`import.meta.glob` path resolution** — runs from Vite's `root`, not the file doing the import. Test by logging `Object.keys(modules)` once. - **Loading 50 posts eagerly** — fine for under ~100 posts. Past that, switch to `eager: false` and dynamic-import each post on demand in `BlogPost.tsx`. - **Inline `