This is the site you're reading right now. I built it in about a week, pivoted the entire concept once, and it currently costs less than $20/month to run.
Here's the full technical breakdown: what I chose, why I chose it, what it actually costs, and the mistakes I made along the way.
The Stack
- Framework: Next.js 15 with App Router, React 19, TypeScript 5
- CMS: Sanity (headless, GROQ queries, Portable Text)
- Styling: Tailwind CSS v4 with @tailwindcss/typography
- Components: shadcn/ui + Base UI + Lucide icons
- Email: Resend (newsletter subscriptions)
- Hosting: Vercel (frontend) + Sanity (CMS)
- Domain: GoDaddy (purchased) + Cloudflare (DNS/email)
- Analytics: Google Analytics 4
Nothing exotic. Every piece was chosen for one reason: I already knew how to use it, or it had a generous free tier.
Why Next.js 15 + Sanity
I considered three options:
Option 1: Static site generator (Astro, Hugo)
- Pros: Fast, cheap, simple
- Cons: No CMS, content changes require git commits and deploys
Option 2: WordPress
- Pros: Everyone knows it, massive plugin ecosystem
- Cons: PHP, hosting costs, security surface area, slow without caching
Option 3: Next.js + headless CMS
- Pros: React ecosystem, ISR (Incremental Static Regeneration), Vercel's free tier
- Cons: More complex setup, learning curve for Sanity's GROQ query language
I went with Option 3 because I wanted two things: a site that feels instant (static pages) but updates without redeploying (ISR). Next.js 15's App Router with revalidate = 60 gives me both. Pages are statically generated at build time, then refresh every 60 seconds if the content changed in Sanity.
Sanity specifically because: free tier is generous (100K API calls/month), the query language (GROQ) is more powerful than REST endpoints, and the Studio (CMS admin) deploys as part of my Next.js app at /studio.
Architecture Decisions
ISR Strategy
Every blog post page uses generateStaticParams() to pre-render at build time, plus revalidate = 60 for ISR. This means:
- First visit after publishing: might take 1-2 seconds (generating the page)
- Every visit after that: instant (serving from CDN cache)
- Content updates in Sanity appear within 60 seconds
The RSS feed uses a different strategy: Cache-Control: s-maxage=3600 — one-hour CDN cache. RSS readers poll infrequently, so freshness matters less than for web pages.
Content Model
Sanity has one unified post document type with a category field: build, launch, grow, or autopsy. I initially considered separate document types per category, but that would have meant 4x the queries, 4x the schemas, and 4x the maintenance. One type with a category filter is simpler.
Each post has: title, slug, category, excerpt, body (Portable Text), coverImage, readTime, difficulty, tools, affiliateLinks, and SEO fields. 22 fields total, but most are optional.
Markdown to Portable Text Pipeline
I write articles in markdown, then run a custom upload script that:
- Parses frontmatter (title, slug, category, excerpt)
- Converts markdown body to Sanity's Portable Text format
- Generates an SVG thumbnail → converts to PNG with sharp
- Uploads thumbnail to Sanity's CDN
- Creates (or updates) the post document
The script handles bold, inline code, links, headings, lists, and italic blocks. It's 400 lines of JavaScript — not pretty, but it means I never touch the Sanity Studio for publishing. Write markdown, run script, done.
Component Architecture
18 components total. The important ones:
- PortableTextRenderer — Custom renderer for Sanity's Portable Text. Maps block types to styled React components. Handles headings, code blocks, callouts, images, and links with Tailwind typography classes.
- NewsletterForm — Client component (
"use client") with form states: idle → loading → success/error. Posts to/api/subscribewhich hits Resend's API. - CategoryPage — Shared layout for /build, /launch, /grow, /autopsy. Takes a category prop, fetches posts, renders the grid.
The Numbers
Monthly Costs
Service | Plan | Cost
Vercel | Hobby (free) | $0
Sanity | Free tier | $0
Cloudflare | Free | $0
GoDaddy | Domain | ~$12/year ($1/mo)
Resend | Free tier (3K emails/mo) | $0
Google Analytics | Free | $0
**Total** | **~$1/month**
At current traffic levels (near zero), everything fits within free tiers. When traffic grows, Vercel Pro is $20/month and Sanity Growth is $15/month. So the realistic ceiling is about $36/month for a fully operational blog with CMS.
Codebase Size
- 62 TypeScript files
- 18 components
- 13 pages + 2 API routes
- 11 Sanity document types (7 active, 4 legacy)
- 20+ GROQ queries
Not small, not large. About right for a production blog with CMS integration.
Build Performance
- Full build: ~30 seconds
- Static pages generated: 14 (6 posts + 8 static pages)
- First Load JS shared: 102 KB
- Individual page JS: 120-195 KB
Mistakes I Made
Mistake 1: Building the old concept first
I originally built this site as an "AI content creator blog" — tool reviews, prompt packs, weekly digests. I built 7 page types, 11 Sanity schemas, and a full e-commerce flow for selling prompt packs.
Then I pivoted to a micro venture studio. All that work — the shop, the prompt packs page, the weekly digest, the AI tool reviews section — deleted. Seven entire page directories, gone.
The lesson: Don't build the full site before validating the concept. I should have started with a single landing page and one blog post. Instead, I built a complete content platform for a strategy I abandoned in two weeks.
Mistake 2: Too many Sanity schemas
I created separate document types for everything: AI tool reviews, workflows, digests, prompt packs, posts. After the pivot, I couldn't delete the schemas without potentially breaking the Sanity Studio. They're still in the codebase, marked @deprecated, doing nothing.
Should have started with one generic "content" type and added specialization later.
Mistake 3: Category type refactoring
When I changed categories from design | marketing | writing to build | launch | grow | autopsy, I forgot to commit the type changes before deploying. Vercel built from git, which still had the old types. The build failed with a TypeScript error that took an hour to debug.
The fix was embarrassingly simple: git add . && git commit. But the debugging wasn't, because the error message pointed to the wrong file.
What I'd Skip Next Time
- shadcn/ui setup — I use maybe 5 of the 30+ components. Should have just written the button and card components from scratch.
- Multiple Sanity schemas — One
posttype with acategoryfield is all I needed from day one. - Legacy migration code — I kept
@deprecatedquery functions for backward compatibility. There was nothing to be backward-compatible with. Zero users, zero data to migrate.
What Was Worth It
- ISR with 60-second revalidation — Publish in Sanity, see it live in a minute. No redeploy needed.
- Custom upload script — Write markdown, run one command, post is live. Worth every line of the 400-line script.
- Tailwind v4 with typography plugin — Blog content looks good with zero custom CSS for the prose.
- Resend for email — Free tier, simple API, took 20 minutes to integrate.
Current State
The site has 6 published articles across 4 categories, a newsletter subscription form, RSS feed, sitemap, and proper SEO setup. It costs essentially nothing to run.
The tech stack is boring on purpose. Every interesting engineering decision is saved for the products we build — not the blog that documents them.
ContentsTailor is a micro venture studio. We build products with partners and document the entire process — including how we built the documentation platform itself. See all projects or apply to build with us.
