
Migrating from Astro to Next.js: A Journey of Modern Web Development
Why Migrate to Next.js?
After running the blog on Astro for a while, I decided it was time for a significant upgrade. While Astro served well for static content, I wanted more flexibility, better developer experience, and access to the rich React ecosystem. Next.js 16 with its App Router, Server Components, and Turbopack provides exactly that.
Migration Goals
- Maintain all existing content - No blog posts should be lost
- Improve performance - Leverage Next.js optimizations
- Enhance user experience - Add modern interactive features
- Better developer experience - Type-safe content handling
- SEO improvements - Structured data and better metadata handling
Tech Stack Comparison
| Feature | Astro (Before) | Next.js (After) |
|---|---|---|
| Framework | Astro 4.x | Next.js 16.1.2 |
| Router | File-based | App Router |
| Styling | Tailwind CSS | Tailwind CSS 4 + shadcn/ui |
| Package Manager | pnpm | pnpm |
| MDX Processing | @astrojs/mdx | @next/mdx + next-mdx-remote |
| Code Highlighting | Basic Shiki | rehype-pretty-code |
| Build Tool | Vite | Turbopack |
| Analytics | PostHog | Vercel Analytics |
| Deployment | Vercel | Vercel |
New Features Implemented
1. Advanced Code Highlighting
One of the most exciting additions is the advanced code highlighting system powered by rehype-pretty-code and Shiki.
Multiple Code Themes
One of the standout features is the comprehensive code theme system. Users can now personalize their reading experience by choosing from 13 carefully curated code themes, each supporting both light and dark variants:
| Theme | Light Variant | Dark Variant | Description |
|---|---|---|---|
| GitHub | GitHub Light | GitHub Dark | Clean, minimal theme from GitHub |
| GitHub Dimmed | GitHub Light | GitHub Dark Dimmed | Softer variant of GitHub's dark theme |
| One Dark Pro | One Light | One Dark Pro | Popular Atom-inspired theme |
| Dracula | GitHub Light | Dracula | Vibrant purple-based theme |
| Night Owl | GitHub Light | Night Owl | Sarah Drasner's acclaimed theme |
| Monokai | GitHub Light | Monokai | Classic Sublime Text theme |
| Nord | GitHub Light | Nord | Arctic, north-bluish color palette |
| Catppuccin | Catppuccin Latte | Catppuccin Mocha | Soothing pastel theme |
| Rosé Pine | GitHub Light | Rosé Pine | All natural pine with a hint of soho vibes |
| Tokyo Night | GitHub Light | Tokyo Night | Clean, elegant theme inspired by Tokyo's night |
| Material Theme | Material Lighter | Material Darker | Google's Material Design palette |
| Vitesse | Vitesse Light | Vitesse Dark | Anthony Fu's fast and minimal theme |
| Min | Min Light | Min Dark | Minimalist, distraction-free theme |
Key Features:
- Automatic Theme Switching - Code blocks automatically adapt to your system's light/dark mode
- Independent Selection - Choose a code theme independent of your site theme
- Persistent Preferences - Your choice is saved in cookies (1 year expiration)
- Smooth Transitions - Themes switch seamlessly without page reload
- Dual Theme Support - Each theme includes optimized light and dark variants
- Syntax Awareness - Powered by Shiki for accurate syntax highlighting across 200+ languages
How It Works:
The theme selector is accessible via a dropdown in the top-right corner of any code block. When you select a theme:
- The preference is immediately saved to cookies (
blog-code-theme) - All code blocks on the page update instantly
- The theme persists across page navigation
- Light/dark variants switch automatically based on your system theme
export interface CodeTheme {
name: string // Internal identifier
label: string // Display name
light: string // Shiki light theme
dark: string // Shiki dark theme
}
export const CODE_THEMES: Record<string, CodeTheme> = {
github: {
name: 'github',
label: 'GitHub',
light: 'github-light',
dark: 'github-dark',
},
dracula: {
name: 'dracula',
label: 'Dracula',
light: 'github-light',
dark: 'dracula',
},
// ... more themes
}The implementation uses server-side rendering with cookie-based preferences, ensuring there's no flash of unstyled code on page load.
'use client'
export function CodeThemeSelector() {
const [theme, setTheme] = useState('github')
const handleThemeChange = (newTheme: string) => {
setTheme(newTheme)
document.cookie = `blog-code-theme=${newTheme}; path=/; max-age=31536000`
}
return (
<Select value={theme} onValueChange={handleThemeChange}>
{/* Theme options */}
</Select>
)
}Code Block Features
Enhanced code blocks now support:
- Line numbers - Optional line numbering
- Line highlighting - Highlight specific lines
- File names/titles - Show file context
- Copy button - One-click code copying
- Expand to modal - Full-screen code view with backdrop effect
- Language badges - Visual language indicators
- Diff highlighting - Show additions/deletions
Expand Feature:
Every code block includes an expand button (⛶) in the top-right corner (next to the copy button) that opens a modal dialog with:
- Large viewport - 90% of screen height for maximum code visibility
- Extra-wide layout - 6xl width (1280px) for long lines
- Backdrop effect - Semi-transparent overlay (80% opacity) for focus
- Perfect syntax highlighting - Full Shiki theme support with all CSS variables
- Theme awareness - Automatically adapts to light/dark mode
- Smooth scrolling - Both horizontal and vertical overflow
- Clean presentation - Title shown in modal header, no duplication
- Context display - Language displayed in modal title
- Preserved features - Line numbers, highlighting, and diff colors maintained
Implementation Details:
The expand feature works by:
- Cloning the entire
<pre>element with all Shiki-generated HTML - Removing button containers and data attributes to prevent duplication
- Rendering the cloned HTML in a modal with
.proseclass for proper CSS scoping - Using
dangerouslySetInnerHTMLto preserve all syntax highlighting
This is especially useful for long code snippets that would otherwise require excessive scrolling on the page, and it maintains perfect syntax highlighting thanks to proper CSS class application.
Code Example:
export function CodeExpandButton({ code, preElement, language, title }: Props) {
const [isExpanded, setIsExpanded] = useState(false)
const [codeHtml, setCodeHtml] = useState<string>('')
useEffect(() => {
if (isExpanded && preElement) {
// Clone the pre element deeply to preserve syntax highlighting
const clonedPre = preElement.cloneNode(true) as HTMLElement
// Remove button containers to avoid duplicates
const buttonContainers = clonedPre.querySelectorAll('div')
buttonContainers.forEach((div) => {
const style = div.getAttribute('style')
if (style?.includes('position: absolute')) {
div.remove()
}
})
// Remove data attributes to prevent CSS pseudo-elements
clonedPre.removeAttribute('data-title')
clonedPre.removeAttribute('data-caption')
setCodeHtml(clonedPre.outerHTML)
}
}, [isExpanded, preElement])
return (
<Dialog open={isExpanded} onOpenChange={setIsExpanded}>
<DialogContent className="max-w-6xl max-h-[90vh]">
<DialogTitle>{title || `Code (${language})`}</DialogTitle>
<div
className="prose dark:prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: codeHtml }}
/>
</DialogContent>
</Dialog>
)
}Try clicking the expand button (⛶) on the code block above to see it in action!
2. File Tree Component
A new interactive file tree component helps visualize project structures in blog posts with an elegant expand feature for better readability.
Features:
- Interactive Folders - Click to expand/collapse directory structures
- Expand to Modal - Full-screen view with backdrop effect for complex structures
- File Type Icons - Automatic icon detection for TypeScript, JavaScript, JSON, YAML, Markdown, and more
- Syntax-Aware Styling - Different colors for different file types
- Nested Structures - Support for deeply nested directory hierarchies
- Comments Support - Add descriptive comments to files
Expand Feature:
The expand button (⛶) in the top-right corner of any file tree opens a modal dialog with:
- Large viewport - 85% of screen height for comfortable viewing
- Backdrop effect - Semi-transparent black overlay (80% opacity)
- Smooth animations - Fade and zoom transitions
- Scrollable content - Handle large directory structures
- Keyboard navigation - Press Escape to close, Tab for navigation
- Click outside to close - Intuitive UX pattern
Try clicking the expand button below to see it in action:
Implementation:
export function FileTree({ children, title }: FileTreeProps) {
const [isExpanded, setIsExpanded] = useState(false)
return (
<div className="rounded-lg border bg-card">
{title && (
<div className="border-b bg-muted/50 px-4 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FolderOpen className="h-4 w-4" />
{title}
</div>
<Dialog open={isExpanded} onOpenChange={setIsExpanded}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm">
<Maximize2 className="h-3.5 w-3.5" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-4xl max-h-[85vh]">
<DialogHeader>
<DialogTitle>{title || "Project Structure"}</DialogTitle>
</DialogHeader>
<div className="overflow-y-auto flex-1">
{/* File tree content */}
</div>
</DialogContent>
</Dialog>
</div>
</div>
)}
{/* File tree content */}
</div>
)
}The component uses Radix UI Dialog for accessibility and includes proper ARIA labels, keyboard navigation, and focus management.
3. Command Palette Search
A powerful command palette (⌘K / Ctrl+K) enables quick navigation:
- Fuzzy search across all blog posts
- Tag filtering - Jump to specific topics
- Recent posts - Quick access to latest content
- Keyboard shortcuts - Full keyboard navigation
Implementation uses cmdk library with custom styling:
export function CommandMenu() {
const [open, setOpen] = useState(false)
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen((open) => !open)
}
}
document.addEventListener('keydown', down)
return () => document.removeEventListener('keydown', down)
}, [])
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<Command>
<CommandInput placeholder="Search blog posts..." />
<CommandList>
{/* Search results */}
</CommandList>
</Command>
</CommandDialog>
)
}4. Theme System
A comprehensive theme system with:
- Light/Dark modes - Automatic system detection
- Manual toggle - User preference override
- Persistent storage - Saved in localStorage
- No flash - Inline script prevents theme flash
- Smooth transitions - Animated theme changes
export function ThemeScript() {
return (
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
const theme = localStorage.getItem('blog-theme') || 'system'
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
const actualTheme = theme === 'system' ? systemTheme : theme
document.documentElement.classList.toggle('dark', actualTheme === 'dark')
})()
`,
}}
/>
)
}5. Table of Contents
Enhanced table of contents with:
- Sticky sidebar on desktop (xl breakpoints)
- Collapsible mobile view - Button to expand/collapse
- Active section tracking - Intersection Observer
- Smooth scrolling - Animated navigation
- Visual hierarchy - Indented nested headings
- Active indicator - Border highlight for current section
Desktop version stays visible in the sidebar, while mobile users get a collapsible button at the top of the article.
6. llms.txt Support
Added support for the llms.txt standard to help LLMs understand the blog:
export async function GET() {
const posts = getBlogPosts()
const llmsTxt = `# ${SITE.TITLE}
> ${SITE.DESCRIPTION}
## Recent Blog Posts
${posts.slice(0, 10).map(post => `
- [${post.title}](${SITE.URL}/blog/${post.slug})
Published: ${post.date.toISOString().split('T')[0]}
Description: ${post.description}
Tags: ${post.tags.join(', ')}
`).join('\n')}
`
return new Response(llmsTxt, {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
})
}This provides structured information about the blog for AI assistants and LLMs.
7. Scroll to Top
A floating scroll-to-top button appears after scrolling down 300px:
- Smooth scrolling animation
- Auto hide/show based on scroll position
- Accessible with ARIA labels
- Fixed positioning at bottom-right
- Hover animations for better UX
8. Improved MDX Processing
Enhanced MDX handling with:
- Gray-matter for frontmatter parsing
- Reading time calculation - Estimated read duration
- Remark plugins - GFM, Math equations
- Rehype plugins - Slug generation, external links
- KaTeX support - Mathematical equations
- Custom components - YouTube, Vimeo embeds
Example of rendering math:
E = mc^29. Structured Data
Comprehensive structured data for SEO:
- BlogPosting schema for articles
- BreadcrumbList for navigation
- Person schema for authors
- CollectionPage for listings
- WebSite schema for the site
All pages include proper JSON-LD structured data in the <head> section.
10. Vercel Analytics
Integrated Vercel Analytics for:
- Web Vitals monitoring
- Page view tracking
- Performance metrics
- Privacy-focused analytics
Migration Challenges
Content Structure
Challenge: Astro used a different content collection structure.
Solution: Created a custom content loading system that reads MDX files from the content directory and processes them with gray-matter.
Code Highlighting
Challenge: Maintaining dual theme support for code blocks.
Solution: Used rehype-pretty-code with dynamic theme configuration based on user preferences stored in cookies.
View Transitions
Challenge: Next.js doesn't have built-in view transitions like Astro.
Solution: Implemented custom view transitions using the View Transitions API with polyfill support.
Dynamic Routes
Challenge: Blog post pagination and dynamic slugs.
Solution: Used Next.js App Router dynamic routes with generateStaticParams for static generation.
Performance Improvements
| Metric | Astro | Next.js | Improvement |
|---|---|---|---|
| Build Time | ~15s | ~12s | 20% faster |
| First Load JS | 85KB | 78KB | 8% smaller |
| LCP | 1.2s | 0.9s | 25% faster |
| CLS | 0.02 | 0.01 | 50% better |
| TTI | 2.1s | 1.8s | 14% faster |
Code Organization
The project follows Next.js App Router conventions with a clean separation of concerns:
Key Highlights:
- App Router Structure - Follows Next.js 16 conventions with server components
- Component Organization - UI primitives separate from feature components
- Plugin System - Custom rehype plugins for code enhancement
- Type Safety - Full TypeScript coverage across the codebase
- Configuration Files - Centralized in
config/directory - Content Management - MDX files in dedicated
content/directory
Lessons Learned
- Plan the migration - Document all features before starting
- Incremental approach - Migrate feature by feature
- Test thoroughly - Ensure all content renders correctly
- Performance first - Monitor metrics throughout
- User experience - Focus on smooth interactions
- Type safety - Leverage TypeScript for content handling
What's Next?
Future enhancements planned:
- Comments system - Integration with Giscus
- Related posts - AI-powered suggestions
- RSS improvements - Full content in feed
- Newsletter - Email subscriptions
- Reading progress - Visual progress indicator
- Social sharing - Enhanced share functionality
Conclusion
The migration from Astro to Next.js has been a success. The blog now has:
- Better developer experience
- More interactive features
- Improved performance
- Enhanced SEO
- Modern UI/UX
While Astro is excellent for content-focused sites, Next.js provides the flexibility and ecosystem needed for a feature-rich blog platform.
The journey continues, and I'm excited to add more features and improvements in the future!
Resources
Thanks for reading! Feel free to explore the new features and let me know what you think.
Made with ❤️ by Vitali!