Skip to main content
Route guards let you control who can access different parts of your application. They’re perfect for implementing role-based access control, feature flags, and authentication requirements.

What Are Guards?

Guards are functions that run before a page renders. They can:
  • Allow access with pass()
  • Block access with hidden() (shows 404)
  • Redirect with redirectTo(path)
Guards execute in order and stop at the first non-pass result.

Basic Guard Syntax

import { type Guard, pass, hidden, redirectTo } from "@tailor-platform/app-shell";

type GuardContext = {
  context: ContextData;  // Custom data from AppShell
};

const myGuard: Guard = ({ context }) => {
  // Your logic here
  return pass();  // or hidden() or redirectTo("/path")
};

Common Guard Patterns

Authentication Guard

Redirect unauthenticated users to login:
import { redirectTo, pass } from "@tailor-platform/app-shell";

const requireAuth: Guard = ({ context }) => {
  if (!context.currentUser) {
    return redirectTo("/login");
  }
  return pass();
};

// Apply to a module
const dashboardModule = defineModule({
  path: "dashboard",
  component: DashboardPage,
  guards: [requireAuth],
  resources: [...],
});

Role-Based Access Control

Hide pages from users without proper roles:
import { hidden, pass } from "@tailor-platform/app-shell";

const requireAdmin: Guard = ({ context }) => {
  if (context.currentUser?.role !== "admin") {
    return hidden();  // Shows 404 for non-admins
  }
  return pass();
};

const adminModule = defineModule({
  path: "admin",
  meta: { title: "Admin Panel" },
  component: AdminDashboard,
  guards: [requireAdmin],
  resources: [...],
});
When a guard returns hidden(), the page shows a 404 error and doesn’t appear in navigation. This prevents unauthorized users from knowing the page exists.

Feature Flag Guard

Hide features that aren’t enabled:
const requireFeature = (featureName: string): Guard => {
  return ({ context }) => {
    if (!context.featureFlags[featureName]) {
      return hidden();
    }
    return pass();
  };
};

const reportsModule = defineModule({
  path: "reports",
  component: ReportsPage,
  guards: [requireFeature("advancedReports")],
  resources: [...],
});

Setting Up Context Data

Guards access data through the context parameter. Here’s how to set it up:
1

Define Context Type

Create a type definition file:
// types/app-shell.d.ts
declare module "@tailor-platform/app-shell" {
  interface AppShellRegister {
    contextData: {
      currentUser: {
        id: string;
        email: string;
        role: "admin" | "staff" | "viewer";
      } | null;
      featureFlags: {
        advancedReports: boolean;
        betaFeatures: boolean;
      };
    };
  }
}
2

Pass Context to AppShell

Provide the context data when initializing AppShell:
import { AppShell, SidebarLayout } from "@tailor-platform/app-shell";

const App = () => {
  const currentUser = useCurrentUser();
  const featureFlags = useFeatureFlags();

  return (
    <AppShell
      title="My App"
      modules={modules}
      contextData={{
        currentUser,
        featureFlags,
      }}
    >
      <SidebarLayout />
    </AppShell>
  );
};
3

Access in Guards

Now your guards are fully type-safe:
const requireAdmin: Guard = ({ context }) => {
  // TypeScript knows about currentUser.role
  if (context.currentUser?.role !== "admin") {
    return hidden();
  }
  return pass();
};

Real-World Example: Admin-Only Page

Here’s a complete working example from the App Shell source code:
import {
  defineModule,
  defineResource,
  type Guard,
  pass,
  hidden,
} from "@tailor-platform/app-shell";
import { Shield } from "lucide-react";

// Step 1: Define the guard
const adminOnlyGuard: Guard = ({ context }) => {
  if (context.role !== "admin") {
    return hidden();
  }
  return pass();
};

// Step 2: Create the admin-only resource
const adminResource = defineResource({
  path: "admin-only",
  meta: {
    title: "Admin Only",
    icon: <Shield />,
  },
  guards: [adminOnlyGuard],
  component: () => (
    <div style={{ padding: "1.5rem" }}>
      <h1>Admin Only Page</h1>
      <p>This page is only visible to administrators.</p>
    </div>
  ),
});

// Step 3: Add to a module
const settingsModule = defineModule({
  path: "settings",
  meta: { title: "Settings" },
  component: SettingsPage,
  resources: [adminResource],
});
Behavior:
  • Admin users see the page in navigation and can access it
  • Non-admin users don’t see it in navigation
  • Direct URL access shows 404 for non-admins

