Skip to content

Overview

Plumix compiles one route map at boot from what your plugins registered, then matches each public request against it in priority order. The first rule that matches decides what the page is about, and nothing downstream reads the URL again to change that answer.

There is no routes file and no directory of URL handlers. Registering recipe as an entry type is what puts /recipe/sicilian-caponata on the site, and registering cuisine as a taxonomy is what puts /cuisine/italian there. Both leading segments default to the registered name, and rewrite: { slug: "recipes" } in the quickstart below is what turns the first one into /recipes/sicilian-caponata. The route map is a projection of the registry.

A match produces a route intent, which is what the matched URL represents. There are nine kinds.

Intent What it means Example URL
entry One entry of a named type /recipes/sicilian-caponata
entryType The paginated listing for one entry type /recipes, /recipes/page/2
term The entries carrying one term /cuisine/italian/sicilian
author One author’s published entries /authors/marco
date A year, month or day of entries /2026, /2026/04/18
frontPage The site root and its later pages /, /page/2
search A search result set /search/caponata
archiveType An archive a plugin registered whole whatever the plugin declared
view An app page a plugin registered /login, /compare/abc

The intent carries only the route shape. The slug, term path and page number ride alongside it as the parameters URLPattern captured. A resolver per kind reads both, loads the data, and hands a typed payload to the theme, which picks a template for it.

frontPage is the intent whose listing core defines outright rather than deriving from a registration, so its rule lives here. It selects published entries from every public non-hierarchical entry type, orders them newest publish time first, and pages them twenty at a time. That page size is a core constant, so archivePerPage does not move it the way it moves an entry-type or taxonomy archive. Hierarchical types are excluded on purpose, which is why the pages plugin’s page entries never appear in the front feed.

search is the one intent that rewrites its own URL. A plain HTML form submitting GET /search?q=caponata 301s to /search/caponata, so the result page is shareable rather than query-string bound. It borrows the same twenty-per-page listing and matches on entry title or excerpt.

Only GET and HEAD reach the public site. Any other method on a public URL returns 405 with an Allow: GET, HEAD header, before any lookup runs.

  1. Register a type and a taxonomy. Create plugins/recipes.ts:

    import { definePlugin } from "plumix/plugin";
    export const recipes = definePlugin("recipes", {
    setup: (ctx) => {
    ctx.registerEntryType("recipe", {
    label: "Recipes",
    labels: { singular: "Recipe", plural: "Recipes" },
    hasArchive: true,
    rewrite: { slug: "recipes" },
    termTaxonomies: ["cuisine"],
    });
    ctx.registerTermTaxonomy("cuisine", {
    label: "Cuisines",
    isHierarchical: true,
    entryTypes: ["recipe"],
    });
    },
    });

    Declare the link on both sides. The taxonomy’s entryTypes is what the term-archive resolver filters the listing by, and the entry type’s termTaxonomies is what the editor’s term picker reads. Omit the second and no author can put a cuisine on a recipe, so /cuisine/italian compiles as a route and then lists nothing.

  2. Install it. Import recipes in plumix.config.ts and add it to the plugins array.

  3. Start the dev server and visit the routes those two calls compiled.

    Terminal window
    pnpm dev
    URL Intent
    /recipes entryType for recipe
    /recipes/page/2 the same archive, page two
    /recipes/sicilian-caponata entry for recipe
    /cuisine/italian term for cuisine
    /cuisine/italian/sicilian the nested term, because cuisine is hierarchical
    /cuisine/italian/page/2 that term archive, page two

    You wrote no patterns. Every row came out of rewrite.slug, hasArchive and isHierarchical.

