Sessions in pages
Web resolves the signed-in user before loaders run and ships it to the browser in the hydration payload’s optional session key. Web never imports auth: it accepts any resolver through web.session, and @warlock.js/auth’s pageSession() fits.
Wire it
Section titled “Wire it”import { pageSession } from "@warlock.js/auth";import type { WebConfigurations } from "@warlock.js/web";import type { User } from "app/users/models/user";import { userSessionResource } from "app/users/resources/user-session.resource";
export default { session: pageSession({ project: (user: User) => userSessionResource(user) }),} satisfies WebConfigurations;project is required: a model never crosses the wire by default. Only what it returns reaches the browser.
Read the user
Section titled “Read the user”useUser() returns the projection, or null for a guest. It works in server render and in the browser, and components re-render on login and logout.
import { useUser } from "@warlock.js/web";
export function Greeting() { const user = useUser();
return user ? <span>Hi {user.name}</span> : <a href="/login">Sign in</a>;}Type it by augmenting SessionRegistry. Without it, user is { id: string | number } and model is unknown.
import type { User } from "app/users/models/user";import type { UserSessionOutput } from "app/users/resources/user-session.resource";
declare module "@warlock.js/web" { interface SessionRegistry { user: UserSessionOutput; model: User; }}Guard a page
Section titled “Guard a page”Use requireUser as page middleware:
import { requireUser } from "@warlock.js/web/session";
export const config = { route: "/account", middleware: [requireUser({ loginPath: "/login" })],};- A guest gets a 302 to
loginPath?redirect=<request.url>. The login path is locale-prefixed when locale routing prefixes the request locale. Data navigations follow the redirect, so client navigation lands on the login page too. - The query name is
redirect, orauth.pageAuth.returnUrlParamwhen you set it. - A request carrying an
Authorizationheader, or an app with nologinPath(option orauth.pageAuth.loginPath), gets a 401 instead. userTyperestricts to one user type andwhen(model)adds a check (it may be async). A failure is a 403, not a redirect.
requireUser({ userType: "admin", when: model => model.isActive });Loader form
Section titled “Loader form”Pass the loader context to read the model and guard from a loader. A guest throws a redirect that stops lower loaders. when must be synchronous here.
import type { PageLoaderContext } from "@warlock.js/web";import { requireUser } from "@warlock.js/web/session";
export async function loader(ctx: PageLoaderContext) { const account = requireUser(ctx, { loginPath: "/login" });
return { name: account.name };}Guest-only pages
Section titled “Guest-only pages”import { requireGuest } from "@warlock.js/web/session";
export const config = { route: "/login", middleware: [requireGuest({ to: "/account" })] };A signed-in user is sent to the redirect query when it is a same-origin relative path, else to to (default /). safeRedirectTarget(value) is exported to apply the same rule in your own login flow: it rejects //host, /\host, absolute URLs, javascript: and control characters.
Type a guarded page as signed-in
Section titled “Type a guarded page as signed-in”A page guarded by requireUser() can never be reached by a guest, so since 5.27 its types say so. The route-types generator reads the guard statically and writes guard: "user" on that page’s PageRouteRegistry entry; GuardedPageRouteName, exported from @warlock.js/web, is the union of those names.
import { useUser } from "@warlock.js/web";import type { PageConfig, PageLoaderContext, PageProps } from "@warlock.js/web";import { requireUser } from "@warlock.js/web/session";
export const config = { route: { path: "/account", name: "account.show" }, middleware: [requireUser({ loginPath: "/login" })],} satisfies PageConfig;
export async function loader({ session }: PageLoaderContext<undefined, undefined, "account.show">) { // session is { user, model } here, neither null, and request.locals.user is non-null return { user: session.user };}
export default function AccountPage({ data }: PageProps<typeof loader>) { const user = useUser("account.show"); // SessionUser, never null
return <h1>{user.name}</h1>; // assumes your SessionRegistry user has a name}- Loader context. For a guarded page,
PageLoaderContext<Validation, typeof route>(andPageLoader) typesessionas a non-optional{ user, model }with neither field null, andrequest.locals.useras non-null. The route name is passed as the third type argument as above, or read fromtypeof config.routewhenroutekeeps a literal name: declareconfigas const satisfies PageConfig, because plainsatisfieswidensnametostringand the loader then typessessionas optional. Unguarded pages, layout and app loaders, and page actions are unchanged: theresessionstays optional andusermay benull. useUser(routeName)returns a non-nullSessionUser. Any name that is not a guarded page fails to compile. Passing the name also keeps the last signed-in user while that component stays mounted, so a sign-out that flips the store to a guest a frame before the navigation completes does not handnullto a component that readsuser.name. PlainuseUser()is unchanged and still returnsnullfor a guest.
What counts as a guard
Section titled “What counts as a guard”The generator types a page as guarded only when it can prove the guard from source. The page, any layout on its chain, or the root (root.tsx) must export a literal config whose middleware is an array literal with a direct element that is a call to requireUser(...), where requireUser is a value import from "@warlock.js/web/session". The arguments do not matter. An aliased import (import { requireUser as ru }) counts, and as, satisfies or parentheses around the array or the call are looked through. A page’s config may live in its .setup.ts companion, in one of the two files.
These do not count, and the page stays unguarded in the types (no guard is emitted):
| Not a guard | Example |
| ------------------------------------------------------------- | ----------------------------------------------------------- |
| a spread into the array | middleware: [...guards] |
| middleware that is not an array literal | middleware: guards |
| an identifier in the array | middleware: [guard] |
| a conditional | middleware: [isPrivate ? requireUser() : audit] |
| a wrapper call around it | middleware: [withAudit(requireUser())] |
| requireGuest() | middleware: [requireGuest()] |
| config.action.middleware | only the page’s action is guarded |
| a type-only import, or an import from any other module | import type { requireUser } from "@warlock.js/web/session" |
| a namespace or member call | session.requireUser() |
| the loader form inside a loader | requireUser(ctx) guards at runtime but is not seen by the generator |
The not-found page is never guarded. A guard the generator cannot prove still runs at runtime; only the types stay loose, so useUser() keeps returning null for a guest. In development a file the generator cannot parse reads as unguarded rather than breaking boot.
Guests and the page cache
Section titled “Guests and the page cache”A guest render carries session: { user: null } and stays cacheable. A signed-in request is never served from or stored in the page cache: it carries a cookie, resolving the user marks the response auth-derived (private, no-store), and a renewal sets a cookie.
Renewal and logout
Section titled “Renewal and logout”Renewal is on by default and reactive: an expired or missing access cookie plus a valid refresh cookie mints a new pair before the response headers are written, so activity keeps the session alive. Do not call the resolver from streamed or deferred work; a late resolution throws in development and logs in production.
Log out with a POST action: authService.logout(user, accessToken, refreshToken), then authService.clearSessionCookies(response). Call clearPrefetchCache() before navigateTo/refresh() after login or logout.
Prefetch identity
Section titled “Prefetch identity”Each prefetch entry is tagged with the identity current when it was fetched (user?.id ?? null) and refused for a different one. When the identity in a payload changes, the client clears the prefetch cache automatically.
See Sessions for pages for the auth side.