SEO

Next.js Static Export SEO: The Complete Implementation Guide

Next.js output:'export' is great for SEO — if you know the patterns. This guide covers sitemap.ts, robots.ts, metadata API, JSON-LD, and generateStaticParams for a static blog.

Zlyqor Team·May 10, 2026·9 min read

Next.js with output: 'export' generates a fully static HTML site. No server, no serverless functions, no Node.js runtime in production — just pre-rendered HTML files that can be served from any CDN or static host. For SEO, this is excellent: fast TTFB (time to first byte), CDN-cacheable, no server-side rendering latency.

But static export has specific patterns that work and specific patterns that don't. If you build your SEO implementation using patterns designed for Next.js with a server, some of it silently breaks at build time. This guide covers what works, what doesn't, and the exact implementation patterns for a static marketing site with a blog.

What Works with output: 'export'

The good news is that Next.js's modern App Router has strong native SEO support, and most of it works correctly in static export mode.

app/sitemap.ts runs at build time and outputs /out/sitemap.xml. Because it runs in a Node.js build environment (not an Edge or serverless runtime), you can use fs.readdirSync to read your blog posts from the filesystem and include their URLs. The result is a complete, accurate sitemap baked into your static output.

app/robots.ts similarly runs at build time and outputs /out/robots.txt. This is cleaner than a static file in /public because you can generate the sitemap URL dynamically from your metadataBase configuration.

export const metadata in any Server Component is evaluated at build time. For static pages (your homepage, features, pricing, about), this is the correct pattern. The metadata is baked into the HTML at build time — fast, no runtime overhead.

generateMetadata() works for dynamic routes like /blog/[slug]. It receives the params and can call build-time functions (file reads, etc.) to generate per-post metadata. The result is unique, accurate metadata in every pre-rendered HTML file.

generateStaticParams() is the core mechanism for pre-rendering dynamic routes. For a blog, it returns the array of slugs. Next.js pre-renders one HTML file per slug at build time.

JSON-LD is just a <script type="application/ld+json"> tag. You can embed it in any component, including client components (the script tag itself is just HTML). A reusable component works fine everywhere.

What Doesn't Work

This is where teams get caught. Some Next.js patterns silently break in static export mode or cause build failures.