A request reaches the public router only after several earlier steps decline it. In order:

  1. The dispatcher strips the base path. With basePath: "/kitchen" in plumix.config.ts, it rewrites /kitchen/recipes to /recipes once, at the edge, so nothing downstream is base-path aware. A request that is not under the base gets a 404, because it is not part of the mounted site.
  2. The platform’s own endpoints answer. Everything under /_plumix/ belongs to the platform: the RPC endpoint, the sign-in flows, the admin app at /_plumix/admin, the MCP endpoint, the REST API at /_plumix/api/v1, and any raw route a plugin mounted at /_plumix/<pluginId>. A /_plumix/ path that matches none of them returns 404 rather than falling through to your content.
  3. Registered public routes answer. A plugin can own a path at the site root with registerPublicRoute, which is how a plugin serves /robots.txt, a sitemap or a feed. These match first, so a registered route shadows everything below it, including core’s own endpoints. The handler always answers; there is no fall-through to the page that would otherwise own the path. Core serves no machine endpoint of its own here: @plumix/plugin-seo claims robots.txt and the sitemap, @plumix/plugin-feeds the feeds.
  4. Redirect rules run. Plumix matches the rules from plumix.config.ts, from plugins and from the theme as one precedence-ordered set. A match ends the request with a 301, 302, 307, 308 or a 410.
  5. Asset-shaped misses stop early. A path ending in .ico, .css, .js, .png, .woff2 or another asset extension returns a cacheable 404 without touching the database.
  6. The canonical normalizer runs. A non-canonical shape 301s to its canonical form before routing. See Permalinks and Slugs for what counts as canonical.
  7. The route map runs. First rule wins. An unmatched / is the front page; any other unmatched URL is a 404, and that 404 is never cached.
  8. The intent resolves and renders. The resolver loads the entries, the term or the author, then the theme’s template hierarchy turns the payload into HTML.

Every compiled rule carries a numeric priority, and lower wins. Rules at equal priority keep the order the compiler emitted them in, and the compiler emits by kind before it emits by registration. It emits the framework routes first, then the taxonomies, then the entry types, then the registerRewriteRule rules, then the registerArchiveType routes, then the registerView routes. So at priority 10 every rewrite rule sits ahead of every archive-type route no matter which plugin registered first, and registration order only breaks ties inside one of those groups.

Priority Rules
5 Framework routes: /page/:page, /search, /authors/:slug, the date archives, and the paginated variant of each
10 Explicit registerRewriteRule, registerArchiveType and registerView routes, by default
50 The single and archive rules generated from registerEntryType and registerTermTaxonomy
60 A single rule whose type set rewrite: { slug: "" }, so its pattern matches at the URL root

Only the 10 is a default. registerRewriteRule takes a third argument { priority }, and registerArchiveType takes a priority in its options, both of which replace it outright. A rule numbered below 5 therefore lands ahead of the framework routes, and one numbered above 60 lands behind the root catch-all. The other three rows are fixed by the compiler.

One pattern normally has one owner, and two rules claiming the same one throw at boot naming both. Numbering below 5 is the exception: a rule may claim a framework pattern outright when its priority actually beats the framework’s, which is how @plumix/plugin-search replaces the search page with a ranked one. Core’s rule stays compiled behind it, so uninstalling the plugin restores the built-in page with nothing to undo, unless the site turned core’s search routes off. Claim a framework pattern at a priority that cannot win and it still throws — a rule that silently never matches is the mistake the check exists to catch.

The same goes for an entry type’s or taxonomy’s own URLs, even though their patterns differ from the framework’s. A taxonomy slugged search compiles /search/:term, which is a different string from /search/:query, but the framework rule would still answer every URL the taxonomy’s permalinks point at. Boot throws instead, naming the registration and the slug. A type slugged page keeps compiling, because the framework reserves only numeric page segments under /page.

Two consequences are worth knowing before you name anything.

Date archives are framework routes, so /2026 is the year archive and a recipe slugged 2026 is unreachable at the root of a type mounted there, unless the site turns the date routes off. The compiler emits taxonomy rules ahead of entry-type rules, so a taxonomy named cuisine and an entry type with rewrite: { slug: "cuisine" } resolve taxonomy-first instead of racing on registration order.

Two rules that compile to the identical pattern throw at boot, and the error names both registering plugins. A silent shadow would be worse than a failed deploy.

The last priority band is how the pages plugin serves /about/team. Its page type registers rewrite: { slug: "" }, which compiles to a /:path+ catch-all at the URL root, and the 60 keeps that catch-all behind every sibling plugin’s rules so resolution does not depend on install order.

Core compiles the author, date and search routes on every site, whether or not it has posts. A site that has no use for one turns its family off with the routes key in plumix.config.ts:

import type { PlumixConfigInput } from "plumix";
export const routes: PlumixConfigInput["routes"] = {
author: false,
date: false,
};

Each of author, date and search defaults to true, so a site that leaves routes out keeps all three. date is one switch for the year, month and day archives. Root pagination (/page/2) belongs to the front page and has no switch.

