Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/dirty-ads-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@solidjs/router": minor
---

Added support for route guards to control access to routes.

Now, in route definitions, you can use the `guard` field, which accepts:

- a boolean value;
- a synchronous or asynchronous function returning `boolean | string | { allowed: boolean; redirect?: string }`.

**Key features:**

- Flexible permission system with automatic redirect when returning a string or an object with `redirect`.
- Integration with preload — for protected routes, data is not loaded unnecessarily.
- New `useRouteGuard` hook for reactive access to the check status in components.
- Utilities `evaluateRouteGuard` and `normalizeGuardResult` for custom scenarios.

**Motivation for changes:**
Previously, route protection was implemented manually inside components, leading to code duplication and not always working uniformly. Built-in guards provide a declarative and centralized way to manage access.

**How to update code:**
For developers, this is a backward-compatible change. Existing routes will continue to work without modifications. To add protection, simply specify the `guard` prop in the `<Route>` component or in the route object (see examples in the documentation).
8 changes: 7 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ export {
useResolvedPath,
useSearchParams,
useBeforeLeave,
usePreloadRoute
usePreloadRoute,
useRouteGuard,
evaluateRouteGuard,
normalizeGuardResult
} from "./routing.js";
export { mergeSearchString as _mergeSearchString } from "./utils.js";
export * from "./data/index.js";
Expand All @@ -30,6 +33,9 @@ export type {
RouteSectionProps,
RoutePreloadFunc,
RoutePreloadFuncArgs,
RouteGuardFunc,
RouteGuardFuncArgs,
RouteGuardResult,
RouteDefinition,
RouteDescription,
RouteMatch,
Expand Down
38 changes: 37 additions & 1 deletion src/routers/components.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/*@refresh skip*/

import type {Component, JSX, Owner} from "solid-js";
import {children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack} from "solid-js";
import {children, createMemo, createRoot, getOwner, mergeProps, on, Show, untrack, createSignal, createEffect, onMount} from "solid-js";
import {getRequestEvent, isServer, type RequestEvent} from "solid-js/web";
import {
createBranches,
Expand All @@ -19,6 +19,7 @@ import type {
RouteContext,
RouteDefinition,
RoutePreloadFunc,
RouteGuardFunc,
RouterContext,
RouterIntegration,
RouteSectionProps
Expand Down Expand Up @@ -110,9 +111,43 @@ function Routes(props: { routerState: RouterContext; branches: Branch[] }) {

const disposers: (() => void)[] = [];
let root: RouteContext | undefined;
const [guardPending, setGuardPending] = createSignal(true);
const [guardResult, setGuardResult] = createSignal<{ allowed: boolean; redirect?: string }>({ allowed: true });

// Check guards when matches change
createEffect(() => {
const matches = props.routerState.matches();
if (!matches.length) {
setGuardPending(false);
setGuardResult({ allowed: true });
return;
}

setGuardPending(true);
props.routerState.checkRouteGuard(matches).then(result => {
setGuardResult(result);
setGuardPending(false);

// Handle redirect if guard failed with a redirect path
if (!result.allowed && result.redirect) {
props.routerState.navigatorFactory()(result.redirect!, { replace: true });
}
});
});

const routeStates = createMemo(
on(props.routerState.matches, (nextMatches, prevMatches, prev: RouteContext[] | undefined) => {
// If guard check is pending or failed, don't create new route states
if (guardPending()) {
return prev || [];
}

const result = guardResult();
if (!result.allowed) {
// Guard failed without redirect, keep previous or return empty
return prev || [];
}

let equal = prevMatches && nextMatches.length === prevMatches.length;
const next: RouteContext[] = [];
for (let i = 0, len = nextMatches.length; i < len; i++) {
Expand Down Expand Up @@ -166,6 +201,7 @@ export type RouteProps<S extends string, T = unknown> = {
path?: S | S[];
children?: JSX.Element;
preload?: RoutePreloadFunc<T>;
guard?: RouteGuardFunc | boolean;
matchFilters?: MatchFilters<S>;
component?: Component<RouteSectionProps<T>>;
info?: Record<string, any>;
Expand Down
145 changes: 143 additions & 2 deletions src/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ import type {
SetParams,
Submission,
SearchParams,
SetSearchParams
SetSearchParams,
RouteGuardFunc,
RouteGuardFuncArgs,
RouteGuardResult
} from "./types.js";
import {
mockBase,
Expand Down Expand Up @@ -273,14 +276,97 @@ export const useBeforeLeave = (listener: (e: BeforeLeaveEventArgs) => void) => {
onCleanup(s);
};

/**
* A hook that evaluates a route guard and returns a reactive signal with the result.
* Useful for conditionally rendering content based on guard status.
*
* @param guard - The guard function or boolean to evaluate
* @returns A reactive signal with the guard result (allowed boolean and optional redirect)
*
* @example
* ```tsx
* function ProtectedComponent() {
* const [guardResult] = useRouteGuard(isAuthenticated);
*
* return (
* <Show when={guardResult().allowed} fallback={<Navigate href="/login" />}>
* <SensitiveContent />
* </Show>
* );
* }
* ```
*/
export function useRouteGuard(guard: RouteGuardFunc | boolean) {
const location = useLocation();
const params = useParams();
const [result, setResult] = createSignal<{ allowed: boolean; redirect?: string }>({
allowed: true
});
const [pending, setPending] = createSignal(false);

createRenderEffect(async () => {
setPending(true);
const guardResult = await evaluateRouteGuard(guard, {
params,
location,
intent: getIntent() || "initial"
});
setResult(normalizeGuardResult(guardResult));
setPending(false);
});

return [result, { pending }] as const;
}

/**
* Evaluates a route guard function or boolean value.
* @param guard - The guard to evaluate (boolean or function)
* @param args - Arguments to pass to the guard function
* @returns A RouteGuardResult indicating whether access is allowed
*/
export async function evaluateRouteGuard(
guard: RouteGuardFunc | boolean | undefined,
args: RouteGuardFuncArgs
): Promise<RouteGuardResult> {
if (guard === undefined || guard === true) {
return true;
}
if (guard === false) {
return false;
}
if (typeof guard === "function") {
return await guard(args);
}
return guard;
}

/**
* Normalizes a RouteGuardResult to a standardized format.
* @param result - The guard result to normalize
* @returns An object with `allowed` boolean and optional `redirect` string
*/
export function normalizeGuardResult(result: RouteGuardResult): {
allowed: boolean;
redirect?: string;
} {
if (typeof result === "boolean") {
return { allowed: result };
}
if (typeof result === "string") {
return { allowed: false, redirect: result };
}
return { allowed: result.allowed, redirect: result.redirect };
}

export function createRoutes(routeDef: RouteDefinition, base: string = ""): RouteDescription[] {
const { component, preload, load, children, info } = routeDef;
const { component, preload, load, children, info, guard } = routeDef;
const isLeaf = !children || (Array.isArray(children) && !children.length);

const shared = {
key: routeDef,
component,
preload: preload || load,
guard,
info
};

Expand Down Expand Up @@ -528,6 +614,29 @@ export function createRouterContext(
// Create a native transition, when source updates
createRenderEffect(on(source, source => transition("native", source), { defer: true }));

async function checkRouteGuard(matches: RouteMatch[]): Promise<{ allowed: boolean; redirect?: string }> {
// Evaluate all guards in the match chain (from root to leaf)
for (let i = 0; i < matches.length; i++) {
const { route, params } = matches[i];
const guard = route.guard;

if (guard !== undefined && guard !== true) {
const result = await evaluateRouteGuard(guard, {
params,
location,
intent: intent || "initial"
});

const normalized = normalizeGuardResult(result);
if (!normalized.allowed) {
return normalized;
}
}
}

return { allowed: true };
}

return {
base: baseRoute,
location,
Expand All @@ -539,6 +648,7 @@ export function createRouterContext(
matches,
beforeLeave,
preloadRoute,
checkRouteGuard,
singleFlight: options.singleFlight === undefined ? true : options.singleFlight,
submissions
};
Expand Down Expand Up @@ -625,8 +735,39 @@ export function createRouterContext(
const matches = getRouteMatches(branches(), url.pathname);
const prevIntent = intent;
intent = "preload";

for (let match in matches) {
const { route, params } = matches[match];

// Check guard before preloading
const guard = route.guard;
if (guard !== undefined && guard !== true) {
// For preload, we evaluate the guard but don't block if it's async
// We just skip preloading if the guard is synchronously false
const guardResult = evaluateRouteGuard(guard, {
params,
location: {
pathname: url.pathname,
search: url.search,
hash: url.hash,
query: extractSearchParams(url),
state: null,
key: ""
},
intent: "preload"
});

// If guard result is synchronously false or a redirect string, skip preload
if (guardResult instanceof Promise) {
// For async guards, we still preload but the guard will be re-checked on navigation
} else {
const normalized = normalizeGuardResult(guardResult);
if (!normalized.allowed) {
continue; // Skip preloading this route
}
}
}

route.component &&
(route.component as MaybePreloadableComponent).preload &&
(route.component as MaybePreloadableComponent).preload!();
Expand Down
29 changes: 29 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,32 @@ export interface RoutePreloadFuncArgs {

export type RoutePreloadFunc<T = unknown> = (args: RoutePreloadFuncArgs) => T;

/**
* Arguments passed to a route guard function.
*/
export interface RouteGuardFuncArgs {
params: Params;
location: Location;
intent: Intent;
}

/**
* Return type for route guard functions.
* - `true`: Allow access to the route
* - `false`: Deny access (show nothing or stay on current route)
* - `string`: Redirect to the specified path
*/
export type RouteGuardResult = boolean | string | { allowed: boolean; redirect?: string };

/**
* Route guard function that determines if a route can be accessed.
* Can return a boolean, redirect path, or a RouteGuardResult object.
* Supports both synchronous and asynchronous guards.
*/
export type RouteGuardFunc = (
args: RouteGuardFuncArgs
) => RouteGuardResult | Promise<RouteGuardResult>;

export interface RouteSectionProps<T = unknown> {
params: Params;
location: Location;
Expand All @@ -94,6 +120,7 @@ export type RouteDefinition<S extends string | string[] = any, T = unknown> = {
path?: S;
matchFilters?: MatchFilters<S>;
preload?: RoutePreloadFunc<T>;
guard?: RouteGuardFunc | boolean;
children?: RouteDefinition | RouteDefinition[];
component?: Component<RouteSectionProps<T>>;
info?: Record<string, any>;
Expand Down Expand Up @@ -141,6 +168,7 @@ export interface RouteDescription {
pattern: string;
component?: Component<RouteSectionProps>;
preload?: RoutePreloadFunc;
guard?: RouteGuardFunc | boolean;
matcher: (location: string) => PathMatch | null;
matchFilters?: MatchFilters;
info?: Record<string, any>;
Expand Down Expand Up @@ -181,6 +209,7 @@ export interface RouterContext {
parsePath(str: string): string;
beforeLeave: BeforeLeaveLifecycle;
preloadRoute: (url: URL, preloadData?: boolean) => void;
checkRouteGuard: (matches: RouteMatch[]) => Promise<{ allowed: boolean; redirect?: string }>;
singleFlight: boolean;
submissions: Signal<Submission<any, any>[]>;
}
Expand Down