Route Handlers (app/api/*/route.ts) require a Node.js server. In output: 'export' mode, they're silently excluded from the build. If your SEO implementation depends on an API route (for sitemap generation, RSS, etc.), move it to a file-based approach in sitemap.ts or robots.ts instead.

Server Actions ('use server' functions called from client components) require a server and don't work in static export. For a static marketing site, you probably don't need server actions — but if you have a contact form or newsletter signup, you'll need to use a third-party service (Formspree, ConvertKit API, etc.) called directly from client-side code.

next/image with optimization requires a server for on-demand image resizing. Set images: { unoptimized: true } in your next.config.ts. You'll serve images at their original size, so optimize them at source (use WebP, reasonable dimensions). The <Image> component still handles lazy loading and layout stability, which are the SEO-relevant benefits.

searchParams in Server Components forces dynamic rendering, which causes a build error in static export. If you need URL-parameter-based filtering (like /blog?category=engineering), handle it in a 'use client' component using useSearchParams().

The Metadata API for Static Sites

The Next.js Metadata API (in the App Router) is the right approach for all metadata in a static export site. It has one non-obvious rule: you cannot add export const metadata to a 'use client' component. If a page needs both client-side interactivity and custom metadata, split it: make the page component a Server Component that exports the metadata, and render a client child component for the interactive parts.

Your root layout.tsx should set metadataBase:

export const metadata: Metadata = {
  metadataBase: new URL('https://yourapp.com'),
  title: { default: 'App Name', template: '%s | App Name' },
  description: 'Default meta description',
  openGraph: {
    type: 'website',
    siteName: 'App Name',
  },
}

metadataBase is critical for Open Graph images. OG image paths like /og-image.png are resolved relative to metadataBase. Without it, the full URL in your OG tags will be missing or incorrect.

Per-Page Static Metadata

For any static page, export a metadata constant:

export const metadata: Metadata = {
  title: 'Project Management for Small Teams',
  description: 'How Zlyqor handles projects, chat, and time tracking...',
  alternates: {
    canonical: 'https://yourapp.com/features/projects',
  },
  openGraph: {
    title: 'Project Management for Small Teams',
    description: '...',
    images: [{ url: '/og/projects.png', width: 1200, height: 630 }],
  },
}

Always include the canonical URL. This is belt-and-suspenders protection against duplicate content from URL parameters or trailing slash variants.

Building a Static Blog with generateStaticParams

The static blog pattern is four pieces working together:

Step 1: Store posts as Markdown files in content/blog/ with YAML frontmatter. Each file is named with its slug (e.g., my-post-slug.md).

Step 2: A build-time utility in lib/posts.ts that reads the filesystem:

import fs from 'fs'
import path from 'path'
import matter from 'gray-matter'

const postsDir = path.join(process.cwd(), 'content/blog')

export function getAllPosts() {
  const files = fs.readdirSync(postsDir)
  return files
    .filter(f => f.endsWith('.md'))
    .map(filename => {
      const slug = filename.replace(/\.md$/, '')
      const raw = fs.readFileSync(path.join(postsDir, filename), 'utf8')
      const { data, content } = matter(raw)
      return { slug, frontmatter: data as PostFrontmatter, content }
    })
    .sort((a, b) =>
      new Date(b.frontmatter.date).getTime() - new Date(a.frontmatter.date).getTime()
    )
}

Step 3: generateStaticParams in your app/blog/[slug]/page.tsx:

export async function generateStaticParams() {
  const posts = getAllPosts()
  return posts.map(post => ({ slug: post.slug }))
}

Step 4: generateMetadata for per-post metadata:

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = getPostBySlug(params.slug)
  return {
    title: post.frontmatter.title,
    description: post.frontmatter.excerpt,
    alternates: { canonical: `https://yourapp.com/blog/${params.slug}` },
    openGraph: {
      type: 'article',
      publishedTime: post.frontmatter.date,
      tags: post.frontmatter.tags,
    },
  }
}

The result: Next.js pre-renders one HTML file per blog post at build time. Each has unique, accurate metadata. The content is fully crawlable — no JavaScript required to read the post.

sitemap.ts That Includes Blog Posts

Because sitemap.ts runs in the Node.js build environment, it can call getAllPosts() and include each post in the sitemap with its accurate lastModified date:

import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'

export default function sitemap(): MetadataRoute.Sitemap {
  const posts = getAllPosts()
  const postEntries = posts.map(post => ({
    url: `https://yourapp.com/blog/${post.slug}`,
    lastModified: new Date(post.frontmatter.date),
    priority: 0.7,
    changeFrequency: 'monthly' as const,
  }))

  return [
    { url: 'https://yourapp.com', lastModified: new Date(), priority: 1.0 },
    { url: 'https://yourapp.com/blog', lastModified: new Date(), priority: 0.9 },
    ...postEntries,
  ]
}

This generates a complete sitemap automatically every time you build. Add a new post, run the build, and the new URL is in the sitemap.

JSON-LD in a Static Export Site

JSON-LD works as a simple React component:

function JsonLd({ schema }: { schema: Record<string, unknown> }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }}
    />
  )
}

Add the SoftwareApplication and Organization schemas in your root layout.tsx (they appear on every page). Add Article and BreadcrumbList schemas in your blog post page component, populated with per-post data.

For blog posts, the Article schema's datePublished and dateModified fields should match your frontmatter dates. author should use the Person schema with a name. image should be a full URL to your OG image (1200×630).

The Complete SEO Checklist

Before you call your static Next.js site SEO-ready, verify every item:

  • metadataBase is set in root layout.tsx
  • app/robots.ts exists and allows your content routes
  • app/sitemap.ts exists, includes all pages and posts, is submitted to Google Search Console
  • Every static page has unique title and description metadata
  • Every blog post has generateMetadata with canonical URL, OG tags, and Article schema
  • Homepage has SoftwareApplication JSON-LD schema
  • images: { unoptimized: true } is set in next.config.ts
  • OG images exist at 1200×630 and are referenced with full URLs
  • Blog content is rendered as semantic HTML (h1, h2, p, ul — not divs with styled-components)
  • Core Web Vitals pass on mobile (check PageSpeed Insights after deploying)

For the broader SEO foundation that makes this technical work pay off, see SEO for SaaS startups and the higher-level perspective on technical SEO for B2B SaaS.


Ready to Put This Into Practice?

If you're looking for a workspace that brings chat, projects, time tracking, meetings, and finance into one place, try Zlyqor free. No credit card required.

Start free →

Try it free

Ready to replace five tools with one?

Chat, projects, time tracking, meetings, and finance — all in Zlyqor.

Start free →