Skip to content

6. Admin Panel

In the previous step you added CRUD actions with @cfast/actions and permission-aware UI with @cfast/ui. Each route was hand-coded: a loader that queries the database, a component that renders the data, and actions wired to buttons.

That works well for public-facing pages where the UI is custom. But every app also needs an admin panel, and the admin panel is always the same structure: a table list, a detail view, a create form, an edit form, user management. The only thing that changes is the schema.

@cfast/admin automates that. It reads your Drizzle schema, generates configuration for @cfast/ui components, and gives you a complete admin panel in one route.

By the end of this step, your blog will have:

  • An /admin route with a full CRUD panel for posts, users, and roles
  • A dashboard with record counts and recent activity
  • User management with role assignment
  • All of it respecting your existing permission system
Terminal window
pnpm add @cfast/admin

@cfast/admin is a thin layer that does two things:

  1. Schema introspection --- reads your Drizzle tables to generate configuration: which columns to show, which fields to use in forms, which actions to offer
  2. Configuration to UI --- passes that configuration to @cfast/ui components for rendering

The admin panel is not a separate app. It is a React Router route that uses the same database, same permissions, and same auth as the rest of your application. An editor who opens the admin sees editor-level data. The permission system is not bypassed.

The server module provides the auth adapter and database factory that @cfast/admin needs:

app/admin.server.ts
import { createAdminLoader, createAdminAction, introspectSchema } from "@cfast/admin";
import type { AdminAuthConfig, AdminUser } from "@cfast/admin";
import { createDb } from "@cfast/db";
import type { DbConfig } from "@cfast/db";
import { requireAuthContext, hasRole } from "~/auth.helpers.server";
import { initAuth } from "~/auth.setup.server";
import { env } from "~/env";
import * as schema from "~/db/schema";
const auth: AdminAuthConfig = {
async requireUser(request: Request) {
const ctx = await requireAuthContext(request);
const user: AdminUser = {
id: ctx.user.id,
email: ctx.user.email,
name: ctx.user.name,
avatarUrl: ctx.user.avatarUrl,
roles: ctx.user.roles,
};
return { user, grants: ctx.grants };
},
hasRole(user: AdminUser, role: string) {
return hasRole(
user as Parameters<typeof hasRole>[0],
role as Parameters<typeof hasRole>[1],
);
},
async getRoles(userId: string) {
const e = env.get();
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
return authInstance.getRoles(userId);
},
async setRole(userId: string, role: string) {
const e = env.get();
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
await authInstance.setRole(userId, role);
},
async removeRole(userId: string, role: string) {
const e = env.get();
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
await authInstance.removeRole(userId, role);
},
async setRoles(userId: string, roles: string[]) {
const e = env.get();
const authInstance = initAuth({ d1: e.DB, appUrl: e.APP_URL });
await authInstance.setRoles(userId, roles);
},
};
function createDbForAdmin(grants: unknown[], user: { id: string } | null) {
const e = env.get();
return createDb({
d1: e.DB,
schema: schema as unknown as DbConfig["schema"],
grants: grants as Parameters<typeof createDb>[0]["grants"],
user,
cache: false,
});
}
const adminConfig = {
db: createDbForAdmin,
auth,
schema: {
users: schema.users,
posts: schema.posts,
roles: schema.roles,
},
users: {
assignableRoles: ["admin", "editor", "reader"],
},
dashboard: {
widgets: [
{ type: "count" as const, table: "users", label: "Total Users" },
{ type: "count" as const, table: "posts", label: "Total Posts" },
{ type: "recent" as const, table: "posts", label: "Recent Posts", limit: 5 },
],
},
requiredRole: "admin",
};
const tableMetas = introspectSchema(adminConfig.schema);
export const adminLoader = createAdminLoader(adminConfig, tableMetas);
export const adminAction = createAdminAction(adminConfig, tableMetas);

There is a lot of code in the auth adapter, but most of it is bridging your existing auth helpers to the interface @cfast/admin expects. The key pieces are:

  • requireUser --- authenticates the request and returns user + grants
  • hasRole / getRoles / setRole --- role management operations
  • db --- a factory that creates a permission-checked database client per request
  • schema --- which tables to include in the admin panel (auth-internal tables like sessions and verifications are excluded)
  • requiredRole --- only users with this role can access the admin

The route file is minimal. It imports the server-side loader and action, and creates a client-safe component from schema introspection:

app/routes/admin.tsx
import type { LoaderFunctionArgs, ActionFunctionArgs } from "react-router";
import { adminLoader, adminAction } from "~/admin.server";
import { createAdminComponent, introspectSchema } from "@cfast/admin";
import * as schema from "~/db/schema";
const tableMetas = introspectSchema({
users: schema.users,
posts: schema.posts,
roles: schema.roles,
});
const AdminComponent = createAdminComponent(tableMetas);
export async function loader({ request }: LoaderFunctionArgs) {
return adminLoader(request);
}
export async function action({ request }: ActionFunctionArgs) {
return adminAction(request);
}
export default AdminComponent;

Notice the server/client split:

  • introspectSchema runs at build time to extract column metadata --- this is safe for the client bundle because it only contains table structure, not data
  • adminLoader and adminAction are server-only --- they access the database and auth system
  • AdminComponent renders using @cfast/ui components, driven by the introspected metadata

Add the admin route to your routes configuration:

app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route("login", "routes/login.tsx"),
route("api/auth/*", "routes/auth.$.tsx"),
route("posts", "routes/posts/index.tsx"),
route("posts/new", "routes/posts/new.tsx"),
route("posts/:id", "routes/posts/$id.tsx"),
route("admin", "routes/admin.tsx"),
] satisfies RouteConfig;

With that one route, you get:

FeatureHow it works
DashboardRecord counts and recent items from the widget config
Table list viewsSortable, searchable tables for posts, users, roles
Detail viewsRead-only detail pages for each record
Create/edit formsAuto-generated from Drizzle schema via @cfast/forms
User managementRole assignment panel, user search
Permission-awareAll CRUD operations go through @cfast/db --- the admin does not bypass your permission system
The auto-generated admin panel showing dashboard with record counts

You can control which columns appear in list views and how tables are labeled:

const adminConfig = {
// ...
tables: {
posts: {
label: "Blog Posts",
listColumns: ["title", "published", "createdAt"],
searchable: ["title", "content"],
defaultSort: { column: "createdAt", direction: "desc" },
fields: {
content: { component: RichTextEditor },
},
},
},
};

Tables not listed in the tables config use sensible defaults derived from the schema.

React Router on Workers bundles server and client code separately. Server-only imports (like your database client, auth instance, and env bindings) must not end up in the client bundle.

The split works like this:

  • admin.server.ts imports createDb, requireAuthContext, env --- all server-only
  • routes/admin.tsx imports only introspectSchema, createAdminComponent (schema metadata + React components), and the server functions via .server convention

This pattern is important. If you import createDb directly in your route file, the Workers bundler will try to include it in the client, causing build failures.

Terminal window
pnpm dev
  1. Sign in as an admin
  2. Navigate to /admin
  3. You should see a dashboard with user and post counts
  4. Click on “Posts” in the sidebar to see the list view
  5. Click on a post to see the detail view
  6. Try creating and editing records
  7. Navigate to the Users section and assign roles

Your blog now has a full admin panel. In the next step, you will add file storage for post cover images using Cloudflare R2 and @cfast/storage.