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 type { PageConfig, PageLoaderContext } 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 };}import { useUser } from "@warlock.js/web";import type { PageProps } from "@warlock.js/web";import type { loader } from "./account.setup";
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. Retention follows the last committed render: an abandoned suspended transition cannot replace the retained user. 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 setup, any layout setup on its chain, or root.setup.ts 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.
Session updates after swaps
Section titled “Session updates after swaps”await refresh() and await changeLocaleCode("ar") install the fetched session
projection before rendering, including a guest (user: null or an omitted
session). Central page/action swaps do the same. Failed or superseded requests
leave the current identity intact; a response superseded while building its tree
cannot publish its identity. Persistent components using useUser() observe
profile updates and sign-out without remounting.