A minimalist markdown site built with React, Convex, and Vite. Optimized for SEO, AI agents, and LLM discovery.
How publishing works: Write posts in markdown, run npm run sync for development or npm run sync:prod for production, and they appear on your live site immediately. No rebuild or redeploy needed. Convex handles real-time data sync, so all connected browsers update automatically.
- Markdown-based blog posts with frontmatter
- Syntax highlighting for code blocks
- Four theme options: Dark, Light, Tan (default), Cloud
- Real-time data with Convex
- Fully responsive design
- Real-time analytics at
/stats
- RSS feeds at
/rss.xmland/rss-full.xml(with full content) - Dynamic sitemap at
/sitemap.xml - JSON-LD structured data for Google rich results
- Open Graph and Twitter Card meta tags
robots.txtwith AI crawler rulesllms.txtfor AI agent discovery
/api/posts- JSON list of all posts for agents/api/post?slug=xxx- Single post JSON or markdown/rss-full.xml- Full content RSS for LLM ingestion- Copy Page dropdown for sharing to ChatGPT, Claude
- Node.js 18 or higher
- A Convex account
- Install dependencies:
npm install- Initialize Convex:
npx convex devThis will create your Convex project and generate the .env.local file.
- Start the development server:
npm run devCreate markdown files in content/blog/ with frontmatter:
Create optional pages like About, Projects, or Contact in content/pages/:
---
title: "About"
slug: "about"
published: true
order: 1
---
Your page content here...Pages appear as navigation links in the top right, next to the theme toggle. The order field controls display order (lower numbers first).
---
title: "Your Post Title"
description: "A brief description"
date: "2025-01-15"
slug: "your-post-slug"
published: true
tags: ["tag1", "tag2"]
readTime: "5 min read"
image: "/images/my-header.png"
---
Your markdown content here...Add an image field to frontmatter for social media previews:
image: "/images/my-header.png"Recommended dimensions: 1200x630 pixels. Images can be local (/images/...) or external URLs.
Add images in markdown content:
Place image files in public/images/. The alt text displays as a caption.
Edit src/pages/Home.tsx to set your site logo:
const siteConfig = {
logo: "/images/logo.svg", // Set to null to hide
// ...
};Replace public/images/logo.svg with your own logo file.
Replace public/favicon.svg with your own icon. The default is a rounded square with the letter "m". Edit the SVG to change the letter or style.
The default OG image is used when posts do not have an image field. Replace public/images/og-default.svg with your own image (1200x630 recommended).
Update the reference in src/pages/Post.tsx:
const DEFAULT_OG_IMAGE = "/images/og-default.svg";Posts are synced to Convex. The sync script reads markdown files from content/blog/ and content/pages/, then uploads them to your Convex database.
| File | Purpose |
|---|---|
.env.local |
Development deployment URL (created by npx convex dev) |
.env.production.local |
Production deployment URL (create manually) |
Both files are gitignored. Each developer creates their own.
| Command | Target | When to use |
|---|---|---|
npm run sync |
Development | Local testing, new posts |
npm run sync:prod |
Production | Deploy content to live site |
Development sync:
npm run syncProduction sync:
First, create .env.production.local with your production Convex URL:
VITE_CONVEX_URL=https://your-prod-deployment.convex.cloud
Then sync:
npm run sync:prodFor detailed setup, see the Convex Netlify Deployment Guide.
- Deploy Convex functions to production:
npx convex deployNote the production URL (e.g., https://your-deployment.convex.cloud).
- Connect your repository to Netlify
- Configure build settings:
- Build command:
npm ci --include=dev && npx convex deploy --cmd 'npm run build' - Publish directory:
dist
- Build command:
- Add environment variables in Netlify dashboard:
CONVEX_DEPLOY_KEY- Generate from Convex Dashboard > Project Settings > Deploy KeyVITE_CONVEX_URL- Your production Convex URL (e.g.,https://your-deployment.convex.cloud)
The CONVEX_DEPLOY_KEY deploys functions at build time. The VITE_CONVEX_URL is required for edge functions (RSS, sitemap, API) to proxy requests at runtime.
Build issues? Netlify sets NODE_ENV=production which skips devDependencies. The --include=dev flag fixes this. See netlify-deploy-fix.md for detailed troubleshooting.
markdown-site/
├── content/blog/ # Markdown blog posts
├── convex/ # Convex backend
│ ├── http.ts # HTTP endpoints (sitemap, API, RSS)
│ ├── posts.ts # Post queries and mutations
│ ├── rss.ts # RSS feed generation
│ └── schema.ts # Database schema
├── netlify/ # Netlify edge functions
│ └── edge-functions/
│ ├── rss.ts # RSS feed proxy
│ ├── sitemap.ts # Sitemap proxy
│ ├── api.ts # API endpoint proxy
│ └── botMeta.ts # OG crawler detection
├── public/ # Static assets
│ ├── images/ # Blog images and OG images
│ ├── robots.txt # Crawler rules
│ └── llms.txt # AI agent discovery
├── scripts/ # Build scripts
└── src/
├── components/ # React components
├── context/ # Theme context
├── pages/ # Page components
└── styles/ # Global CSS
| Script | Description |
|---|---|
npm run dev |
Start Vite dev server |
npm run dev:convex |
Start Convex dev backend |
npm run sync |
Sync posts to dev deployment |
npm run sync:prod |
Sync posts to production deployment |
npm run build |
Build for production |
npm run deploy |
Sync + build (for manual deploys) |
npm run deploy:prod |
Deploy Convex functions + sync to production |
- React 18
- TypeScript
- Vite
- Convex
- react-markdown
- react-syntax-highlighter
- date-fns
- lucide-react
- Netlify
The /stats page shows real-time analytics powered by Convex:
- Active visitors: Current visitors on the site with per-page breakdown
- Total page views: All-time view count
- Unique visitors: Based on anonymous session IDs
- Views by page: List of all pages sorted by view count
Stats update automatically via Convex subscriptions. No page refresh needed.
How it works:
- Page views are recorded as event records (not counters) to avoid write conflicts
- Active sessions use heartbeat presence (30s interval, 2min timeout)
- A cron job cleans up stale sessions every 5 minutes
- No PII stored (only anonymous session UUIDs)
| Endpoint | Description |
|---|---|
/stats |
Real-time site analytics |
/rss.xml |
RSS feed with post descriptions |
/rss-full.xml |
RSS feed with full post content |
/sitemap.xml |
Dynamic XML sitemap |
/api/posts |
JSON list of all posts |
/api/post?slug=xxx |
Single post as JSON |
/api/post?slug=xxx&format=md |
Single post as markdown |
/meta/post?slug=xxx |
Open Graph HTML for crawlers |
Slugs are defined in the frontmatter of each markdown file:
---
slug: "my-post-slug"
---The slug becomes the URL path: yourdomain.com/my-post-slug
Rules:
- Slugs must be unique across all posts
- Use lowercase letters, numbers, and hyphens
- The sync script reads the
slugfield from frontmatter - Posts are queried by slug using a Convex index
The default theme is Tan. Users can cycle through themes using the toggle:
- Dark (Moon icon)
- Light (Sun icon)
- Tan (Half icon) - default
- Cloud (Cloud icon)
To change the default theme, edit src/context/ThemeContext.tsx:
const DEFAULT_THEME: Theme = "tan"; // Change to "dark", "light", or "cloud"The blog uses a serif font (New York) by default. To switch fonts, edit src/styles/global.css:
body {
/* Sans-serif option */
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
/* Serif option (default) */
font-family:
"New York",
-apple-system-ui-serif,
ui-serif,
Georgia,
Cambria,
"Times New Roman",
Times,
serif;
}Replace the font-family property with your preferred font stack.
Fork this project: github.com/waynesutton/markdown-site