Writing · Next.js · SEO · Architecture · Static Export
How I Built a SEO-Optimized Blog with Next.js Static Export

Update, October 2026: this post describes an earlier version of this site. I have since rebuilt it with no client JavaScript at all; read how I rebuilt it and reached 100 on mobile.
When I decided to rebuild my personal site, I had one clear goal: ship a fast, SEO-friendly blog without maintaining a server, a database, or a CMS. No WordPress, no Contentful, no Vercel server functions. Just static files served from a CDN.
This post walks through the architecture I landed on: a Next.js static export hosted on S3 + CloudFront, with hand-rolled SEO that actually works.
Why Static Export?
Next.js offers several rendering strategies: SSR, ISR, and static export. I chose static export for three reasons:
- Cost: S3 + CloudFront costs pennies per month. No server runtime, no cold starts, no surprises.
- Performance: Every page is pre-rendered at build time. There is nothing faster than an HTML file served from a CDN edge node.
- Simplicity: No Node.js server, no database connection pools, no API routes to secure. The attack surface is essentially zero.
The trade-off? You lose SSR and ISR. For a blog, I can live with that.
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
trailingSlash: true,
distDir: 'deployment',
images: { unoptimized: true },
}SEO That Actually Works
SEO for a static site requires thinking about three layers: sitemaps for crawlability, metadata for ranking, and Open Graph for social sharing. Here is how I wired each one.
Sitemap Generation
I use next-sitemap to generate a sitemap and robots.txt at build time. The config is minimal but covers everything: dynamic routes from blog posts, proper change frequency, and priority.
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: 'https://ezequielcarrizo.com',
generateRobotsTxt: true,
changefreq: 'monthly',
priority: 0.7,
}Every time I add a new blog post and run npm run build, the sitemap automatically includes the new URL. Zero manual work.
Per-Page Metadata
Each blog post exports its own Metadata object with title, description, keywords, and Open Graph tags. I use a shared defaultMetadata object for consistent fallbacks across the site, then override the specifics per post.
export const metadata: Metadata = {
...defaultMetadata,
title: 'My Blog Post Title',
description: 'A compelling meta description under 160 chars',
keywords: ['next.js', 'static export', 'SEO'],
openGraph: {
type: 'website',
url: 'https://example.com/blog/my-post/',
title: 'My Blog Post Title',
description: 'A compelling meta description under 160 chars',
images: [{url: '/images/my-post.webp', alt: 'Alt text'}]
}
}This pattern scales surprisingly well. Every post has its own unique metadata, search engines pick it up, and social sharing previews look great on Twitter, LinkedIn, and WhatsApp.
Content Structure: No CMS, No Problem
I chose to write posts as React components rather than MDX files. Each post lives in its own directory with a data.ts (metadata) and a page.tsx (content). This gives me full control over TypeScript types for metadata, and I can embed any React component directly in posts.
A central posts.ts file at the content layer aggregates all posts into arrays that feed the blog listing page and category pages. Adding a new post is three steps: create a folder, write data.ts and page.tsx, and add the import to posts.ts.
Deployment: SST + S3 + CloudFront
I use SST (Serverless Stack) to provision the infrastructure. My sst.config.ts declares a StaticSite component that builds the Next.js app, uploads the output to S3, and configures CloudFront as the CDN. The entire deployment is a single command: npx sst deploy.
I set aggressive cache headers for static assets (CSS, JS, images) and no-cache for HTML files, ensuring users always get the latest content while cached resources load instantly on repeat visits.
What I Would Do Differently
Looking back, there are a few things I would change if I were starting fresh:
- Use
next-mdx-remotefor post content instead of JSX — it is already in my dependencies but I never migrated. - Add an RSS feed. Static blogs benefit enormously from RSS distribution for developer audiences.
- Implement a scheduled build pipeline instead of manual deploys when I publish.
The static export approach has served me well. My blog loads fast, ranks for its keywords, costs almost nothing to run, and requires exactly zero maintenance. For a personal site, I cannot think of a better trade-off.