zudo-doc
GitHub repository

Type to search...

to open search from anywhere

Trailing-Slash Policy

Created Apr 28, 2026Updated Jun 20, 2026Takeshi Takatsudo

How zfb splits trailing-slash enforcement between build time and runtime.

Overview

zudo-doc enforces a "trailing slash always" URL policy: every page lives at a path ending with / — for example /docs/getting-started/ rather than /docs/getting-started.

zfb is a static-only builder — there is no server-side middleware layer. The enforcement therefore splits cleanly into two concerns with different owners.

Astro background

zudo-doc originated on Astro, which enforced trailing slashes in two places: the build emitted dist/docs/getting-started/index.html (controlled by trailingSlash: "always"), and src/middleware-handler.ts issued 301 redirects for bare-path requests. In zfb those two concerns map to the same split described below — build time and deployment host — but the enforcement mechanism is different in each case.

Build time — owned by zfb / url-normalizer

All URLs emitted during the static build must end with /. This is enforced by buildUrl() in @takazudo/zudo-doc/url-normalizer:

import { buildUrl } from "@takazudo/zudo-doc/url-normalizer";

// In a paths() implementation or integration:
const url = buildUrl("docs", slug);          // "/docs/my-slug/"
const root = buildUrl();                      // "/"
const localized = buildUrl("ja", "docs", slug); // "/ja/docs/my-slug/"

buildUrl() is the primary helper. It joins path segments, strips redundant slashes, and wraps the result in /<joined>/. It is used by:

  • pages/sitemap.xml.tsx — generates <loc> entries.

  • Any zfb paths() export that maps collection slugs to route paths.

  • Integration-level URL emitters (llms.txt, claude-resources, etc.).

A secondary helper normalizePathname() is available for cases where a pathname arrives from external data and may or may not already carry a trailing slash:

import { normalizePathname } from "@takazudo/zudo-doc/url-normalizer";

const canonical = normalizePathname(rawPathname); // adds "/" if missing

Skip rules

normalizePathname() and its underlying shouldSkipNormalization() apply these skip rules:

ConditionBehaviour
Already ends with /no-op
Last segment has a letter-starting extension (.js, .css, .png …)no-op — static asset
Starts with /_astro/ or /_imageno-op — preserved for cross-framework asset path compatibility
Version string like /docs/v2.0 (digit after dot)normalised — not treated as extension

Runtime — owned by the deployment host

When a visitor types https://example.com/docs/guide without the trailing slash, there is no framework code to intercept the request — the static files have already been deployed. The deployment host is responsible for issuing a redirect.

Cloudflare Pages

Add the following rule to public/_redirects. It must come before any page-specific redirect rules (Cloudflare Pages evaluates rules top-to-bottom and stops at the first match):

# Redirect non-trailing-slash URLs to their canonical form.
/:splat  /:splat/  301

Why the host owns this

Keeping runtime redirects on the host rather than in framework code means you can swap deployment targets — Cloudflare Pages, Netlify, Vercel, a plain nginx — by changing only the _redirects (or equivalent) file. The framework stays platform-agnostic.

Cloudflare Workers Static Assets

zudo-doc now deploys to Cloudflare Workers (wrangler.toml), serving the built dist/ through the Workers static-assets layer rather than Cloudflare Pages. Because the static build already emits the trailing-slash directory layout (dist/docs/getting-started/index.html), the Workers asset layer serves each canonical /<route>/ URL from its pre-built index.html directly.

As a result, the manual public/_redirects rule the Pages section describes is not shipped on Workers — it was dropped in the Pages → Workers migration (zudolab/zudo-doc#1691). The relevant wrangler.toml settings are:

[assets]
directory = "./dist"
not_found_handling = "404-page"   # serves dist/404.html for unmatched GETs
run_worker_first = false          # asset layer is consulted before the worker

Note

The Pages section above is retained for reference — it still applies if you deploy this site to Cloudflare Pages or another host. On Workers static assets, the platform's built-in asset handling covers the trailing-slash case, so no_redirects file is part of the deployed artifact.

Other hosts

HostMechanism
Netlifypublic/_redirects — same /:splat /:splat/ 301 syntax
Vercelvercel.json "trailingSlash": true
nginxtry_files + return 301 $uri/
AWS CloudFrontLambda@Edge or CloudFront Function for the viewer-request event

Summary

LayerOwnerMechanism
Build-time URL constructionzfb / url-normalizerbuildUrl() — always emits /<path>/
Build-time pathname correctionzfb / url-normalizernormalizePathname()
Runtime redirect (no-slash → slash)Deployment host_redirects or host config

The split keeps each concern in the right place and makes it easy to reason about which layer is responsible when something goes wrong.

Revision History

Takeshi TakatsudoCreated: 2026-04-28T20:41:06+09:00Updated: 2026-06-20T07:20:58Z

AI Assistant

Ask a question about the documentation.