Skip to content

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.

src/config/web.ts
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.

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.

src/types/session.d.ts
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;
}
}

Use requireUser as page middleware:

src/web/account/account.page.tsx
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, or auth.pageAuth.returnUrlParam when you set it.
  • A request carrying an Authorization header, or an app with no loginPath (option or auth.pageAuth.loginPath), gets a 401 instead.
  • userType restricts to one user type and when(model) adds a check (it may be async). A failure is a 403, not a redirect.
requireUser({ userType: "admin", when: model => model.isActive });

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 };
}
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.

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.

src/web/account/account.page.tsx
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> (and PageLoader) type session as a non-optional { user, model } with neither field null, and request.locals.user as non-null. The route name is passed as the third type argument as above, or read from typeof config.route when route keeps a literal name: declare config as const satisfies PageConfig, because plain satisfies widens name to string and the loader then types session as optional. Unguarded pages, layout and app loaders, and page actions are unchanged: there session stays optional and user may be null.
  • useUser(routeName) returns a non-null SessionUser. 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 hand null to a component that reads user.name. Plain useUser() is unchanged and still returns null for a guest.

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.

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 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.

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.