Next.jsSEOArchitectureStatic Export

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

By Ezequiel Carrizo
Ezquiel, the author Picture
Published on
Laptop with code on screen

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.

Hi there! Want to support me?

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://ezequielcarrizo.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.

Hi there! Want to support me?

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-remote for 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.