Skip to main content

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 a loader that returns null.
  • 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​

WordMeaning
RouteOne 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
OutletA 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
MiddlewareA 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 sessionThe 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 routeA 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 layoutA 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 ruleA 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
GuardThe 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)
AccessThe question "may this person enter this area?" A policy route answers it
AppearanceThe 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.

NodeWhat it doesWhat it renders
Root routePicks the identity system. Loads the sessionOnly an Outlet
Policy routeDecides whether a person may enter the routes inside itOnly an Outlet
Visual layoutDraws the navbar, sidebar and page framePage structure
PageLoads its own data and shows itThe 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.js nests 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.

ShapeWhenExampleWhat it gives
The policy wraps the visual layoutThe layout draws only guarded pagesemployer-required-policy above org-dashboard-layout.jsxA denied person is redirected before the layout renders, so the layout has no auth code at all
The visual layout wraps the policiesThe layout also draws open pagescareers-public-layout.jsx above talent-required-policyThe 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 typeAnswersRendersExports
Policy routeMay this person enter?Only an Outletmiddleware, loader
Visual layoutWhat does this area look like?Navbar, sidebar, page frameA component, and a clientLoader for the facts its frame shows

A visual layout follows four rules.

  • It never exports middleware or an action.
  • It never reads the session to decide access, and it never redirects.
  • Its clientLoader loads 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.

Both are layout() routes

A 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 />
}
ExportJob
middlewareRuns one entry rule on Node, before any loader
loaderForces the .data request on an in-app click, so the middleware runs then too. The policy loader page explains the mechanism
The componentRenders an Outlet. It draws nothing and decides nothing

Every policy route looks like this. Only the rule changes.

  • The loader returns null on 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.

PartLives onJobRule it must follow
Session middlewareThe root routeGets the factsIt may call Rails. It never decides
Entry ruleapp/auth/auth-entry-rules.jsDecidesA pure function. It never fetches
GuardThe policy route's middlewareConnects the twoIt 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, not request.url. On an in-app click the request URL ends in .data and carries a _routes parameter. url is the page the person is going to.
  • The admin guard in app/auth/auth-admin-guard.js is the same function reading getTeamIdentitiesAdminSessionsCurrent. 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 routeGuardsWraps
user-guest-policySigned-out people only/login, /sign-up, /forgot-password, /reset-password
user-required-policySigned-in people/identities/edit
talent-onboarding-policyA talent profile that is not finished/talent/setup
talent-required-policyA finished talent profileThe rest of /talent/**
employer-guest-policySigned-out people only/employers/login, /employers/sign-up
employer-onboarding-policyA person with no membership/employers/create-account
employer-inactive-policyA membership in state company_disabled or revoked/employers/access-unavailable
employer-required-policyAn active membershiporg-dashboard-layout and every page inside it
admin-guest-policySigned-out admins only/team/login
admin-required-policySigned-in adminsteam-layout and every page inside it
  • Employer access has four states, not two: missing, active, company_disabled and revoked. That is why employers need four policy routes and everyone else needs two.
  • /team/password sits outside admin-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​

RuleWhy
Always export a loaderWithout it, an in-app click may send no request to Node, so the middleware never runs. The policy loader page shows why
Never export shouldRevalidateReact Router tests revalidation before it tests the loader. Opting out turns the check off silently
Never export clientLoaderA route with one is handed to its own clientLoader before the loader is considered, so it stops forcing the request
Render only an OutletAny markup here belongs in a visual layout
One rule per policyA policy that answers two questions should be two policies
Never call Rails in the componentThe middleware already has the answer, in router context
Never nest one policy inside anotherTwo 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.

ClaimTrue?
A denied person never sees a protected pageYes. The redirect always wins the navigation
No server loader below the policy runs for a denied personYes. The thrown redirect stops the loader phase of that request
No browser fetch fires for a denied person on an in-app clickNo. 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.