Route Guards#

Route guards allow you to control access to routes, redirect users, and load data before rendering.

Basic Guard#

Add a guard function to a route:

TSX
const router = createRouter({
    routes: [
        {
            path: '/admin',
            component: AdminDashboard,
            guard: () => {
                if (!isAuthenticated()) {
                    return '/login'; // Redirect to login
                }
                return true; // Allow access
            },
        },
    ],
});

Guard Return Values#

Guards can return different values:

TSX
guard: () => {
    // Allow navigation
    return true;
    
    // Block navigation (stays on current page)
    return false;
    
    // Redirect to path
    return '/login';
    
    // Redirect with options
    return { path: '/login', query: { redirect: '/admin' } };
}

Async Guards#

Guards can be asynchronous for data fetching or validation:

TSX
{
    path: '/profile',
    component: Profile,
    guard: async () => {
        try {
            await fetchUserProfile();
            return true;
        } catch (error) {
            return '/login';
        }
    },
}

Guard with Route Context#

Guards receive the target route as context:

TSX
{
    path: '/users/:id',
    component: UserProfile,
    guard: async (to) => {
        const userId = to.params.id;
        const hasAccess = await checkUserAccess(userId);
        return hasAccess ? true : '/unauthorized';
    },
}

Nested Route Guards#

Guards are executed from parent to child. If a parent guard fails, child guards are not executed:

TSX
{
    path: '/admin',
    component: AdminLayout,
    guard: () => isAdmin(), // Checked first
    children: [
        {
            path: 'settings',
            component: AdminSettings,
            guard: () => hasSuperAdminRole(), // Checked second
        },
    ],
}

Register global guards that run on every navigation:

TSX
router.beforeEach((to, from) => {
    // Log all navigation
    console.log(`Navigating from ${from?.path ?? '(initial)'} to ${to.path}`);
    return true;
});

router.afterEach((to, from) => {
    // Track page views
    analytics.pageView(to.path);
});

Guards run on the initial navigation#

The full guard pipeline — beforeEach, per-route beforeEnter, beforeResolve, route-record redirects, and afterEach — runs on the initial route resolution too, not only on later navigate() calls. A direct load or refresh of a guarded URL (server or client) goes through the same guards as any in-app navigation, kicked off by app.use(router) (or the first router.isReady() call). Two things to know:

  • from is null on the initial navigation. There is no previous route on a fresh load, so always treat from as nullable in guards and afterEach hooks:

    TSX
    router.beforeEach((to, from) => {
        if (from === null) {
            // first resolution — direct load or refresh
        }
        return true;
    });
  • A guard returning false on the initial navigation keeps the matched route. There is no previous route to stay on, so a bare false leaves the protected component showing. To actually protect content on a direct load, redirect (return a path or location) instead of returning false:

    TSX
    router.beforeEach((to, from) => {
        if (to.meta.requiresAuth && !isAuthenticated()) {
            return '/login';   // redirect — works on initial load and later navigations
        }
        return true;
    });

A redirect on the initial load lands with replace semantics (Back can't return to the guarded URL) and records the original location on to.redirectedFrom — which is what makes real server-side 30x redirects possible (see SSR Routing).

router.isReady() resolves once that initial navigation — guards included — has completed:

TSX
const app = defineApp(<App />).use(router);
await router.isReady();   // initial route resolved, guards have run

Guards run in the app's DI context#

When the router is installed into an app (defineApp(...).use(router)), guards and afterEach hooks are invoked inside that app's DI context. A defineFactory / defineInjectable use-function called from a guard resolves the same app-scoped instance your components receive — so an auth guard that reads an injected auth store sees the same state the rest of the app sees:

TSX
router.beforeEach((to) => {
    const auth = useAuthStore();        // resolves the app's instance, same as components
    return auth.isLoggedIn || to.path === '/login' ? true : '/login';
});

Async-guard caveat. The app context covers only the synchronous portion of each guard — it does not survive an await. Resolve your injected dependencies at the top of the guard, before the first await:

TSX
router.beforeEach(async (to) => {
    const auth = useAuthStore();      // ✓ resolve before awaiting
    await auth.refresh();
    // useOtherStore() here would resolve the realm fallback, not the app instance
    return auth.isLoggedIn ? true : '/login';
});

When the router runs standalone (no app), guards are invoked directly, as they always are.