Back to blog
Migrating from Ghost to Next.js: A Journey with Claude and Cursor

Migrating from Ghost to Next.js: A Journey with Claude and Cursor

February 15, 2026By Alex Rezvov

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:

  1. I start with a rough outline
  2. Tell Cursor what to emphasize
  3. Point it to relevant parts of the local codebase
  4. Ask it to search for information online
  5. 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: true frontmatter field
  • Filter drafts from public listings
  • Keep drafts accessible via direct URL (for testing)
  • Exclude drafts from sitemap.xml
  • Update PostMetadata interface

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 style attributes
  • 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:

  1. Create test post (test-media-rendering.md) with all content types:

    • Code blocks (multiple languages)
    • YouTube embeds
    • Google Drive links
    • Tables
    • Images
  2. Test email rendering:

    pnpm test:email post
    # Sends test post to TEST_EMAIL address
    
  3. Compare side-by-side:

  4. 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
  5. 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:

  1. AI excels at implementation - Given clear requirements, Claude generated high-quality code quickly.

  2. Humans provide direction - Architecture decisions, business logic, and testing still require human judgment.

  3. Documentation is critical - LLM-oriented documentation prevents repeated mistakes and accelerates future development.

  4. Comprehensive testing matters - Email rendering, cross-browser compatibility, and infrastructure testing still require manual verification.

  5. 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:

Technologies mentioned:

Comments