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
prefixmay 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:
conn.path_infois rewritten to an internal route.super/2runs the real Phoenix pipeline — plugs, telemetry, error handling, all of it.- 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.