[ HTTP & responses ]
@sdxc/trailing-slash-middleware
Router middleware that redirects every path to one canonical trailing-slash form
- Used by
- auth-saas, blog
Router middleware that redirects every path to one canonical trailing-slash form, so search engines, caches and relative links see one URL per page.
Installation
npm add @sdxc/trailing-slash-middleware
It goes on a remix/router middleware chain, so remix (v3) installs alongside it.
Usage
Strip trailing slashes
The default makes the slash-free form canonical. Only the path changes, so the query string carries over.
import { trailingSlash } from "@sdxc/trailing-slash-middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [trailingSlash()] });
// GET /posts/?page=2 -> 308, Location: https://example.com/posts?page=2
// GET /posts/// -> 308, Location: https://example.com/posts
// GET /posts -> the matched handler
// GET / -> the matched handler
Enforce a trailing slash
{ mode: "always" } makes the slashed form canonical, and lets a file name such as /robots.txt through as it arrived.
import { trailingSlash } from "@sdxc/trailing-slash-middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [trailingSlash({ mode: "always" })] });
// GET /posts -> 308, Location: https://example.com/posts/
// GET /posts/ -> the matched handler
// GET /robots.txt -> the matched handler
The longhand
In the default mode, trailingSlash() stands in for this hand-written middleware:
import type { Middleware } from "remix/router";
let stripTrailingSlash: Middleware = (context, next) => {
let url = new URL(context.request.url);
if (url.pathname === "/" || !url.pathname.endsWith("/")) return next();
url.pathname = url.pathname.replace(/\/+$/, "") || "/";
return new Response(null, { status: 308, headers: { Location: url.href } });
};
API
trailingSlash(options?: TrailingSlashOptions): Middleware
Returns a middleware that passes a request in canonical form to next() and answers any other with a 308 to the same URL with only the path changed. / is canonical in both modes, and a run of trailing slashes reaches the canonical form in one redirect.
options.mode:"never"(default) makes/postscanonical and strips every non-root trailing slash."always"makes/posts/canonical, except that a slash-free path whose last segment contains a.(/robots.txt,/.well-known/security.txt) is a file and passes through.
Types
TrailingSlashOptions
interface TrailingSlashOptions {
mode?: "never" | "always";
}
Pattern: Placing It First
Router middleware runs before route matching, so the router's chain sees both forms of every path. Put trailingSlash() at the top: a redirected request skips the session, authentication and body parsing its canonical retry runs anyway, while a middleware above it, such as a logger, still sees the redirect.
import { catchResponse } from "@sdxc/catch-response-middleware";
import { trailingSlash } from "@sdxc/trailing-slash-middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [trailingSlash(), catchResponse()] });
router.get("/posts", () => new Response("posts"));
Pattern: Forms Keep Their Method
A 308 makes the client repeat the method and body, so a form that posts to the non-canonical path still reaches its handler as a POST.
import { trailingSlash } from "@sdxc/trailing-slash-middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [trailingSlash()] });
router.post("/comments", async ({ request }) => {
let form = await request.formData();
return new Response(`Saved: ${form.get("body")}`, { status: 201 });
});
// POST /comments/ -> 308, Location: https://example.com/comments
// POST /comments -> 201, with the same body
Pattern: Directory URLs Beside Static Files
In "always" mode, only a dot in the last segment marks a file. A file without one (/LICENSE) gets a slash, a dot in an earlier segment (/v1.2/docs) does not count, and a slashed path such as /tags/node.js/ already has its slash and passes through.
import { trailingSlash } from "@sdxc/trailing-slash-middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [trailingSlash({ mode: "always" })] });
router.get(
"/feed.xml",
() => new Response("<feed />", { headers: { "Content-Type": "application/atom+xml" } }),
);
router.get("/v1.2/docs/", () => new Response("docs"));
// GET /feed.xml -> the matched handler
// GET /assets/app.min.js -> passes through
// GET /v1.2/docs -> 308, Location: https://example.com/v1.2/docs/
// GET /LICENSE -> 308, Location: https://example.com/LICENSE/
Pattern: Picking The Mode Once
Generate links in the canonical form, so a click never costs a redirect; the middleware catches the links other sites and people write by hand. Browsers cache a 308, so switching a live site from one mode to the other sends returning visitors into a redirect loop until those entries expire.
import { trailingSlash } from "@sdxc/trailing-slash-middleware";
import { createRouter } from "remix/router";
let router = createRouter({ middleware: [trailingSlash({ mode: "always" })] });
router.get(
"/posts/",
() =>
new Response('<a href="/posts/2026/">2026</a>', { headers: { "Content-Type": "text/html" } }),
);