I built a full-featured technical blog with React, MDX, Vite, and Cloudflare in a single engineering session.
The problem: vachsark.com was a portfolio site built with React + Vite + Tailwind. Adding a separate blog repo would mean duplicate build pipelines, design system maintenance, and coordinated deployments. The solution: integrate MDX into the existing Vite project, sharing the design system and deploying both site and blog through the same Cloudflare Pages pipeline.
The result: three published posts, custom typography components, Telegram notifications on new drafts, and a heartbeat automation system that auto-generates post skeletons. The single codebase means changes to the design system automatically propagate to all blog posts without manual sync.
A debugging lesson emerged: when a visual change doesn't render after two CSS adjustment attempts, stop tweaking styles and inspect the build output instead. The missing providerImportSource in vite.config.js silently disconnected all custom MDX components from their Tailwind overrides — a configuration issue disguised as a styling bug.
The Stack
The portfolio site at vachsark.com was already React + Vite + Tailwind. Adding a blog meant adding MDX support to the existing project rather than spinning up a separate repo. Shared design system, single deploy pipeline, no duplicate maintenance.
The MDX pipeline:
@mdx-js/rollupcompiles.mdxfiles during the Vite buildremark-gfmadds GitHub Flavored Markdown (tables, strikethrough)remark-frontmatter+remark-mdx-frontmatterparse YAML frontmatter and export it as a namedfrontmatterobjectrehype-pretty-code+ Shiki handle syntax highlighting with thegithub-dark-dimmedtheme@mdx-js/reactprovidesMDXProviderfor custom component overrides
The blog routes use import.meta.glob — the blog index eagerly loads frontmatter only (for the card list), while individual posts lazy-load their full MDX content on demand. Each post becomes its own chunk.
// Blog index: frontmatter only (no content loaded)
const postMeta = import.meta.glob("./content/blog/*.mdx", {
eager: true,
import: "frontmatter",
});
// Post page: lazy-load full content on demand
const postLoaders = import.meta.glob("./content/blog/*.mdx");Custom Components
Every MDX element gets a custom React component override — headings, paragraphs, tables, code blocks, lists, blockquotes, links, images. The design goal was an Obsidian-like reading experience: generous whitespace, clear hierarchy, and a premium dark-theme editorial feel.
Tables get a dark #0e0e10 container with gold monospace uppercase headers, alternating row backgrounds, and hover states. Code blocks integrate seamlessly with rehype-pretty-code. Lists use CSS counters with gold accent markers. Inline code gets a gold-tinted background treatment.
The h2 headings include a gradient line that extends to the right edge — a small detail that creates visual rhythm between sections.
The Silent Bug
After building all of this, the blog posts looked... plain. No custom typography. No styled tables. No gold accents. Just default HTML rendering.
I made four rounds of changes to MDXComponents.jsx and index.css. Bigger fonts. More spacing. Brighter colors. Nothing worked. The text stubbornly refused to change.
The fix was one line in vite.config.js:
mdx({
providerImportSource: "@mdx-js/react", // THIS LINE
remarkPlugins: [/* ... */],
rehypePlugins: [/* ... */],
}),Without providerImportSource, the @mdx-js/rollup plugin compiles MDX into components that use built-in HTML elements. The MDXProvider wrapper in the post layout is completely ignored. No error. No warning. The page renders fine — just without any of your custom component overrides.
The moment I added that line, every typography change from the previous four cycles took effect simultaneously. Tables had gold headers. Paragraphs had proper spacing. Code blocks had their dark containers. It had all been working the whole time — just disconnected from the output.
The lesson: when a change doesn't work after two attempts at the same layer, stop tweaking and investigate one layer deeper. I should have dumped the rendered DOM on the first failed attempt. The answer — no custom CSS classes on any element — would have been immediately obvious.
Cloudflare Deployment
The site deploys automatically on every git push origin main via Cloudflare Pages. Adding blog.vachsark.com was a CNAME record pointing to the same Pages project, plus a subdomain redirect in the React router:
// In main.jsx — redirect blog subdomain to /blog
if (window.location.hostname === "blog.vachsark.com"
&& window.location.pathname === "/") {
window.location.replace("/blog");
}Same build, same deploy, two domains. The SPA _redirects file handles all client-side routes.
Email routing was also set up — [email protected] forwards to Gmail via Cloudflare Email Routing. The API for this has undocumented permission requirements (there's a GitHub issue about it), so it had to be configured through the dashboard.
Auto-Drafting Heartbeat
The most interesting infrastructure addition: two heartbeat tasks that auto-draft blog post proposals every Sunday.
blog-drafter-vault scans for vault and infrastructure changes — new automation, system evolution, architecture decisions, tooling breakthroughs. It checks file modification times, protocol changes, heartbeat logs, RULES.md evolution, and the CLAUDE.md changelog.
blog-drafter-projects scans git activity across all project repos — performance wins with numbers, multi-commit features, architecture decisions, refactoring stories, interesting bug hunts.
Both run on a local qwen2.5-coder:14b model (zero API cost) and produce structured briefs with topic, angle, key facts, and draft hooks. The briefs batch into the PM Telegram digest. If there's nothing blog-worthy that week, they stay quiet.
The goal is a pipeline: heartbeat surfaces candidates, I review the briefs, and the next session writes the actual posts from the structured data. The blog feeds itself.
Deploy Notifications
Every deploy now pushes a Telegram notification with context:
Succeeded [vachsark.com] (push) — 6ca0ad8..00485f7 main -> main
Succeeded [Linesheet] (convex) — 48 functions
FAILED [FitnessRewards] (build): Module not found...
The hook detects the project from the command or working directory, identifies the deploy type (push/convex/vercel/build), and extracts relevant detail from the output. Previously it just said "Deploy succeeded [unknown]."
What Shipped
In one session:
| Component | Details |
|---|---|
| MDX pipeline | Vite plugin, 6 remark/rehype plugins, frontmatter export |
| Custom components | 18 element overrides (h1-h3, p, table, code, lists, etc.) |
| Blog posts | 3 published (local AI optimization, bundle diet, zero-cost automation) |
| Categories | Projects and Vault sections with filtered views |
| Typography | Obsidian-inspired dark editorial styling, 17px body text, gold accents |
| Infrastructure | Cloudflare DNS, custom domain, email routing, deploy notifications |
| Automation | Weekly blog-drafting heartbeat tasks (vault + projects) |
| Documentation | Project CLAUDE.md with structure, design tokens, and critical rules |
| The blog exists because I wanted to document the AI development process in the open. The irony of spending four cycles debugging a one-line config bug on the blog about debugging is not lost on me. At least now there's a rule for it. |