A family set to false is never compiled, not even as a redirect. Its URLs fall through to whatever else matches, or to 404. With date: false, a page slugged 2026 under a type mounted at the root is reachable at /2026. With search: false, /search?q= is not redirected, and a page slugged search is reachable at /search. @plumix/plugin-search keeps serving /search/:query on its own rules either way. Anything that reads the archive routes, such as @plumix/plugin-feeds, registers no feed for a family that is off.

A date archive with no entries answers 404 whatever the switch says, because an empty year names nothing but a number. An empty author archive still answers 200, because the author exists.

Four calls put URLs on your site that no entry-type registration would produce.

registerRewriteRule(pattern, intent, options?) maps a URLPattern pathname onto an existing intent. Use it to serve a type at a second URL, or to give an archive a shape the generated rules do not cover. The only option is priority, which defaults to 10.

import { definePlugin } from "plumix/plugin";
export const recipes = definePlugin("recipes", {
setup: (ctx) => {
ctx.registerRewriteRule("/kitchen/:slug", {
kind: "entry",
entryType: "recipe",
});
},
});

An entry intent can also name one fixed entry with slug. The rule then serves that entry at whatever URL the pattern matched, which puts an editable page at a dynamic path:

import { definePlugin } from "plumix/plugin";
export const compare = definePlugin("compare", {
setup: (ctx) => {
ctx.registerRewriteRule("/compare/:id", {
kind: "entry",
entryType: "page",
slug: "shared-comparison",
});
},
});

/compare/abc renders the shared-comparison page, and its block loaders read the captured id from ctx.resolvedRoute.params. A fixed-slug match behaves like any other visit to the entry: a draft needs a preview link, ?plumix.edit opens the editor, and the entry’s per-entry access choice gates the path. A slug that names no visible entry is a 404. The rule is the plugin’s own URL, so /compare/abc never redirects, and the page’s permalink stays where it was.

registerArchiveType(name, options) registers a whole archive: its URL patterns, what the archive contains, and an optional priority. It compiles to the archiveType intent, and the theme targets it by name. @plumix/plugin-feeds augments the options of an archive that lists entries with an optional feed, so an installed plugin can syndicate the archive too, and @plumix/plugin-seo lists its URLs in the sitemap when the plugin calls ctx.registerSitemap under its name. Reach for it when core has no concept of the archive at all, such as a printable weekly menu.

entries is what the archive contains, and declaring it is what hands the listing to core. It receives an entry query already holding nothing but public entries, narrows it, and returns it — or null for params that name no archive, which is a 404. It is synchronous and runs no queries; the term slug below is resolved when the page is served.

import { definePlugin } from "plumix/plugin";
export const talks = definePlugin("talks", {
setup: (ctx) => {
ctx.registerArchiveType("talks", {
routes: ["/talks/:track"],
entries: (q, params) =>
q
.ofTypes("talk")
.inTerm("track", params.track ?? "")
.orderBy("title"),
perPage: 10,
title: (params) => `Talks: ${params.track ?? ""}`,
});
},
});

Core then lists that query, pages it, and reads the order it carries. It derives /talks/:track/page/:page from each declared route — you do not register the paginated form — and answers 404 past the last page. The theme receives data.entries and data.pagination, the same two fields a built-in archive hands over, so its pagination and entry components work unchanged. With cacheable: true, the stored page carries the type tag of each entry type the query can list, and a publish of any of them purges it. An archive whose resolve reads entries itself gets the same through compileEntryQuery, and one that reads its plugin’s own data calls ctx.cache.reads(key).

perPage defaults to 20. title takes a string or a function of the route params. resolve becomes optional once entries is declared, and is left with what only it can do — load a subject the route names, add fields of its own. It is handed the finished page as a third argument, and what it returns is merged under core’s entries and pagination:

import type { AppContext } from "plumix";
import { definePlugin } from "plumix/plugin";
declare function loadRoom(
ctx: AppContext,
slug: string | undefined,
): Promise<{ name: string } | null>;
export const rooms = definePlugin("rooms", {
setup: (ctx) => {
ctx.registerArchiveType("rooms", {
routes: ["/rooms/:room"],
entries: (q) => q.ofTypes("talk"),
resolve: async (appCtx, params, listing) => {
const room = await loadRoom(appCtx, params.room);
if (room === null) return null;
return {
data: { kind: "archiveType", name: "rooms", room: room.name },
title: `${room.name} (${String(listing.pagination.total)})`,
};
},
});
},
});

