dynamic_routes

Database-driven top-level URLs in Phoenix — without your own routes getting shadowed.

defmodule MyAppWeb.Router do
use MyAppWeb, :router
use DynamicRoutes, resolver: MyApp.Pages
scope "/", MyAppWeb do
pipe_through :browser
dynamic_routes MyAppWeb.PageController, :show # written FIRST
get "/settings", SettingsController, :edit # still matches
end
end

/settings reaches SettingsController. /about reaches PageController, if MyApp.Pages says there is a page there. Declaration order stops mattering.

The problem

Phoenix routes are compile-time macros, so a URL living in a database row cannot be declared. The usual workaround is a catch-all at /, which then swallows every route declared after it — Phoenix matches in declaration order and there is no fall-through.

This is not a niche complaint. Beacon, the Phoenix LiveView CMS, documents it as a limitation the developer must hand-manage:

"put all specific routes first"

"any route after the prefix may match a published page"

That is the state of the art: order your routes carefully and hope nobody adds one in the wrong place. This package makes the ordering irrelevant.

Installation

def deps do
[{:dynamic_routes, "~> 0.1"}]
end

How it works

A declared route always wins. On each request the router is asked — through Phoenix.Router.route_info/4, its own public lookup — whether anything matches. If something does, the request passes straight through and Phoenix behaves exactly as it always would.

The path is percent-decoded before that question is asked, because Phoenix decodes before matching and route_info/4 does not. Skipping that step makes the two disagree on every encoded request — and /%61dmin then slips past the pipeline guarding /admin.

Only when nothing matches, on a request that was heading for a 404 anyway, is the resolver consulted. If it claims the path:

  1. conn.path_info is rewritten to an internal route.
  2. super/2 runs the real Phoenix pipeline — plugs, telemetry, error handling, all of it.
  3. The path is restored before the controller sees it, so canonical URLs the controller builds name what the client actually requested.

Nothing here reimplements routing. The router does the matching, twice, and the second time it is matching a route that was declared normally.

That precedence — code beats data — is the deliberate part. If a database row could shadow a declared route, deploying a route would be a gamble on the contents of a table, and a page called settings would take down your settings page.

Setting it up

A resolver. It is asked only about paths no declared route matched, and answers :pass or {:match, term}:

defmodule MyApp.Pages do
@behaviour DynamicRoutes.Resolver
@impl true
def resolve([slug]) do
case MyApp.Content.page_by_slug(slug) do
nil -> :pass
page -> {:match, page}
end
end
def resolve(_path), do: :pass
end

The controller gets whatever the resolver returned, so it does not look the record up a second time:

def show(conn, _params) do
page = DynamicRoutes.resolution(conn)
render(conn, :show, page: page)
end

use DynamicRoutes must come afteruse MyAppWeb, :router — it overrides the call/2 that defines.

Caching is not optional

The resolver runs on requests that matched no declared route. On a public site that includes every scanner probing for /wp-login.php, forever. Without a cache that is a database query per probe.

So answers are cached — including the misses, which are the common case — with a TTL and a hard size limit. The keys are paths chosen by whoever is making the request, so an unbounded cache would be a memory-exhaustion bug with a public trigger.

Invalidate when pages change:

DynamicRoutes.invalidate() # everything
DynamicRoutes.invalidate("/blog/post") # one path

This clears the local node. Broadcast it if you run more than one.

config :dynamic_routes,
cache_ttl: :timer.minutes(5),
cache_max_size: 10_000,
cache: true

Set cache: false in development so editing a page shows up on reload.

What it does not do

Dynamic paths do not appear in ~p sigils, route helpers, or mix phx.routes. They are data; the compiler has never seen them. Build those URLs from the same records the resolver reads. The internal route does appear in all three — requesting it directly is a 404.

The target must be a controller, not a LiveView.live/3 compiles to a route carrying live-session metadata that Phoenix.LiveView.Plug matches on, and there is no way to reconstruct it from here. If your dynamic pages are LiveViews, this is not the tool — which is worth saying plainly, given that the motivating example above is a LiveView CMS.

Precedence is per method and path. A declared post "/x" does not stop a resolver claiming GET /x.

Dynamic pages answer every method. The internal route is match :*, so a POST to a dynamic page reaches the controller rather than 404ing. Check conn.method if that matters.

A forward/2 prefix cannot host dynamic paths — everything beneath it counts as declared, so the forwarded plug's own 404 wins.

Notes

The controller module passed to dynamic_routes/2 must be fully qualified even inside a scope with an alias — it travels as route metadata rather than as the route's plug, and Phoenix only expands aliases for the latter.

Declaring it inside a scope works: the rewrite reads the path the route actually ended up at from the router's own route table, rather than assuming the one it was configured with.

Dynamic requests log exactly like declared ones — Processing with MyAppWeb.PageController.show/2 — rather than naming the internal dispatcher, so a dynamically routed request is not a mystery in production logs.

Licence

MIT.