Next.js• 5 MIN READ
Kirito

Kirito

6/10/20265 min read

The Next.js Static Export That Generated 50,000 404 Pages

Setting output: "export" in Next.js seemed simple until Google indexed 50,000 404 pages because of a trailing slash mismatch.

The Next.js Static Export That Generated 50,000 404 Pages

I love Next.js static exports. No servers. No cold starts. Just HTML files on S3.

But one configuration option turned 50,000 valid pages into 404 errors.

The Setup

We had a marketing site with 50,000 blog posts (static content).

Next.js 14 with App Router.

// next.config.js module.exports = { output: 'export', // Static export trailingSlash: false, // Our setting images: { unoptimized: true } };

Our links looked like:

<Link href="/blog/post-123">Read post</Link> // Renders <a href="/blog/post-123">

During build, Next.js generated:

out/
  blog/
    post-123.html
    post-124.html
    ...

We uploaded to S3 and configured CloudFront.

The Problem

After launch, Google Search Console showed:

Indexed pages: 50,000
404 pages: 50,000

Every single blog post was marked as a 404.

I visited a post: https://mysite.com/blog/post-123

It worked in the browser.

Why was Google seeing 404?

The Investigation

I fetched the page with curl:

curl -I https://mysite.com/blog/post-123 HTTP/2 200 OK # Works curl -I https://mysite.com/blog/post-123/ HTTP/2 404 Not Found # FAILS

The trailing slash was the culprit.

Googlebot was crawling both versions:

  • Without trailing slash → 200 (S3 serves post-123.html)
  • With trailing slash → 404 (S3 looks for folder post-123/index.html)

Our S3 bucket was configured to handle index.html for folders, but not to remove trailing slashes.

Google was indexing the trailing-slash URLs because they appeared in our sitemap:

<url> <loc>https://mysite.com/blog/post-123/</loc> <!-- Trailing slash! --> </url>

Our sitemap generator added trailing slashes automatically.

The Fixes

1. Remove trailing slashes from sitemap

// app/sitemap.ts export default function sitemap() { const posts = getAllPosts(); return posts.map(post => ({ url: `https://mysite.com/blog/${post.slug}`, // NO trailing slash lastModified: post.date })); }

2. Configure S3 to remove trailing slashes

Using CloudFront Functions:

// CloudFront viewer request function function handler(event) { var request = event.request; var uri = request.uri; // Remove trailing slash if (uri.endsWith('/') && uri !== '/') { uri = uri.slice(0, -1); request.uri = uri; } // Add .html for extensionless paths if (!uri.includes('.') && !uri.endsWith('/')) { request.uri = uri + '.html'; } return request; }

3. Use redirect rules in S3

<RoutingRules> <RoutingRule> <Condition> <KeyPrefixEquals>blog/</KeyPrefixEquals> <SuffixEquals>/</SuffixEquals> </Condition> <Redirect> <ReplaceKeyPrefixWith>blog/</ReplaceKeyPrefixWith> <RemoveSuffix>/</RemoveSuffix> </Redirect> </RoutingRule> </RoutingRules>

4. Configure Next.js to generate both versions

// next.config.js with custom export module.exports = { output: 'export', trailingSlash: true, // Change to true // Then generate both .html and /index.html };

But this doubles the build time and storage.

The Prevention

Canonical URLs without trailing slashes

// Add canonical tag to every page <Head> <link rel="canonical" href={`https://mysite.com/blog/${post.slug}`} /> </Head>

Redirect trailing slashes in middleware

// middleware.ts (works with static export using edge config) import { NextResponse } from 'next/server'; export function middleware(request) { const url = request.nextUrl.clone(); if (url.pathname.endsWith('/') && url.pathname !== '/') { url.pathname = url.pathname.slice(0, -1); return NextResponse.redirect(url, 301); } }

Test with bot user-agent

# Simulate Googlebot curl -A "Googlebot" -I https://mysite.com/blog/post-123/ # Should return 301 or 200, never 404

Commands to Audit

Find all trailing slash URLs in sitemap

grep -o '<loc>[^<]*/' sitemap.xml | wc -l

Check S3 behavior

aws s3 ls s3://my-bucket/blog/ --recursive | grep '.html$'

Bulk test redirects

# Using httpie cat urls.txt | xargs -I {} http -h {} | grep -E "HTTP|Location"

What I Learned

  • Static exports are unforgiving about trailing slashes.
  • Sitemaps must match the actual URL structure.
  • CloudFront Functions can fix S3 limitations.
  • Always test both slash variants before launch.

That mistake cost us 2 weeks of SEO recovery. Now we have a CI check that ensures all generated URLs are consistent and no trailing slash variants return 404.