An archive that leaves entries out works exactly as before: resolve returns the whole { data, title } payload or null, and owns its own paging. That is the shape for an archive whose results are a match rather than a set — @plumix/plugin-search registers this way — and only an archive described by an entry query can have a feed.

An archive is the same page for every visitor. A page that lists nothing and differs per visitor, such as a sign-in form, an account page or a shared comparison, is a view. Register it with registerView, not as an archive without entries.

Either form of resolve can end the request instead of naming a page. Throw redirectTo(location, status?) from plumix/support to send the visitor elsewhere, or pageNotFound() for the theme’s 404, which is the same answer as returning null. resolve receives the full AppContext, so the decision can read the session or the store:

import { definePlugin } from "plumix/plugin";
import { redirectTo } from "plumix/support";
export const rooms = definePlugin("rooms", {
setup: (ctx) => {
ctx.registerArchiveType("rooms", {
routes: ["/rooms/:room"],
resolve: (_appCtx, params) => {
// The hall was renamed; its old address moves for good.
if (params.room === "main-hall") {
throw redirectTo("/rooms/auditorium", 301);
}
return {
data: { kind: "archiveType", name: "rooms" },
title: params.room ?? "Rooms",
};
},
});
},
});

The status defaults to 302 and takes any of 301, 302, 307 and 308. The location is sent as given, so a site under a basePath passes the prefixed path. A redirect from a page step depends on who asked, so the CDN never stores it and it leaves with cache-control: private, no-store. Block loaders, template deps and a template’s document() can throw the same two outcomes.

registerView(name, options) registers an app page: a sign-in form, an account page, a comparison a visitor shared. It renders through the theme like any page, with a template, the document() head and the access policy an archive takes, but it lists nothing. So it has no entries, no paging and no feed. It compiles to the view intent, and the theme targets it with forView(name).

resolve(ctx, params) returns { data, title } or null for the theme’s 404, and can throw redirectTo() or pageNotFound() the way an archive’s can. Core wraps data for the template as data.data, beside the view’s name and the params its route captured. Sending a signed-in visitor away from /login is one line:

import { definePlugin } from "plumix/plugin";
import { redirectTo } from "plumix/support";
export const login = definePlugin("login", {
setup: (ctx) => {
ctx.registerView("login", {
routes: ["/login"],
resolve: (appCtx) => {
if (appCtx.user !== null) throw redirectTo("/account");
return { data: null, title: "Sign in" };
},
});
},
});

A view is per-visitor by default:

  • The CDN never stores it unless you pass cacheable: true. A cacheable view is stored under what its resolve read: entries it lists through compileEntryQuery, and its plugin’s own data through ctx.cache.reads(key). A write to any of them drops the stored page. A view with access always renders live.
  • It claims no canonical address. The page carries no automatic <link rel="canonical">; a view template whose document() declares one renders that one.
  • @plumix/plugin-seo marks it noindex, and lists it in no sitemap or llms.txt. A site offers a view to search engines with indexViews.

A view’s name and its routes follow the archive rules. A name registered twice, or a route another registration already claims, fails the boot naming both plugins. To type data, add the view to ViewRegistry; a name left out still registers, with data typed unknown:

declare module "plumix" {
interface ViewRegistry {
compareShare: { data: { readonly products: readonly string[] } };
}
}

render.archiveEntries hands any archive’s entry query to a plugin to narrow — core’s front page, type, term, author and date archives as well as a plugin archive with entries. It is Plumix’s pre_get_posts for archives. The handler receives the query and { archive, params }: the archive it describes and what the archive’s URL captured. It returns a query. It runs wherever the archive’s query is read, so the archive’s page, its pagination and its feed all change together.

import { sql } from "plumix/db";
import { definePlugin, render } from "plumix/plugin";
import { entries, entryTerm, terms } from "plumix/schema";
// Entries not filed under the `asides` category.
const notAnAside = sql`${entries.id} not in (select ${entryTerm.entryId} from ${entryTerm} inner join ${terms} on ${terms.id} = ${entryTerm.termId} where ${terms.taxonomy} = 'category' and ${terms.slug} = 'asides')`;
export const quietFrontPage = definePlugin("quiet-front-page", {
setup: (ctx) => {
ctx.filters.add(render.archiveEntries, (query, { archive }) =>
archive.kind === "frontPage" ? query.where(notAnAside) : query,
);
},
});

