Skip to content

Redirects — loop-safe, per-site URL redirects

Granit.Cms.Redirects keeps URLs stable across restructures. Each Redirect maps a site-relative source path to a target (path or absolute URL) with an HTTP status, scoped to a site and optionally a culture. Resolution follows chains and refuses loops, and a page move can mint the redirect for you.

Redirect redirect = Redirect.Create(
id, siteId,
source: RedirectSource.Create("/old-pricing"),
matchType: RedirectMatchType.Exact, // or Prefix
target: RedirectTarget.Create("/pricing"), // site-relative or absolute URL
type: RedirectType.MovedPermanently, // 301 / 302 / 307 / 308
culture: null); // null ⇒ all cultures
  • Match typeExact for one path, Prefix to move a whole subtree (/blog/*).
  • StatusMovedPermanently (301), Found (302), TemporaryRedirect (307), PermanentRedirect (308).
  • Source is normalized (leading slash, no trailing slash except /, no query/fragment) and matched case-insensitively. The source is immutable after creation; retarget instead.
  • Origin records provenance — Manual, AutoFromPageMove, or Imported.

Resolution works off an immutable per-site RedirectMapSnapshot. It follows chains (A→B→C), stops at absolute targets, and treats a chain longer than 10 hops as a loop.

GET /api/cms/redirects/resolve?path=/old-pricing&culture=en
→ 200 { "target": "/pricing", "statusCode": 301 } (or 204 when nothing matches)

When a page is moved or renamed, its old URL would 404. If the site’s SiteRedirectSettings has AutoRedirectOnMove enabled (the default), the module listens for the page’s path-change event and creates AutoFromPageMove 301s for the affected paths — no manual step.

Each redirect tracks HitCount and LastHitAt, but counting on the hot path would be a write per request. Instead hits accumulate in an in-process buffer and a recurring RedirectHitFlushJob (every 5 min) drains them to the store. A nightly RedirectIntegritySweepJob flags drift — sources now shadowed by a live page, dangling internal targets.

MapGranitCmsRedirects() maps everything under /api/cms/redirects:

RoutePermissionPurpose
GET /resolve?path=…&culture=…anonymousResolve a path (the renderer’s call)
GET /sites/{siteId}/redirects · GET /{id}Cms.Redirects.ReadList by site / read one
POST /sites/{siteId}/redirectsCms.Redirects.ManageCreate (loop + duplicate checked; warns if it shadows a live page)
PUT /{id} · DELETE /{id}Cms.Redirects.ManageRetarget (source immutable) / soft-delete
GET /gridCms.Redirects.ReadFilterable grid (Inactive / AutoFromPageMove / Imported)
GET /sites/{siteId}/settings · PUT …/settingsCms.Redirects.Read / ManageAutoRedirectOnMove toggle
GET /sites/{siteId}/preview?path=…Cms.Redirects.ReadTest a path before relying on it