Policy routes
A policy route is a route with no page. It decides whether the routes inside it may open.
- It exports
middleware, which runs one entry rule on Node, and aloaderthat returnsnull. - It renders only an
Outlet, so it draws nothing. - Every protected area of JodApp Web sits inside one policy route, and ten policy routes cover the whole app.
- This page covers five things.
- The whole route tree, and where a policy sits in it.
- Why one route decides and another route draws.
- What a policy route is made of, and the three parts that share the work.
- The ten policy routes.
- The rules every policy route follows, and what a policy does and does not block.
The words this page uses
| Word | Meaning |
|---|---|
| Route | One entry in app/routes.js. A route says which file runs for which URL. For example, the route for /talent/profile points at app/routes/talent/profile/talent-profile-page.jsx. A route can sit inside another route. The inner one is the child, the outer one is the parent |
layout() | The way to declare a parent route in app/routes.js. A route made with layout() adds nothing to the URL. Its only job is to hold child routes and to run code for all of them. Example: layout('roots/public-root.jsx', [ ...child routes ]). The root route, every policy route and every visual layout are made this way |
Outlet | A React component from React Router. A parent route puts <Outlet /> in the place where its child route should appear. When the child route is /talent/profile, the profile page appears in that spot |
| Middleware | A function that a route exports in its middleware list. Node runs it before any loader, on every request that reaches Node. Reading the session explains it in full |
| The session | The current-session JSON that the root loader fetched from Rails, or null when nobody is signed in. Reading the session explains how it is read and where it is kept |
| Policy route | A parent route whose only job is to decide whether the routes inside it may open. It lives in app/routes/policies/. It exports three things: a middleware that runs one entry rule, a loader that returns null, and a component that only renders <Outlet />. It draws nothing on the screen. Example: employer-required-policy.jsx decides whether a person may open the employer dashboard |
| Visual layout | A parent route whose only job is to draw the parts of the screen that its child routes share: the navbar, the sidebar, the footer, the page frame. It lives in app/layouts/. Example: org-dashboard-layout.jsx draws the employer dashboard's sidebar and top bar. It never decides who may enter |
| Entry rule | A small function that answers one question: may this person open this page? It takes two inputs, the session and the location. It returns null to say "yes, allow", or a path such as /login to say "no, send the person there". It reads nothing from the network and changes nothing. The user rules live in app/auth/auth-entry-rules.js. The admin rules live in app/auth/auth-admin-entry-rules.js |
| Guard | The function that connects an entry rule to React Router. authUserGuard.protect(rule) returns a middleware. That middleware waits for the session, gives it to the rule, and redirects when the rule returns a path. A policy route puts that middleware in its middleware export. The admin twin is authAdminGuard.protect(rule) |
| Access | The question "may this person enter this area?" A policy route answers it |
| Appearance | The question "what does this area look like?" A visual layout answers it |
The route tree
This is the shape of the whole app. It mirrors app/routes.js, so you can open both side by side.
routes.js
│
├── roots/public-root.jsx session middleware + root loader
│ │
│ ├── /logout, /logout/everywhere resource routes: the logout actions, no page
│ ├── layouts/careers/careers-public-layout.jsx visual layout
│ │ ├── open pages /, /jobs, /companies, /privacy, /rewards …
│ │ ├── user-guest-policy /login, /sign-up, /forgot-password, /reset-password
│ │ ├── user-required-policy /identities/edit
│ │ ├── talent-onboarding-policy /talent/setup
│ │ └── talent-required-policy the rest of /talent/**
│ │
│ ├── layouts/org/org-public-layout.jsx visual layout
│ │ ├── open page /employers
│ │ ├── employer-guest-policy /employers/login, /employers/sign-up
│ │ ├── employer-onboarding-policy /employers/create-account
│ │ └── employer-inactive-policy /employers/access-unavailable
│ │
│ └── employer-required-policy
│ └── layouts/org-dashboard/org-dashboard-layout.jsx visual layout
│ ├── private employer pages /employers/dashboard, /employers/careers/** …
│ └── company switch page /employers/memberships
│
├── roots/team-root.jsx session middleware + root loader
│ ├── admin-guest-policy /team/login
│ ├── /team/logout, /team/logout/everywhere resource routes: the logout actions, no page
│ ├── /team/password an invitation token protects it, not a session
│ └── admin-required-policy
│ └── layouts/team/team-layout.jsx visual layout
│ └── private /team/** pages
│
├── /up health check, outside both roots
└── /* catch-all, outside both roots
Two small route groups are left out to keep the tree readable. Neither has a policy: /debug, which exists in development only, and the nested /rewards pages.
The tree has four kinds of node. Learn these four and the tree reads itself.
| Node | What it does | What it renders |
|---|---|---|
| Root route | Picks the identity system. Loads the session | Only an Outlet |
| Policy route | Decides whether a person may enter the routes inside it | Only an Outlet |
| Visual layout | Draws the navbar, sidebar and page frame | Page structure |
| Page | Loads its own data and shows it | The page |
Where a policy sits in the tree
A policy wraps exactly the routes it guards. That gives two shapes, and the app uses both. Each box below is one route, drawn inside its parent route, the same way reading the session draws one request.
Legend: Each dashed box is one route, drawn inside its parent route the way
app/routes.jsnests them. The italic label in each box's top-left corner says which kind of route the box is. The bold line in each node is the file or the path. Orange is a policy route, this page's subject. Lavender is the root route that loads the session. Grey is a route that only draws, or a page. An arrow points from a parent route to a route inside it. Everything inside the orange policy's box is protected by it, and nothing outside it is.
| Shape | When | Example | What it gives |
|---|---|---|---|
| The policy wraps the visual layout | The layout draws only guarded pages | employer-required-policy above org-dashboard-layout.jsx | A denied person is redirected before the layout renders, so the layout has no auth code at all |
| The visual layout wraps the policies | The layout also draws open pages | careers-public-layout.jsx above talent-required-policy | The open pages and the guarded pages share one frame, and each policy sits around its own pages only |
The employer shape in app/routes.js:
layout('roots/public-root.jsx', [
layout('layouts/org/org-public-layout.jsx', [
...prefix('employers', orgPublicRoutes)
]),
// The policy is the parent, so the sidebar is inside the protected area.
layout('routes/policies/employer-required-policy.jsx', [
layout('layouts/org-dashboard/org-dashboard-layout.jsx', [
...prefix('employers/dashboard', orgDashboardRoutes)
])
])
])
One route decides, another route draws
Access and appearance are two questions, and each gets its own file.
| Route type | Answers | Renders | Exports |
|---|---|---|---|
| Policy route | May this person enter? | Only an Outlet | middleware, loader |
| Visual layout | What does this area look like? | Navbar, sidebar, page frame | A component, and a clientLoader for the facts its frame shows |
A visual layout follows four rules.
- It never exports
middlewareor an action. - It never reads the session to decide access, and it never redirects.
- Its
clientLoaderloads only what the frame itself shows, such as the company name in the dashboard's top bar. - A change the person makes, such as switching company, is a page with its own
clientAction. Everything about entry stays on the policy route.
When the check sits in a separate route above the layout, a denied person never reaches the layout. So the layout never needs to know that authentication exists.
layout() routesA policy route and a visual layout are declared the same way, with layout(). React Router calls both of them layout routes. They are the same kind of thing, and they differ only in the job they do.
When these pages say visual layout, they mean the one that draws page furniture. They never mean "layout route in general".
What a policy route is made of
A policy route is short on purpose. This is the whole file that guards the employer dashboard.
// app/routes/policies/employer-required-policy.jsx
import { Outlet } from 'react-router'
import { authUserGuard } from '@/auth/auth-user-guard'
import { authEntryRules } from '@/auth/auth-entry-rules'
export const middleware = [authUserGuard.protect(authEntryRules.employerRequired)]
// Not empty by accident. A loader is what makes the middleware run on an in-app click.
// Never remove it.
export const loader = () => null
export default function EmployerRequiredPolicy() {
return <Outlet />
}
| Export | Job |
|---|---|
middleware | Runs one entry rule on Node, before any loader |
loader | Forces the .data request on an in-app click, so the middleware runs then too. The policy loader page explains the mechanism |
| The component | Renders an Outlet. It draws nothing and decides nothing |
Every policy route looks like this. Only the rule changes.
- The loader returns
nullon all of them. The session already lives on the root loader, so a policy has nothing of its own to publish. - The component has no logic at all. A condition that seems to belong in it belongs in the rule.
Three parts, three jobs
The work of a policy splits three ways, and each part has one job.
| Part | Lives on | Job | Rule it must follow |
|---|---|---|---|
| Session middleware | The root route | Gets the facts | It may call Rails. It never decides |
| Entry rule | app/auth/auth-entry-rules.js | Decides | A pure function. It never fetches |
| Guard | The policy route's middleware | Connects the two | It calls the getter and throws the redirect. It never fetches on its own and never decides |
The guard is one function.
// app/auth/auth-user-guard.js
import { replace } from 'react-router'
import { authUserSession } from '@/auth/auth-user-session-middleware'
const protect = (rule) => async ({ context, url }) => {
const { getIdentitiesUserSessionsCurrent } = context.get(authUserSession.context)
const identitiesUserSession = await getIdentitiesUserSessionsCurrent()
// url is the page location, normalised by React Router. request.url would still carry the
// .data suffix and the _routes param of an in-app click.
const redirectPath = rule(identitiesUserSession, { pathname: url.pathname, search: url.search })
// replace(), not redirect(). A policy redirect is a correction, not a destination.
// It must not stay in browser history, or the Back button bounces the person forward again.
if (redirectPath) throw replace(redirectPath)
}
export const authUserGuard = {
protect: protect
}
Three details in that code:
- The guard never calls
next(). React Router calls it for a middleware that returns without calling it. - The guard reads
url, notrequest.url. On an in-app click the request URL ends in.dataand carries a_routesparameter.urlis the page the person is going to. - The admin guard in
app/auth/auth-admin-guard.jsis the same function readinggetTeamIdentitiesAdminSessionsCurrent. The two share no code, so a user session can never satisfy an admin rule.
When one piece owns all three jobs, none of them can be tested or changed on its own. Splitting them is most of the value of this design.
The ten policy routes
Each policy route owns one question.
| Policy route | Guards | Wraps |
|---|---|---|
user-guest-policy | Signed-out people only | /login, /sign-up, /forgot-password, /reset-password |
user-required-policy | Signed-in people | /identities/edit |
talent-onboarding-policy | A talent profile that is not finished | /talent/setup |
talent-required-policy | A finished talent profile | The rest of /talent/** |
employer-guest-policy | Signed-out people only | /employers/login, /employers/sign-up |
employer-onboarding-policy | A person with no membership | /employers/create-account |
employer-inactive-policy | A membership in state company_disabled or revoked | /employers/access-unavailable |
employer-required-policy | An active membership | org-dashboard-layout and every page inside it |
admin-guest-policy | Signed-out admins only | /team/login |
admin-required-policy | Signed-in admins | team-layout and every page inside it |
- Employer access has four states, not two:
missing,active,company_disabledandrevoked. That is why employers need four policy routes and everyone else needs two. /team/passwordsits outsideadmin-required-policy. An invitation token protects that page, and a person opening it has no admin session yet.- The exact answer for every state is on the entry rules page.
Rules for every policy route
| Rule | Why |
|---|---|
Always export a loader | Without it, an in-app click may send no request to Node, so the middleware never runs. The policy loader page shows why |
Never export shouldRevalidate | React Router tests revalidation before it tests the loader. Opting out turns the check off silently |
Never export clientLoader | A route with one is handed to its own clientLoader before the loader is considered, so it stops forcing the request |
Render only an Outlet | Any markup here belongs in a visual layout |
| One rule per policy | A policy that answers two questions should be two policies |
| Never call Rails in the component | The middleware already has the answer, in router context |
| Never nest one policy inside another | Two policies on one path means two answers. Pick one |
What the policy blocks, and what it does not
Two different claims hide in "the policy runs first". Be precise about each.
| Claim | True? |
|---|---|
| A denied person never sees a protected page | Yes. The redirect always wins the navigation |
No server loader below the policy runs for a denied person | Yes. The thrown redirect stops the loader phase of that request |
| No browser fetch fires for a denied person on an in-app click | No. The browser may start the page's own clientLoader fetches before the policy answer arrives |
That third row is a fact of React Router, not a gap in the design.
- Rails refuses those fetches with
401, so nothing leaks, and the redirect still wins the navigation. - The policy loader page shows the ordering in the React Router source.
- Rails is the boundary explains why Rails must protect every request on its own, whatever the policy does.