Chaining Multiple Guards

Guards execute in order. Use this for complex access control:
const requireAuthAndAdmin: Guard[] = [
  // First, check authentication
  ({ context }) => {
    if (!context.currentUser) {
      return redirectTo("/login");
    }
    return pass();
  },
  // Then, check admin role
  ({ context }) => {
    if (context.currentUser.role !== "admin") {
      return hidden();
    }
    return pass();
  },
];

const secureModule = defineModule({
  path: "secure",
  guards: requireAuthAndAdmin,
  component: SecurePage,
  resources: [...],
});
Guards stop at the first non-pass result. In this example, unauthenticated users are redirected before the role check runs.

Guards on Resources

Apply guards to individual resources:
const settingsModule = defineModule({
  path: "settings",
  component: SettingsPage,
  resources: [
    // Public resource
    defineResource({
      path: "profile",
      component: ProfilePage,
    }),
    // Admin-only resource
    defineResource({
      path: "billing",
      guards: [requireAdmin],
      component: BillingPage,
    }),
  ],
});

Controlling UI Elements with WithGuard

Use the WithGuard component to conditionally render UI elements based on the same guard logic:
import { WithGuard, DefaultSidebar } from "@tailor-platform/app-shell";

const CustomSidebar = () => (
  <DefaultSidebar>
    <SidebarItem to="/dashboard" />
    
    {/* Only show for admins */}
    <WithGuard guards={[requireAdmin]}>
      <SidebarItem to="/admin" />
    </WithGuard>
    
    {/* Show upgrade prompt for non-admins */}
    <WithGuard 
      guards={[requireAdmin]} 
      fallback={<UpgradePrompt />}
    >
      <SidebarItem to="/premium-features" />
    </WithGuard>
  </DefaultSidebar>
);
WithGuard only supports pass() and hidden(). It does not support redirectTo(). For redirects, use route-level guards.

Async Guards

Guards can be asynchronous for checking permissions from an API:
const checkPermission: Guard = async ({ context }) => {
  const hasAccess = await context.apiClient.checkAccess("admin-panel");
  
  if (!hasAccess) {
    return hidden();
  }
  return pass();
};

const adminModule = defineModule({
  path: "admin",
  guards: [checkPermission],
  component: AdminPage,
  resources: [...],
});
App Shell automatically shows a loading state while async guards execute.

Default Redirects

Redirect module landing pages to a default resource:
import { redirectTo } from "@tailor-platform/app-shell";

const reportsModule = defineModule({
  path: "reports",
  meta: { title: "Reports" },
  // No component - redirect to first report
  guards: [() => redirectTo("/reports/sales")],
  resources: [
    defineResource({
      path: "sales",
      component: SalesReport,
    }),
    defineResource({
      path: "inventory",
      component: InventoryReport,
    }),
  ],
});

Testing Guards

Test guards by simulating different context values:
import { describe, it, expect } from "vitest";
import { requireAdmin } from "./guards";

describe("requireAdmin guard", () => {
  it("allows admin users", () => {
    const result = requireAdmin({
      context: { currentUser: { role: "admin" } },
    });
    expect(result).toEqual({ type: "pass" });
  });

  it("blocks non-admin users", () => {
    const result = requireAdmin({
      context: { currentUser: { role: "staff" } },
    });
    expect(result).toEqual({ type: "hidden" });
  });

  it("blocks unauthenticated users", () => {
    const result = requireAdmin({
      context: { currentUser: null },
    });
    expect(result).toEqual({ type: "hidden" });
  });
});

Best Practices

Security

  • Never trust client-side guards alone - Always verify permissions on your backend
  • Guards only hide UI elements; backend validation is essential
  • Use hidden() for sensitive features to avoid information disclosure

Organization

  • Keep guards in a separate guards.ts file
  • Create reusable guard factories for common patterns
  • Document what each guard protects and why
// guards.ts
export const requireAuth: Guard = ({ context }) => {
  if (!context.currentUser) return redirectTo("/login");
  return pass();
};

export const requireRole = (role: string): Guard => ({
  context,
}) => {
  if (context.currentUser?.role !== role) return hidden();
  return pass();
};

export const requireFeature = (flag: string): Guard => ({
  context,
}) => {
  if (!context.featureFlags[flag]) return hidden();
  return pass();
};

Performance

  • Keep guards fast - they run on every navigation
  • Cache permission checks when possible
  • Use async guards sparingly (they add loading time)

Build docs developers (and LLMs) love