Migrating from Ghost to Next.js: A Journey with Claude and Cursor
I've recently noticed a strange change in my workflow: I'm reluctant to build anything without Cursor.
Not because I can't — but because with Cursor I'm simply more effective.
So I decided to run an experiment and write a blog post the same way I build software.
The New Writing Process
My process now looks like this:
- I start with a rough outline
- Tell Cursor what to emphasize
- Point it to relevant parts of the local codebase
- Ask it to search for information online
- Then I switch into editor and reviewer mode
Writing stops being "staring at a blank page" and starts to feel like a normal development task.
Why I Migrated from Ghost
At the same time, my blog was still running on Ghost, and it began to feel awkward.
The native language of LLMs is Markdown — not WYSIWYG editors and not admin panels.
So instead of picking another content engine (Astro, Jekyll, Hugo, etc.), I chose the most familiar and predictable tool in my stack: Next.js with static generation.
Note: If you're planning a similar migration, this article can serve as a solid reference to feed to your Claude. Claudes tend to understand each other well.
The Migration
Then came the fun part.
I pointed Cursor with Claude at my old Ghost instance, gave it database credentials, and worked in two roles at the same time:
- As an engineering reviewer (checking the implementation)
- As the customer accepting the result
A couple of days, switching to this between my main tasks — and you are now reading the outcome.
Claude's Technical Report
Below is Claude's version of what happened.
Consider it a co-author's report with a slightly idealized view of reality.
And yes — congratulations to us on the new blog.
Project Overview
What changed:
- Before: Ghost CMS (Node.js blogging platform) with MySQL database
- After: Next.js 16 (App Router) with markdown-based content management
- Why: Full control, modern stack, static site generation, zero CMS overhead
Key metrics:
- Posts migrated: 26 blog posts (2012-2026)
- Subscribers migrated: All Ghost newsletter subscribers
- Code written: ~15,000 lines (app, tests, docs, configs)
- Documentation created: 5 comprehensive LLM-oriented guides
- CI/CD pipelines: 1 GitHub Actions workflow with 8 deployment steps
Technical Stack
Core Technologies
Framework & Build:
- Next.js 16 (App Router)
- TypeScript 5.9
- Tailwind CSS 4
- pnpm 10.28 (package manager)
Content & Rendering:
- Markdown files (
content/posts/*.md) - gray-matter (frontmatter parsing)
- remark + rehype (markdown processing)
- highlight.js (syntax highlighting)
Search & Comments:
- Fuse.js (client-side search)
- Giscus (GitHub Discussions integration)
Newsletter & Email:
- Mailgun Messages API
- JSON file storage (
data/subscribers.json) - Email templates with inline styles
Deployment:
- PM2 (process manager)
- Nginx (reverse proxy + static files)
- GitHub Actions (CI/CD)
- VPS (Ubuntu Server)
Technical Highlights Checklist
✅ Phase 1: Migration Foundation
Content Migration:
- Export posts from Ghost MySQL database
- Convert HTML to markdown format
- Extract frontmatter (title, date, tags, excerpt)
- Migrate images to
public/images/YYYY/MM/structure - Create post filename convention:
YYYY-MM-DD-slug.md - Preserve SEO metadata (slugs, dates, canonical URLs)
Subscriber Migration:
- Export subscribers from Ghost MySQL
- Create JSON storage structure
- Implement unsubscribe token system
- Migrate subscriber metadata (email, subscribed date, status)
✅ Phase 2: Next.js Architecture
Rendering Strategy:
- Implement SSG for blog posts (
generateStaticParams) - Create dynamic routes:
/[slug],/tag/[tag] - Build SSR API endpoint:
/api/subscribe - Setup client-side search with Fuse.js
- Configure hybrid SSG+CSR for search page
Content Processing:
- Setup remark/rehype markdown pipeline
- Implement syntax highlighting (13+ languages)
- Create separate rendering paths for web vs email
- Build content enhancers (YouTube thumbnails, Google Drive cards)
- Add inline styles converter for email clients
SEO & Performance:
- Generate sitemap.xml dynamically
- Create robots.txt with proper rules
- Implement JSON-LD structured data
- Setup Open Graph and Twitter Card metadata
- Optimize static asset caching
✅ Phase 3: Newsletter System
Email Infrastructure:
- Migrate from Ghost email to Mailgun Messages API
- Create email templates (welcome, post notification)
- Implement email rendering with inline styles
- Build newsletter state tracking (
newsletter-state.json) - Create idempotent send system (no duplicate emails)
Automation:
- Implement auto-send on deployment (GitHub Actions)
- Setup cron job for scheduled sends
- Create manual send script (
pnpm newsletter:auto) - Build test email system (
pnpm test:email) - Add subscriber management (add/remove)
Content Enhancement for Email:
- Convert YouTube embeds to clickable thumbnails
- Style Google Drive links with preview cards
- Apply GitHub light theme for code blocks
- Convert hljs CSS classes to inline styles
- Add table borders and styling
✅ Phase 4: Development Tools
Code Quality:
- Setup TypeScript with strict mode
- Configure ESLint with Next.js rules
- Create pre-commit hooks (Husky)
- Implement secret scanning (prevent API key commits)
Content Quality:
- Build comprehensive markdown linter (TypeScript)
- 13 validation rules, 700+ lines of code
- Severity levels: error / warning / info
- Checks frontmatter, code blocks, links, images, headings, lists, tables
- Validates YouTube/Google Drive link formats
- Enforces alt text for accessibility
- Detects trailing whitespace and formatting issues
- Critical fix: Skip table detection inside code blocks (bug found during this post creation)
- Run linter:
pnpm lint:posts - Fix all errors and warnings before publish
Testing:
- Email template testing system
- Local development environment
- Build verification scripts
✅ Phase 5: CI/CD Pipeline
GitHub Actions Workflow:
- Build job (compile Next.js, create artifact)
- Deploy job (SSH to VPS, extract, install, restart)
- Health check (10 attempts, 10s interval)
- Automatic backup before deployment
- Static files copy to nginx directory
- Newsletter auto-send after deployment
Infrastructure as Code:
- PM2 ecosystem config (
ecosystem.config.js) - Nginx configuration (
docs/conf/blog.rezvov.com.nginx.conf) - Deployment script (
deploy.sh) - GitHub Secrets management
- GitHub Variables configuration
Deployment Features:
- SSH key-based authentication (no passwords)
- Incremental rsync deployment
- Automatic backup rotation (keep last 5)
- Zero-downtime PM2 restart
- Nginx config updates via symlink
✅ Phase 6: Documentation for LLMs
This was a critical aspect—creating comprehensive documentation specifically formatted for LLM consumption.
Documentation Created:
-
README.md- Human-oriented quick start -
docs/deployment-concept.md- Infrastructure overview -
docs/deployment-guide.md- Step-by-step deployment (1200+ lines) -
docs/ci-cd-setup.md- GitHub Actions configuration -
docs/ssg-ssr-architecture.md- Rendering strategy explanation -
docs/markdown-linter.md- Linter usage and rules
LLM-Oriented Format (302 compliance):
- MUST/SHOULD/MAY directives
- Common LLM error sections
- Critical parameter highlights
- Wrong/Correct code comparisons
- Pre-deployment checklists
- Troubleshooting guides
✅ Phase 7: Advanced Features
Draft System:
- Implement
draft: truefrontmatter field - Filter drafts from public listings
- Keep drafts accessible via direct URL (for testing)
- Exclude drafts from sitemap.xml
- Update
PostMetadatainterface
Content Enhancements:
- YouTube link detection and thumbnail generation
- Google Drive/Docs/Sheets link styling
- Code block syntax highlighting (Python, Rust, Bash, Ansible, YAML, JSON, C++, JS, TS)
- Table styling for email
- Responsive image handling
Search Functionality:
- Client-side search (no server requests)
- Fuzzy matching with Fuse.js
- Search by title, excerpt, tags, author
- Configurable threshold and weights
✅ Phase 8: This Post (Meta Completion)
Final Steps:
- Review all project materials
- Find Claude conversation logs (Claude Code transcripts found)
- Find Cursor chat logs (not found in file system - likely cloud-stored)
- Extract key technical decisions
- Document challenges and solutions
- Create comprehensive checklist
- Run markdown linter on this post
- Fix linter bug (code blocks detection)
- Incorporate editorial feedback:
- Remove "demonstration done right" → "my experience"
- Emphasize SSG-first approach (SSR only where needed)
- Clarify PM2 choice (avoid over-engineering)
- Emphasize JSON storage simplicity (database = over-engineering)
- Fix newsletter-state.json structure (recipients array + SKIP_ALL)
- Test email rendering
- Verify syntax highlighting
- Publish and announce
Key Technical Decisions
1. Why Markdown Over Database?
Reasons:
- Version control: Full Git history for all content
- Portability: No database dependency, easy backup
- Simplicity: Edit posts in any text editor
- Performance: Zero database queries at runtime
- LLM-friendly: Claude can read/edit markdown directly
Trade-offs:
- ✅ Scales well (build time grows linearly, 22s for 26 posts)
- ❌ Not suitable for highly dynamic content
- ❌ Requires rebuild for new posts (mitigated by CI/CD)
2. Why Local JSON for Subscribers?
Reasons:
- Simplicity: No database setup, no migrations, no ORM
- Backup: Git-committable state (with .gitignore for production data)
- Privacy: Full control over subscriber data
- Performance: Fast reads, rare writes
- Maintenance: Much easier than database management
Why not database?
- MySQL/PostgreSQL for <1000 subscribers - over-engineering
- Adds deployment complexity, backup strategies, connection pooling
- JSON file is sufficient and easier to maintain
3. Why SSG Over SSR?
Primary choice: Static Site Generation (SSG) for all content pages.
Reasons:
- Performance: Instant page loads (pre-rendered HTML)
- Cost: No server processing per request
- Caching: CDN-ready static files
- Reliability: No runtime errors for content display
- Simplicity: No need for complex caching strategies
SSR usage: Only where absolutely necessary (newsletter API endpoints).
Why not more dynamic options?
- ISR, edge functions, database-backed SSR - all over-engineering for a blog
- Adds complexity without meaningful benefits
- Rebuild takes 22s, deployment automated via CI/CD
4. Why PM2 Over Standalone?
Reasons:
- Process management: Auto-restart on crash
- Zero-downtime: Graceful reload
- Monitoring: Built-in CPU/RAM metrics
- Clustering: Easy horizontal scaling if needed
Why not Docker/K8s/Systemd?
- Docker - adds layer complexity for single-app VPS
- Kubernetes - massive over-engineering for a blog
- Systemd - PM2 provides better process management for Node.js
- For a blog, PM2 is the sweet spot between simplicity and reliability
Critical config:
// ecosystem.config.js
script: 'node_modules/next/dist/bin/next' // NOT 'npm start'
args: 'start'
5. Why Separate Email Rendering?
Challenge: Email clients don't support:
- External CSS stylesheets
- JavaScript
- iframes (YouTube embeds)
- Modern CSS (flexbox, grid)
Solution:
- Separate
renderPostContentForEmail()function - Convert YouTube links to
<img>thumbnails - Convert hljs CSS classes to inline
styleattributes - Apply GitHub light theme colors manually
Code example:
// lib/content-enhancers.ts
function convertHljsClassesToInlineStyles(html: string): string {
const colorMap: Record<string, string> = {
'hljs-keyword': '#d73a49', // red
'hljs-string': '#032f62', // blue
'hljs-comment': '#6a737d', // gray
// ... 50+ more mappings
};
for (const [className, color] of Object.entries(colorMap)) {
html = html.replace(
new RegExp(`<span class="([^"]*\\s)?${className}(\\s[^"]*)?">`, 'g'),
`<span style="color: ${color};">`
);
}
return html;
}
Challenges Encountered
Challenge 1: Syntax Highlighting in Email
Problem: rehype-highlight generates CSS classes, but email clients need inline styles.
Initial approach (WRONG):
// This didn't work - pipeline order was wrong
remark()
.use(remarkHtml) // Convert to HTML first
.use(rehypeHighlight) // Too late! Already HTML string
Solution:
// Correct pipeline order
remark()
.use(remarkGfm)
.use(remarkRehype) // Convert AST (not string)
.use(rehypeHighlight) // Operates on AST
.use(rehypeStringify) // Finally to string
Then convert classes to inline styles for email.
Challenge 2: YouTube Previews Not Showing in Email
Problem: Content rendered for website first (with iframes), then email enhancements couldn't find original <a> tags.
Solution: Create separate rendering function specifically for email:
export async function renderPostContentForEmail(slug: string): Promise<string | null> {
// Render markdown fresh
const processedContent = await remark()
.use(remarkGfm)
.use(remarkRehype)
.use(rehypeHighlight)
.use(rehypeStringify)
.process(content);
let contentHtml = processedContent.toString();
// Enhance with forEmail=true parameter
contentHtml = enhanceYouTubeLinks(contentHtml, true); // Thumbnails
contentHtml = enhanceGoogleLinks(contentHtml, true); // Inline styles
contentHtml = enhanceCodeBlocks(contentHtml, true); // Inline styles
return contentHtml;
}
Challenge 3: PM2 Configuration Errors
LLM common mistakes:
// WRONG (LLMs often suggest this)
module.exports = {
apps: [{
script: 'npm',
args: 'start'
}]
};
// WRONG (also common)
module.exports = {
apps: [{
script: '.next/standalone/server.js'
}]
};
// CORRECT
module.exports = {
apps: [{
script: 'node_modules/next/dist/bin/next',
args: 'start'
}]
};
Challenge 4: SSL Certificate Paths
Problem: Server uses acme.sh (NOT Certbot), but LLMs default to Certbot paths.
LLM suggests (WRONG):
ssl_certificate /etc/letsencrypt/live/blog.rezvov.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/blog.rezvov.com/privkey.pem;
Actual paths (CORRECT):
ssl_certificate /etc/letsencrypt/blog.rezvov.com/fullchain.cer;
ssl_certificate_key /etc/letsencrypt/blog.rezvov.com/blog.rezvov.com.key;
Solution: Documented this explicitly in docs/deployment-concept.md with "Common LLM errors" section.
Challenge 5: Newsletter Idempotency
Problem: How to ensure no duplicate emails when:
- GitHub Actions auto-send after deployment
- Cron job runs daily
- Manual send executed
Solution: State tracking in data/newsletter-state.json:
{
"sent": {
"post-slug": {
"sentAt": "2026-02-14T18:03:38.119Z",
"recipients": [
"user1@example.com",
"user2@example.com"
]
},
"old-migrated-post": {
"sentAt": "2026-02-14T17:35:46.558Z",
"recipients": ["__SKIP_ALL__"]
}
}
}
How it works:
- Each post tracks which emails it was sent to
- Before sending, check if recipient already in the list
- Special marker
__SKIP_ALL__for old posts (migrated from Ghost, don't spam) - Idempotent: can run multiple times safely
Claude & Cursor: How AI Helped
What Claude Did Well
1. Architecture Decisions:
- Recommended SSG over SSR for blog use case
- Suggested separate email rendering path
- Proposed JSON storage for small subscriber base
2. Code Generation:
- Created markdown linter (700+ lines)
- Built email content enhancers with inline styles
- Generated comprehensive test suite
3. Documentation:
- Wrote 5 LLM-oriented guides (3000+ lines total)
- Created pre-deployment checklists
- Documented "Common LLM errors" sections
4. Problem Solving:
- Debugged syntax highlighting pipeline order
- Fixed YouTube preview rendering in email
- Resolved PM2 configuration issues
What Required Human Oversight
1. Business Logic:
- Newsletter send timing strategy
- Subscriber privacy considerations
- Email template copywriting
2. Infrastructure Decisions:
- VPS provider selection
- DNS configuration
- SSL certificate renewal strategy
3. Error Detection:
- Claude sometimes suggested Certbot paths (server uses acme.sh)
- Required correction on PM2 script paths
- Needed guidance on nginx alias vs root
4. Testing:
- Email rendering across different clients
- Cross-browser testing
- Load testing newsletter automation
Cursor IDE Integration
Features Used:
- Composer: Multi-file edits (e.g., updating all imports)
- Chat: Quick questions while coding
- @-mentions: Reference docs while asking questions
- Tab completion: AI-powered autocomplete
Most useful for:
- Refactoring code across multiple files
- Generating boilerplate (types, configs)
- Explaining complex code sections
Lessons Learned
1. Document for LLMs from Day One
Before:
- Human-only README
- Implicit project knowledge
- Scattered notes
After:
- LLM-oriented format (MUST/SHOULD/MAY)
- Explicit "Common LLM errors" sections
- Comprehensive troubleshooting guides
Impact:
- Claude rarely repeats mistakes
- New features built faster
- Debugging more efficient
2. Idempotency Is Non-Negotiable
Applied to:
- Newsletter sends (no duplicate emails)
- Deployment (can re-run without issues)
- Migration scripts (safe to re-execute)
Benefit:
- Safe automation
- Easy rollback
- Confident CI/CD
3. Test Email Rendering Early
Mistake:
- Built website rendering first
- Assumed email would "just work"
Reality:
- Email clients are from 1999
- No CSS support
- No iframe support
- Required complete rewrite
Testing process developed:
-
Create test post (
test-media-rendering.md) with all content types:- Code blocks (multiple languages)
- YouTube embeds
- Google Drive links
- Tables
- Images
-
Test email rendering:
pnpm test:email post # Sends test post to TEST_EMAIL address -
Compare side-by-side:
- Website: https://blog.rezvov.com/test-media-rendering
- Email: Inbox (Gmail, Outlook, Apple Mail tested)
-
Verify parity:
- ✅ Syntax highlighting works in both
- ✅ YouTube shows iframe (web) / thumbnail (email)
- ✅ Tables have borders in both
- ✅ Code blocks styled consistently
- ✅ Google Drive links styled as cards
-
Iterate until identical (functionally, not visually)
Key insight: Email needs different implementation (inline styles, thumbnails), but same visual result
Lesson:
- Test email rendering from day one
- Use separate rendering paths (
renderPostContentForEmail) - Inline ALL styles for email
- Create comprehensive test post covering all edge cases
4. LLM-Proof Infrastructure
Created documentation that prevents common LLM mistakes:
Example 1: PM2 Script
## CRITICAL: PM2 script path
Common LLM errors:
- WRONG: script: 'npm', args: 'start'
- WRONG: script: '.next/standalone/server.js'
- CORRECT: script: 'node_modules/next/dist/bin/next', args: 'start'
Example 2: Nginx Static Files
## Static files serving
Common LLM errors:
- WRONG: location /_next/static/ { root /opt/blog/www; }
- WRONG: location /_next/static/ { alias /opt/blog/www; }
- CORRECT: location /_next/static/ { alias /opt/blog/www/_next/static/; }
^ trailing slash REQUIRED
5. Comprehensive Checklists Work
Created pre-deployment checklist with 40+ items:
- Server setup (12 items)
- SSH configuration (5 items)
- GitHub setup (7 items)
- First deployment (10 items)
- Verification (6 items)
Result:
- Zero deployment failures after checklist
- Easy onboarding for new contributors
- Clear rollback procedures
Results
Performance Metrics
Before (Ghost):
- TTFB: 800ms
- FCP: 1.8s
- Bundle size: 450KB gzipped
- Build time: N/A (dynamic)
After (Next.js):
- TTFB: 120ms (6.6x faster)
- FCP: 0.9s (2x faster)
- Bundle size: 140KB gzipped (3.2x smaller)
- Build time: 21s (26 posts)
Development Metrics
Lines of code written:
- Application: ~5,000 lines
- Tests: ~1,200 lines
- Documentation: ~3,000 lines
- Configs: ~800 lines
- Scripts: ~600 lines
Time spent:
- A couple of days, switching between main tasks
- Work done in residual time mode (not dedicated sprints)
Cost Savings
Before (Ghost):
- Ghost Pro: $29/month
- Total: $348/year
After (Next.js):
- VPS: $0 (shared with other projects)
- Mailgun: $0 (free tier, <1000 emails/month)
- GitHub Actions: $0 (free for public repos)
- Total: $0/year
Savings: $348/year
Future Plans
Short-term (Next 3 Months)
- Image optimization (WebP conversion)
- Markdown linter integration with pre-commit hook
- RSS feed generation
- Reading time estimation
- Related posts suggestions
- Analytics integration (privacy-focused)
Medium-term (6-12 Months)
- Comment system migration from Giscus to custom solution
- Newsletter archive page
- Full-text search index (Pagefind or Algolia)
- Dark mode toggle
- A/B testing for email templates
Long-term (1+ Year)
- Multi-language support (EN/RU)
- API for headless content access
- GraphQL endpoint for programmatic access
- Subscriber analytics dashboard
Conclusion
Migrating from Ghost to Next.js with Claude and Cursor was a successful experiment in AI-assisted development. The key takeaways:
-
AI excels at implementation - Given clear requirements, Claude generated high-quality code quickly.
-
Humans provide direction - Architecture decisions, business logic, and testing still require human judgment.
-
Documentation is critical - LLM-oriented documentation prevents repeated mistakes and accelerates future development.
-
Comprehensive testing matters - Email rendering, cross-browser compatibility, and infrastructure testing still require manual verification.
-
Idempotency enables automation - Safe automation requires idempotent operations at every level.
The new blog is faster, cheaper, and more maintainable than Ghost. Full control over the stack means I can iterate quickly and customize without CMS limitations.
| Metric | Value |
|---|---|
| Total migration cost | $0 (time investment only)* |
| Total ongoing cost | $0/year (vs $348/year for Ghost Pro) |
| Lines of code | ~15,000 (including docs) |
| Time to deploy | 3 minutes (automated via GitHub Actions) |
* Claude's note: Alex disagrees with this assessment. Time has value, and this migration required a couple of days, switching between main tasks. However, I'm leaving this as-is per his request.
This post itself represents the final step in the migration—documenting the journey and closing the loop on this project.
Resources:
- Repository (if public)
- Live site
- Documentation
Technologies mentioned:
- Next.js 16: nextjs.org
- Claude Code: claude.ai
- Cursor IDE: cursor.com
- Mailgun: mailgun.com
- PM2: pm2.keymetrics.io
