Documentation
¶
Overview ¶
Package redirect parses and resolves _redirects files.
The _redirects file format, popularised by Netlify and Cloudflare Pages, defines rules for URL redirects, rewrites, and error responses based on incoming request paths.
File Format ¶
A _redirects file contains one rule per line in the format:
<source> <target> [<status>]
For example:
/old-path /new-path /blog/:year/* /posts/:year/:splat 302 /app/* /index.html 200 /gone / 410
The status code determines how the match is handled:
- 3xx (or omitted): redirects the client to the target URL (default 301, or 302 with WithDefaultStatus).
- 200: rewrites the request, serving the target path internally without redirecting the client.
- 4xx / 5xx: answers the request immediately with that HTTP status code.
Query parameters from the original request are preserved and appended to the target URL unless the target explicitly defines query parameters.
Placeholders and Security ¶
Placeholders (such as ":name" and trailing "*"/":splat") captured from the source path are expanded into the target path, query string, and fragment separately, with component-appropriate escaping. Placeholder values containing ".." cannot escape the target directory path.
Forced Rules and Shadowing ¶
A trailing "!" on the status code (e.g. "301!") marks the rule as forced (Rule.Force). When Handler is configured with WithExists, unforced rules are skipped if the wrapped handler already serves a static file at the requested path. Without WithExists, rules always apply regardless of existing files.
Proxying ¶
Rewrites (status 200) to absolute URLs (e.g. "https://api.example.com/:splat") are permitted when parsed with WithProxying. In Handler, proxied requests are forwarded to the handler supplied via WithProxy. If no proxy handler is configured, Handler returns an error wrapping ErrProxy.
Usage ¶
The package supports two workflows:
- HTTP middleware: Handler and NewHandler wrap an existing net/http.Handler and automatically handle redirects, rewrites, errors, proxying, and shadowing.
- Manual resolution: Parse reads rules into a []Rule slice, and Resolve returns a *Resolved result for callers that want to handle redirection and rewriting directly.
Example ¶
Example demonstrates parsing and resolving redirect.
package main
import (
"fmt"
"log"
"net/url"
"strings"
"thde.io/rulefiles/redirect"
)
// file contains example redirect rules.
const file = `
/old-path /new-path
/blog/:year/* /posts/:year/:splat 302
/gone / 410
/app/* /index.html 200
`
func main() {
rules, err := redirect.Parse(strings.NewReader(file))
if err != nil {
log.Fatal(err)
}
for _, request := range []string{"/old-path", "/blog/2026/hello/world?utm=1", "/gone", "/app/deep/link", "/about"} {
from, err := url.Parse(request)
if err != nil {
log.Fatal(err)
}
resolved, err := redirect.Resolve(rules, from)
if err != nil {
log.Fatal(err)
}
if resolved == nil {
fmt.Printf("%s: no rule\n", request)
continue
}
fmt.Printf("%s: %d %s\n", request, resolved.Rule.Status, resolved.To)
}
}
Output: /old-path: 301 /new-path /blog/2026/hello/world?utm=1: 302 /posts/2026/hello/world?utm=1 /gone: 410 / /app/deep/link: 200 /index.html /about: no rule
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrSyntax reports malformed rule syntax. ErrSyntax = rulefile.ErrSyntax // ErrUnsupported reports a line that Netlify or Cloudflare Pages accepts but that this package does not implement, // such as matching on a country or a query parameter. ErrUnsupported = rulefile.ErrUnsupported )
var ErrProxy = errors.New("no proxy configured")
ErrProxy reports a destination that is fetched from another host, resolved by a handler that was not given a proxy with WithProxy.
Functions ¶
func Handler ¶
Handler returns a handler that resolves the rules against the URL of a request and acts on the first rule that matches: a status of 200 rewrites the request and passes it to next, a status of 400 and above answers it with that status, anything else redirects to the destination. A request no rule matches is passed to next as it was received.
Rule.Force is only honoured when WithExists is given; without it every rule applies, whether or not next serves a file of the same name. A destination Resolved.Proxy reports needs WithProxy to be fetched.
The rules are resolved once. A rewrite is not matched against the rules again, so a rule may rewrite to a path another rule redirect.
Example ¶
ExampleHandler demonstrates wrapping a handler with redirect rules.
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"thde.io/rulefiles/redirect"
)
// file contains example redirect rules.
const file = `
/old-path /new-path
/blog/:year/* /posts/:year/:splat 302
/gone / 410
/app/* /index.html 200
`
func main() {
rules, err := redirect.Parse(strings.NewReader(file))
if err != nil {
slog.Error("reading the redirect rules", "error", err)
return
}
site := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:gosec // Echo request path for example output.
_, _ = fmt.Fprintln(w, "serving "+r.URL.Path)
})
handler := redirect.Handler(rules, site)
for _, request := range []string{"/old-path", "/app/deep/link", "/gone", "/about"} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, request, http.NoBody))
fmt.Printf("%s: %d %s\n", request, rec.Code, strings.TrimSpace(rec.Header().Get("Location")+rec.Body.String()))
}
}
Output: /old-path: 301 /new-path<a href="/new-path">Moved Permanently</a>. /app/deep/link: 200 serving /index.html /gone: 410 Gone /about: 200 serving /about
Types ¶
type HandlerOption ¶
type HandlerOption func(*handlerOptions)
HandlerOption configures a Handler. It settles what the rules of a file leave to the caller: whether a rule may shadow a file next serves, and how a destination of another host is fetched.
func WithErrorHandler ¶
func WithErrorHandler(fn func(w http.ResponseWriter, r *http.Request, err error)) HandlerOption
WithErrorHandler answers a request whose destination could not be built, or that resolves to a proxy without WithProxy. It defaults to a 500.
func WithExists ¶
func WithExists(exists func(r *http.Request) bool) HandlerOption
WithExists honours Rule.Force: a rule written without a trailing "!" is skipped while exists reports that next serves the path of the request itself, so that a redirect does not shadow a file of the same name. A later rule still matches such a request.
Without it every rule applies, forced or not, as the handler cannot know what next serves.
Example ¶
ExampleWithExists demonstrates honouring the force flag of a rule.
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"thde.io/rulefiles/redirect"
)
func main() {
const file = `
/about /about-us 301
/contact /contact-us 301!
`
rules, err := redirect.Parse(strings.NewReader(file))
if err != nil {
slog.Error("reading the redirect rules", "error", err)
return
}
site := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:gosec // Echo request path for example output.
_, _ = fmt.Fprintln(w, "serving "+r.URL.Path)
})
// files holds the paths the site serves itself.
files := map[string]bool{"/about": true, "/contact": true}
handler := redirect.Handler(rules, site, redirect.WithExists(func(r *http.Request) bool {
return files[r.URL.Path]
}))
for _, request := range []string{"/about", "/contact"} {
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, request, http.NoBody))
fmt.Printf("%s: %d %s\n", request, rec.Code, strings.TrimSpace(rec.Header().Get("Location")+rec.Body.String()))
}
}
Output: /about: 200 serving /about /contact: 301 /contact-us<a href="/contact-us">Moved Permanently</a>.
func WithProxy ¶
func WithProxy(proxy http.Handler) HandlerOption
WithProxy serves a destination Resolved.Proxy reports with proxy, in a copy of the request whose URL is the absolute destination of the rule. Only rules parsed with WithProxying resolve to such a destination; without a proxy it is answered as an error wrapping ErrProxy.
A net/http/httputil.ReverseProxy fetches the URL of the request it is given:
redirect.WithProxy(&httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) { pr.Out.URL = pr.In.URL },
})
Example ¶
ExampleWithProxy demonstrates fetching a rewrite from another host.
package main
import (
"context"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/http/httputil"
"strings"
"thde.io/rulefiles/redirect"
)
func main() {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
//nolint:gosec // Echo request path for example output.
_, _ = fmt.Fprintln(w, "the backend answered "+r.URL.Path)
}))
defer backend.Close()
rules, err := redirect.Parse(
strings.NewReader("/api/* "+backend.URL+"/:splat 200\n"),
redirect.WithProxying(),
)
if err != nil {
slog.Error("reading the redirect rules", "error", err)
return
}
proxy := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) { pr.Out.URL = pr.In.URL },
}
rec := httptest.NewRecorder()
redirect.Handler(rules, http.NotFoundHandler(), redirect.WithProxy(proxy)).
ServeHTTP(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/v1/users", http.NoBody))
fmt.Printf("%d %s", rec.Code, rec.Body)
}
Output: 200 the backend answered /v1/users
type Option ¶
type Option func(*options)
Option configures the parser.
func WithDefaultStatus ¶
WithDefaultStatus sets the default redirect status. Defaults to 301, as on Netlify. Cloudflare Pages uses 302.
func WithExactTrailingSlash ¶
func WithExactTrailingSlash() Option
WithExactTrailingSlash matches trailing slashes exactly. By default a trailing slash is ignored on both sides, as on Netlify.
Example ¶
ExampleWithExactTrailingSlash demonstrates exact slash matching.
package main
import (
"fmt"
"log"
"net/url"
"strings"
"thde.io/rulefiles/redirect"
)
func main() {
const file = "/docs /docs/\n"
for _, opts := range [][]redirect.Option{nil, {redirect.WithExactTrailingSlash(), redirect.WithDefaultStatus(302)}} {
rules, err := redirect.Parse(strings.NewReader(file), opts...)
if err != nil {
log.Fatal(err)
}
for _, request := range []string{"/docs", "/docs/"} {
from, err := url.Parse(request)
if err != nil {
log.Fatal(err)
}
resolved, err := redirect.Resolve(rules, from)
if err != nil {
log.Fatal(err)
}
if resolved == nil {
fmt.Printf("%s: no rule\n", request)
continue
}
fmt.Printf("%s: %d %s\n", request, resolved.Rule.Status, resolved.To)
}
}
}
Output: /docs: 301 /docs/ /docs/: 301 /docs/ /docs: 302 /docs/ /docs/: no rule
func WithProxying ¶
func WithProxying() Option
WithProxying allows rewrites to other hosts. Fetching the destination is left to the caller.
Example ¶
ExampleWithProxying demonstrates proxying rewrites to URLs.
package main
import (
"fmt"
"log"
"net/url"
"strings"
"thde.io/rulefiles/redirect"
)
func main() {
const file = `
/api/* https://backend.example.com/:splat 200
/app/* /index.html 200
`
rules, err := redirect.Parse(strings.NewReader(file), redirect.WithProxying())
if err != nil {
log.Fatal(err)
}
for _, request := range []string{"/api/v1/users", "/app/deep/link"} {
from, err := url.Parse(request)
if err != nil {
log.Fatal(err)
}
resolved, err := redirect.Resolve(rules, from)
if err != nil {
log.Fatal(err)
}
if resolved.Proxy() {
fmt.Printf("%s: fetch %s\n", request, resolved.To)
continue
}
fmt.Printf("%s: serve %s\n", request, resolved.To)
}
}
Output: /api/v1/users: fetch https://backend.example.com/v1/users /app/deep/link: serve /index.html
type Resolved ¶
type Resolved struct {
// Rule is the first rule that matched the request.
Rule Rule
// To is the target URL.
To *url.URL
}
Resolved holds the matched redirect result.
func Resolve ¶
Resolve returns the redirect of the first rule matching from, in the order the rules are declared. It returns nil if no rule matches.
The status of the matched rule says what to do with the destination: - a 200 rewrites the request to it - a 400 and above answers the request with that error - anything else redirects to it
The caller needs to act on the result. A rewrite Resolved.Proxy reports is fetched from another host rather than served from the site.
Example ¶
ExampleResolve demonstrates handling matched redirect rules.
package main
import (
"context"
"fmt"
"log"
"net/http"
"net/http/httptest"
"strings"
"thde.io/rulefiles/redirect"
)
// file contains example redirect rules.
const file = `
/old-path /new-path
/blog/:year/* /posts/:year/:splat 302
/gone / 410
/app/* /index.html 200
`
func main() {
rules, err := redirect.Parse(strings.NewReader(file))
if err != nil {
log.Fatal(err)
}
serve := func(w http.ResponseWriter, r *http.Request) {
//nolint:gosec // Echo request path for example output.
_, _ = fmt.Fprintln(w, "serving "+r.URL.Path)
}
handler := func(w http.ResponseWriter, r *http.Request) {
resolved, err := redirect.Resolve(rules, r.URL)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if resolved == nil {
serve(w, r)
return
}
switch status := resolved.Rule.Status; {
case status == http.StatusOK:
// Rewrite request URL.
r.URL = resolved.To
serve(w, r)
case status >= 400:
http.Error(w, http.StatusText(status), status)
default:
//nolint:gosec // Destination is from static rules.
http.Redirect(w, r, resolved.To.String(), status)
}
}
for _, request := range []string{"/old-path", "/app/deep/link", "/gone", "/about"} {
rec := httptest.NewRecorder()
handler(rec, httptest.NewRequestWithContext(context.Background(), http.MethodGet, request, http.NoBody))
fmt.Printf("%s: %d %s%s", request, rec.Code, rec.Header().Get("Location"), rec.Body)
}
}
Output: /old-path: 301 /new-path<a href="/new-path">Moved Permanently</a>. /app/deep/link: 200 serving /index.html /gone: 410 Gone /about: 200 serving /about
func (*Resolved) Proxy ¶
Proxy reports whether the destination is to be fetched from another host rather than served from the site. It is only ever true for rules parsed with WithProxying, which allows a rewrite to target an absolute URL, and is false for a nil *Resolved.
type Rule ¶
type Rule struct {
// Source is the original path pattern.
Source string
// Target is the destination path or URL.
Target string
// Status is the HTTP status code.
Status int
// Force overrides existing static files.
Force bool
// contains filtered or unexported fields
}
Rule is a single redirect rule.
func Parse ¶
Parse reads the rules of a _redirects file. Empty lines and comments starting with "#" are ignored. Errors of all lines are reported together.
By default, the rules behave as they do on Netlify. Use Option arguments to configure their behaviour.
func (Rule) Destination ¶
Destination builds the URL a request to from is sent to. Query parameters of the request are kept unless the target defines them itself.
Example ¶
ExampleRule_Destination demonstrates expanding path placeholders.
package main
import (
"fmt"
"log"
"net/url"
"strings"
"thde.io/rulefiles/redirect"
)
func main() {
rules, err := redirect.Parse(strings.NewReader("/blog/:slug /posts/:slug?from=:slug\n"))
if err != nil {
log.Fatal(err)
}
rule := rules[0]
for _, request := range []string{"/blog/hello", "/blog/a%20b%26c", "/about"} {
from, err := url.Parse(request)
if err != nil {
log.Fatal(err)
}
captures, ok := rule.Match(from.Path)
if !ok {
fmt.Printf("%s: %s does not match\n", request, rule.Source)
continue
}
to, err := rule.Destination(captures, from)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%s: slug %q -> %s\n", request, captures["slug"], to)
}
}
Output: /blog/hello: slug "hello" -> /posts/hello?from=hello /blog/a%20b%26c: slug "a b&c" -> /posts/a%20b&c?from=a+b%26c /about: /blog/:slug does not match