Stripping Trailing Slashes from a Static Site — A Three-Layer Duet of Next.js and Lambda@Edge
Getting rid of trailing slashes on a static-export blog turned out to need three different layers cooperating — trailingSlash config, Lambda@Edge redirect, and handling SPA client-side navigation. The story of stitching them together.
Opening — /ko/breathing/ Bothered Me
Running the site a while, I kept noticing:
https://js2devlog.com/ko/breathing/
https://js2devlog.com/ko/devlog/supabase-integration/
Every URL had a / tacked onto the end. No functional issue — but sharing or bookmarking always felt a bit untidy. Most modern sites — GitHub, Vercel, Twitter — keep their URLs clean at the end.
"This shouldn't be hard", I thought. What followed was an unexpectedly deep walk through three separate layers, each contributing its own reason for the slashes.
Layer 1 — The trailingSlash: true Setting
First stop, next.config.ts:
const nextConfig: NextConfig = {
output: isDev ? undefined : 'export',
trailingSlash: !isDev, // true only in production
// ...
};
Only production builds had trailingSlash: true enabled.
Why Was It There
This blog uses Next.js output: 'export' + S3 + CloudFront. Due to S3 static hosting, to serve /breathing you need a /breathing.html file — but App Router exports to /breathing/index.html (directory) structure by default.
To match that structure with the URL, trailingSlash: true was set. With /breathing/ in the request, S3 naturally serves /breathing/index.html.
Attempt 1 — Just Remove trailingSlash?
Initially I thought removing the setting would suffice. Building showed otherwise — the file structure changed completely.
[Before: trailingSlash: true]
out/ko/breathing/index.html
out/ko/devlog/supabase-integration/index.html
[After removal]
out/ko/breathing.html ← flat .html file!
out/ko/devlog/supabase-integration.html
Turns out Next.js App Router actually changes the file structure based on trailingSlash ("App Router always produces dir/index.html" — that belief was wrong).
Decision time:
- Option A: Go flat (.html) and rewrite the CloudFront logic to match
- Option B: Keep the file structure, clean up only the URL at CloudFront level
Considering the sensitivity of Lambda@Edge swap + mass S3 file changes + deploy ordering, I went with Option B.
Layer 2 — Need an Edge-Level Redirect
Going with Option B, when someone hits /ko/breathing/ directly, CloudFront needs to 301 redirect to /ko/breathing. Added one block to the existing Lambda@Edge function.
Added to the existing function (locale prefix injection + index.html rewrite):
// 🆕 new block
if (uri.length > 4 && uri.endsWith('/')) {
return {
status: '301',
statusDescription: 'Moved Permanently',
headers: {
location: [{ key: 'Location', value: uri.slice(0, -1) + qs }],
},
};
}
The length > 4 check excludes the home (/ko/, /en/). Everything else strips the trailing slash and redirects.
Deploy and test:
curl -I "https://js2devlog.com/ko/breathing/"
# HTTP/2 301
# location: /ko/breathing ✓
Typing /ko/breathing/ in the address bar → immediately reconciles to /ko/breathing.
Layer 3 — SPA Client-Side Navigation
Then I tried clicking internal links on the site, and something was off.
Address bar:
/ko/breathing/← the/was still there?
Refresh removes the /, but clicking a link keeps adding it. After some digging, the realization: internal link clicks don't make HTTP requests.
Direct URL input / Refresh:
→ HTTP request → CloudFront → Lambda@Edge → 301 → clean ✓
Internal <Link> click:
→ React handles the transition in memory (client-side navigation)
→ No HTTP request → Lambda@Edge not invoked
→ history.pushState("/breathing/") ← the / stays stamped in the URL
SPA routing is outside the reach of Lambda@Edge.
Why the Link Adds /
Next.js <Link> references the trailingSlash setting at build time and auto-appends / to the rendered HTML <a href="..."> values. With trailingSlash: true, it becomes href="/breathing/", and click-time pushState carries that string verbatim.
You could swap the Link component (wrapping one that strips /), but that's fighting the framework — not recommended.
The Fix — A Tiny Client Component with history.replaceState
Simple idea:
Clean the address bar right after a client-side navigation.
Detect pathname change with usePathname, strip trailing slash in useEffect, update the URL via history.replaceState. No React re-render, no reload — pure address bar edit.
// src/components/TrailingSlashCleanup.tsx
'use client';
import { usePathname } from 'next/navigation';
import { useEffect } from 'react';
export default function TrailingSlashCleanup() {
const pathname = usePathname();
useEffect(() => {
if (pathname.length > 1 && pathname.endsWith('/')) {
const clean = pathname.replace(/\/$/, '') + window.location.search + window.location.hash;
window.history.replaceState(null, '', clean);
}
}, [pathname]);
return null;
}
Mount once in the layout:
// src/app/[locale]/layout.tsx
<SearchProvider>
<TrailingSlashCleanup /> {/* ← added */}
{/* existing children */}
</SearchProvider>
That's it. An 11-line component that auto-strips trailing slashes on every client-side navigation.
How It Settles
| Scenario | Who handles it |
|---|---|
Direct typing /ko/breathing/ | Lambda@Edge → 301 → /ko/breathing |
| Browser refresh | Same as above (HTTP request again) |
Internal <Link> click | Next.js pushState /ko/breathing/ → TrailingSlashCleanup replaceState → /ko/breathing |
| Back button | popstate → pathname changes → useEffect → replaceState |
All three paths converge on a single form: /ko/breathing. Total unification.
Performance
A natural question, so:
return nullcomponent → DOM render cost zerohistory.replaceState→ no re-render, no reload (pure browser API)- Condition check + replaceState → ~0.1 ms
- Fires once per page navigation. Zero perceived impact.
Actually — having Lambda@Edge 301 on every internal click would have cost tens of ms each time. Client-side handling is the faster choice here.
Why the "Three-Layer Duet"
| Layer | Handled by | When |
|---|---|---|
| 1 | next.config.ts: trailingSlash: true | Build time (file structure) |
| 2 | Lambda@Edge 301 redirect | Initial HTTP requests (direct URL / refresh) |
| 3 | TrailingSlashCleanup client component | SPA internal transitions (click / popstate) |
Each layer owns its domain, and together they produce one outcome (no trailing slash). Miss any one and some scenario still shows a /.
Retrospective
-
The "one-line fix" instinct is often wrong. I thought removing
trailingSlashwould be enough. In practice, three layers — file structure, edge, SPA — all needed attention. -
Knowing each layer's role lets you pick the cheapest intervention. Here I kept the file structure untouched, added one block to Lambda@Edge, and dropped in an 11-line component. Much lighter than Option A (file structure conversion + massive Lambda swap).
-
Don't fight the framework — fill the gaps it leaves. Kept
trailingSlash: trueso file structure stays. Kept the Link component. Just added a supplementary layer that cleans the URL after SPA routing. Not pushing against the framework's flow, just polishing UX at the edge.
If you run a similar static-export + CDN + SPA stack, I'd recommend checking all three layers. Fix only one and you end up in the awkward state where "refresh is clean but clicks still leave a /".
Guestbook
Leave a short note about this post
Loading...