Utility functions for working with PhoenixKit routes and URLs.
This module provides helpers for constructing URLs with the correct PhoenixKit prefix configured in the application.
Summary
Types
The request a redirect is being built for. The application's router is read
from it, which is how routable?/2 works. nil is accepted — it simply
makes every candidate fail closed, so the chain lands on a core-owned page.
Functions
Returns a locale-aware admin path. For non-primary locales the locale
segment is always emitted. For the primary locale the shape follows
the site-wide default_language_no_prefix setting
(Languages.default_language_no_prefix?/0): prefixless when the
setting is on, prefixed when off.
Whether a path lands on one of the sign-in pages (or /users/log-out).
The bare base URL (scheme + host, no trailing slash), same source url/1 uses.
Returns the default locale (base code) from the Languages module.
Returns true when path is safe to use as a local redirect / return_to
target: a binary that begins with a single / but not // or /\ — both of
which browsers resolve as protocol-relative, host-switching URLs. Use this to
guard user-supplied redirect params against open-redirect attacks.
Returns a locale-aware path using locale from assigns.
The configured site main page, or nil when unset or unusable.
Builds a PhoenixKit path: the host's mount prefix, plus a locale segment when the site is multilingual.
Gets the base module name for the parent application.
Resolves where to send a user once they are signed in and confirmed.
Renders ?return_to=<path> for a link, or "" when there is nothing safe to
carry. Lets the auth pages hand the pending destination to each other instead
of dropping it the moment a visitor switches to another sign-in method.
Whether path actually resolves to a GET route in the application's router.
Resolves where to send a visitor that core is allowed to send them.
Returns a full url with preconfigured prefix.
Returns the configured PhoenixKit URL prefix.
Types
@type request_context() :: Plug.Conn.t() | Phoenix.LiveView.Socket.t() | nil
The request a redirect is being built for. The application's router is read
from it, which is how routable?/2 works. nil is accepted — it simply
makes every candidate fail closed, so the chain lands on a core-owned page.
Functions
Returns a locale-aware admin path. For non-primary locales the locale
segment is always emitted. For the primary locale the shape follows
the site-wide default_language_no_prefix setting
(Languages.default_language_no_prefix?/0): prefixless when the
setting is on, prefixed when off.
Both URL shapes resolve at the router level — the admin route macros
declare /:locale/admin/* AND /admin/* scopes — so either shape is
routable. The two shapes share one live_session :phoenix_kit_admin,
so locale switching across them stays on the WebSocket
(push_navigate) without a full-page reload.
Examples
iex> Routes.admin_path("/admin/users", "uk")
"/phoenix_kit/uk/admin/users"
iex> Routes.admin_path("/admin/users", nil)
"/phoenix_kit/admin/users"Primary-locale shape depends on the default_language_no_prefix
setting (not shown as doctests because the result varies with
runtime state):
# setting OFF (default)
Routes.admin_path("/admin/users", "en") #=> "/phoenix_kit/en/admin/users"
# setting ON
Routes.admin_path("/admin/users", "en") #=> "/phoenix_kit/admin/users"
Whether a path lands on one of the sign-in pages (or /users/log-out).
Public because the after_login_path / after_registration_path changeset
applies the same rule when the setting is saved — one list, one predicate, so
a new auth route can't be guarded on read and forgotten on write.
Suffix-matched, since the real URL carries the host's mount prefix and an
optional locale segment (/app/et/users/log-in). A host page whose own path
happens to end in one of these segments is refused too — over-strict rather
than allowing a redirect loop.
Examples
iex> PhoenixKit.Utils.Routes.auth_page?("/users/log-in")
true
iex> PhoenixKit.Utils.Routes.auth_page?("/et/users/log-out/")
true
iex> PhoenixKit.Utils.Routes.auth_page?("/dashboard")
false
@spec base_url() :: String.t()
The bare base URL (scheme + host, no trailing slash), same source url/1 uses.
For absolutizing a path that is already url-prefixed and locale-prefixed
(e.g. a notification's link, built via path/1) — concatenate it onto this
directly. Do NOT pass such a path to url/1, which re-applies path/1 and
would double-prefix it.
Returns the default locale (base code) from the Languages module.
Extracts the base code from the default language (e.g., "en-US" becomes "en"). Falls back to "en" if no default language is configured.
Examples
iex> Routes.get_default_admin_locale()
"en"
Returns true when path is safe to use as a local redirect / return_to
target: a binary that begins with a single / but not // or /\ — both of
which browsers resolve as protocol-relative, host-switching URLs. Use this to
guard user-supplied redirect params against open-redirect attacks.
ASCII control characters are rejected too. Browsers strip tab/CR/LF while
parsing a URL, so "/\t/evil.example" (from ?return_to=%2F%09%2Fevil.example)
becomes //evil.example — a cross-origin navigation — once it reaches
window.location. Phoenix.Controller.redirect/2 blocks those itself, but
LiveView's validate_local_url! only rejects \\ and a leading //, so a
LiveView redirect(socket, to: ...) would otherwise pass it through.
Examples
iex> PhoenixKit.Utils.Routes.local_path?("/admin/dashboard")
true
iex> PhoenixKit.Utils.Routes.local_path?("//evil.com")
false
iex> PhoenixKit.Utils.Routes.local_path?("https://evil.com")
false
iex> PhoenixKit.Utils.Routes.local_path?("/\t/evil.com")
false
Returns a locale-aware path using locale from assigns.
This function is specifically designed for use in component templates where the locale needs to be passed explicitly via assigns.
Prefers base locale code for URL generation (current_locale_base), falls back to extracting base from full dialect code (current_locale).
@spec main_page_path() :: String.t() | nil
The configured site main page, or nil when unset or unusable.
nil rather than "/" is deliberate: an unset setting means "nobody chose",
and safe_destination/2 probes "/" on its own as the last candidate. A
"/" default here would instead assert it as the administrator's
first-priority choice — ahead of everything, on a host that may not route it.
The setting is validated as a local path when saved and re-guarded here on read, so a hand-edited DB row can't turn it into an open redirect.
Builds a PhoenixKit path: the host's mount prefix, plus a locale segment when the site is multilingual.
The :locale option
This is the contract, and it is what a bilingual site needs when auth pages come out in the wrong language. Without it the locale is determined — from the process's Gettext locale — which is right for a link rendered inside a request and wrong for a link built outside one (an email, a background job, a script).
locale: "et"— force this locale.locale: :none— emit no locale segment at all. For anything that is not a page: assets, webhooks,sitemap.xml.locale: nilor omitted — determine it from the current process.
Examples
Routes.path("/users/log-in") # current locale
Routes.path("/users/log-in", locale: "et") # /et/users/log-in
Routes.path("/sitemap.xml", locale: :none) # never localizedNote that the primary language is emitted prefixlessly when the site is
configured that way, so locale: "en" on an English-primary site yields a
path with no /en segment — that is deliberate, not a dropped option.
See the multilang guide for how locales are resolved and switched; the symptom of getting this wrong (English auth pages on a translated site) reads like an i18n bug rather than a routing one.
@spec phoenix_kit_app_base() :: String.t()
Gets the base module name for the parent application.
Reads from :phoenix_kit, :layouts_module config (e.g., MprojectWeb.Layouts -> MprojectWeb).
Examples
iex> PhoenixKit.Utils.Routes.phoenix_kit_app_base()
"MprojectWeb"
Resolves where to send a user once they are signed in and confirmed.
Takes candidate destinations in priority order (e.g. a ?return_to= param,
then the session's user_return_to) and returns the first that passes
local_path?/1. Falls back to the after_login_path setting, then to
core's own guaranteed landing, /admin.
The setting is validated as a local path when saved, but is re-guarded here
so a hand-edited DB row can't turn a post-auth redirect into an open
redirect. Single source of truth for the post-auth landing page — used by
the login flow (signed_in_path/1) and by both confirmation LiveViews.
Options
:context— theconn/socketthe redirect is being built for. Pass it wherever one is at hand."/"is the host's home page and core declares no route for it; with a context the tail of the chain is probed withroutable?/2and, when the host really has no/, handed tosafe_destination/2, which can then pick the best destination for this subject instead of merely a safe one. Without a context the tail ispath("/admin")— still safe, just less specific.:scope— the subject, forwarded tosafe_destination/2for that last step. Only consulted when a:contextis present.
The context-less tail is path("/admin") and deliberately not
safe_destination(nil, opts): with no scope to go on that call runs the
ANONYMOUS chain and terminates on /users/log-in, whose on_mount bounces
an authenticated visitor straight back through here — the "core-owned
destination" guarantee would survive exactly one hop. /admin is scope-blind
on purpose: core declares it unconditionally and admits every authenticated
visitor, so it cannot bounce anyone.
Examples
iex> PhoenixKit.Utils.Routes.post_auth_path(["/checkout"])
"/checkout"With nothing usable to go on the chain lands on core's guaranteed landing.
Its exact shape depends on the host's mount prefix and the language settings,
so these read as comparisons rather than literals — they asserted "/" back
when the tail was the host's unowned home page:
iex> alias PhoenixKit.Utils.Routes
iex> Routes.post_auth_path(["https://evil.com", nil]) == Routes.path("/admin")
true
iex> alias PhoenixKit.Utils.Routes
iex> Routes.post_auth_path(["/users/log-out"]) == Routes.path("/admin")
true
Renders ?return_to=<path> for a link, or "" when there is nothing safe to
carry. Lets the auth pages hand the pending destination to each other instead
of dropping it the moment a visitor switches to another sign-in method.
@spec routable?(request_context(), term()) :: boolean()
Whether path actually resolves to a GET route in the application's router.
The router is taken from the request: conn.private[:phoenix_router], set by
the generated router before dispatch, or socket.router, set when the
LiveView is mounted at the router.
When the router cannot be determined this returns false. The two failure
modes are asymmetric: emitting an unverified path is the 404 this whole
mechanism exists to eliminate, while skipping the candidate merely falls
through to a core-owned page that is guaranteed to exist.
@spec safe_destination( request_context(), keyword() ) :: String.t()
Resolves where to send a visitor that core is allowed to send them.
Replaces every hardcoded "/" / Routes.path("/") destination in core.
Routes.path("/") emits a locale-prefixed root (/en); the route that would
serve it belongs to the host application, which core cannot declare, so on a
host that never declared one every such redirect 404s.
The chain
Authenticated (opts[:scope] passes Scope.authenticated?/1):
:return_to— the untrusted explicit destination/admin, whenScope.can_access_admin_area?/1and:skip_adminis not set/dashboard- the
after_login_pathsetting - the host's home page —
path("/"), then"/"
Anonymous:
- the
main_page_pathsetting, when set and still resolvable - the host's home page —
path("/"), then"/"
Every candidate must be a local path (local_path?/1), not an auth page
(auth_page?/1), and actually routable (routable?/2). Under
:skip_admin it must additionally not be an admin-area path
(admin_area_path?/1) — see the option below.
The home page is a candidate like any other, and only like any other. It is the one destination in this whole mechanism that core cannot declare, so it is used exactly where the host proves it declared it, and skipped silently everywhere else. Dropping it entirely would have been a silent regression for every already-working install, where logging out has always landed on the site home.
Both shapes are offered, locale-prefixed first, because a multilingual host
may declare either or both — see home_candidates/0. The prefixed form is
what the eleven original call sites emitted; the defect was that they emitted
it unprobed, not that they named it.
When nothing survives, the terminal is core's own: /admin for any
authenticated visitor, /users/log-in for an anonymous one. The terminal
follows authentication rather than being a single page, because
/users/log-in bounces a signed-in visitor straight back out again —
terminating an authenticated chain there would hand the decision to
post_auth_path/2, one hop later.
Options
:scope—%PhoenixKit.Users.Auth.Scope{}ornil. Pass it explicitly. Several call sites run on pipelines that never assign a scope, and full logout still carries the just-logged-out user inconn.assignsafter the session has been cleared, so inferring it here would answer "authenticated" about someone who no longer is.:return_to— a candidate path, or a list of them in priority order. Honoured on the authenticated chain only: an anonymous pending destination belongs in theuser_return_tosession key, not in a redirect.:skip_admin— the caller is rejecting this visitor from the admin area. It suppresses step 2 and drops every remaining candidate that resolves into the admin area (admin_area_path?/1), whichever step produced it — a:return_to, or anafter_login_pathan operator pointed at/admin/users. Suppressing only step 2 was not enough: on a host withuser_dashboard_enabled: falsethe setting was the first candidate left standing, it is routable, and handing it back re-entered the same gate that had just refused the visitor — a candidate always won, the terminal was never reached, and the browser gave up withERR_TOO_MANY_REDIRECTS.The terminal is deliberately NOT filtered: it is the
/adminindex, which the gate admits every authenticated visitor to, so arriving there is a render rather than a second bounce. That asymmetry is the whole point — the chain has somewhere to end.
The invariant
Every value returned is either a path the caller supplied, an administrator configured, or one of the two shapes of the host's own home page — each proven to resolve in the router — or one of the two landings core declares itself. There is no third branch, and nothing is ever returned unprobed except those two landings, which core declares and permits unconditionally.
path("/"), the locale-prefixed root that started all this, is therefore
still a candidate — it simply may no longer be synthesized. That distinction
is the fix: the eleven original call sites returned it without asking whether
it resolved, which is why it 404'd.
The invariant used to be "every candidate was probed, terminals included",
because core could promise nothing about its own pages: /dashboard is
compiled out by user_dashboard_enabled: false, and /admin used to reject
an authenticated visitor who held no admin rights. Neither is true of the
terminals any more:
/adminis declared unconditionally by the admin index route and, since:phoenix_kit_ensure_adminexempts that one view from its permission checks (PhoenixKitWeb.Users.Auth.landing_view?/1), admits every authenticated visitor — one who holds no rights is greeted and shown nothing else. So it can neither 404 nor bounce./users/log-inis declared unconditionally too, by the public auth surface. That is a separate fact from the/admindecision: it rests ongenerate_public_live_routes/1inPhoenixKitWeb.Integration, not on anything the admin area does, and it holds independently of it.
So the invariant is now: the chain ends at a path core declares
unconditionally and permits unconditionally. The terminal is still handed
to routable?/2, but only as a diagnostic — the arm is returned either way,
and the probe exists to name a misconfigured install in the log instead of
letting it surface as a mystery 404. See terminal/2.
That invariant is about the TERMINAL, and stating it was not enough to make
the chain terminate. A candidate that wins is returned instead of the
terminal, so under :skip_admin — the rejection path — the candidates are
held to the weaker fact the caller actually needs: no candidate may be an
admin-area path. Otherwise the resolver can answer with the very kind of
page the visitor was just refused, the gate refuses it again, and the
identical computation runs forever without ever reaching the terminal it was
promised. With the filter in place a skip_admin resolution is either a
non-admin path proven routable in this router, or the /admin index — and
the index admits everyone, so the next hop renders.
The authenticated terminal is not an auth page: /users/log-in redirects an
authenticated visitor through post_auth_path/2, which re-enters this
function, so using it there is an infinite redirect rather than a fallback.
Returns a full url with preconfigured prefix.
This function first checks for a configured site URL in Settings, then automatically detects the correct URL from the running Phoenix application endpoint when possible, falling back to static configuration. This ensures that magic links and other email links work correctly in both development and production environments, with full control over the base URL through the Settings admin panel.
@spec url_prefix() :: String.t()
Returns the configured PhoenixKit URL prefix.
Examples
iex> PhoenixKit.Utils.Routes.url_prefix()
"/phoenix_kit"