archive names the archive as the router does — frontPage, entryType (with its entryType), term (with its taxonomy), author, date, or archiveType (with the plugin archive’s name). No term or author is loaded for it; read the slug off the params. A query only narrows, and core applies the public-entries rule again when it compiles one, so a handler can hide entries but never reveal one — even returning a fresh entryQuery() lists no draft. A handler can change the order too, and a feed still reads newest first. Like entries, the handler is synchronous and runs no queries. The search page is not an entry-query archive and does not pass through it.

registerPublicRoute(options) skips the route map entirely and answers a path at the site root itself, which is how a plugin owns a machine-readable endpoint like /robots.txt, a sitemap or a feed. path is an exact pathname or a URLPattern pathname whose captured groups reach the handler as its third argument, and cacheable: true opts the response into the CDN on the same terms as a /_plumix/ plugin route, except that a signed-in request renders live, as described below. Two plugins claiming one path throws at boot naming both owners; a path inside /_plumix/ throws too, since core owns that prefix — mount those with registerRoute.

import { definePlugin } from "plumix/plugin";
export const feeds = definePlugin("feeds", {
setup: () => undefined,
afterSetup: (ctx) => {
ctx.registerPublicRoute({
path: "/feed",
handler: () =>
new Response("<rss />", {
headers: { "content-type": "application/rss+xml" },
}),
});
},
});

Register from the descriptor’s afterSetup rather than from setup. It runs once every plugin’s setup has, so every entry type and taxonomy is registered, and a plugin enumerates them and claims concrete paths instead of matching an ambiguous pattern per request — the reason there is no fall-through to worry about. ctx.plugins is what it reads them from: the same read-only registry AppContext.plugins carries at request time. Only the afterSetup context has it, because during setup it would be missing whatever later plugins register.

The route answers GET and HEAD; a write method gets a 405 from the step above it. Without an access policy, the handler runs ahead of the access gate. An anonymous request reaches it with a null ctx.user, and that is the response a cacheable route stores. A request the site’s authenticator calls signed in, or one bearing an Authorization header or a ?preview= token, reaches it with its principal loaded instead, as a page with no policy would. That render never reads or fills the CDN, and it leaves as private, no-store, so a handler that lists content through core’s read rules lists each reader what that reader may see.

Pass access — the same access policy registerArchiveType takes — to gate the route instead. Plumix loads the principal and resolves the policy before the handler runs. A reader the gate refuses gets the gate’s own response, a redirect to sign-in or a challenge, and the handler never runs. A reader it admits reaches the handler with ctx.user loaded and ctx.access set. A policied route answers per reader, so it never enters the CDN, even with cacheable: true, and a response for any segment other than anonymous is sent private, no-store.

The request URL has had any basePath stripped, so build outbound URLs from ctx.origin and ctx.config.basePath rather than from request.url.

In development the debug bar carries a Template panel showing what the request resolved to and which template rule won, along with every rule that was skipped or never reached. On an error page it shows nothing, because an error page resolves no node.

Plumix compiles the route map from registrations rather than from a declaration you write, so Entry Types and Taxonomies and Terms are where a URL shape is really decided. Everything after the match belongs to the theme. Template Hierarchy covers how an intent resolves to a template, and Template Data covers the payload each resolver hands over. basePath and redirects are both slots in Configuration. The blog and pages plugins each register types with deliberate URL shapes, described in Blog and Pages.

Read Permalinks and Slugs for the outbound half of routing: what a slug may contain, how Plumix composes one into a permalink, and which URL it treats as canonical. Then Template Hierarchy picks the request up where this page leaves it.

Four pages in this section are not written yet, and everything they describe already ships. Archives covers the paginated listing routes, the archivePerPage page size and what an out-of-range page number returns. Redirects covers the three places redirect rules come from, the URLPattern and RegExp forms of from, query preservation, and answering 410 Gone for content you removed on purpose. Rewrite Rules goes deeper into registerRewriteRule, registerArchiveType and registerView, including priorities and the patterns that can never match. Base Path covers serving the whole site under a subdirectory.