Next Starter Logo

Admin Dashboard

How Next Starter builds the admin dashboard with role-based access control: a role-gated route group plus admin-only user management and R2 file browsing.

How it works

The admin area gives admins two extra dashboard pages: Users and Files. Everyone else never sees them.

These pages live in a route group at app/dashboard/(admin)/. A route group is a folder wrapped in parentheses. The parentheses keep the folder out of the URL, so /dashboard/users and /dashboard/files sit at the same level as the rest of the dashboard. What the folder does add is a shared layout that runs an admin check.

Two things keep the pages admin-only:

  1. Route-group layout. app/dashboard/(admin)/layout.tsx runs on the server before any page in the group. No session redirects to /auth/sign-in. A signed-in user whose role is not admin redirects to /dashboard.
  2. Sidebar visibility. The SYSTEM nav group only renders for admins, so non-admins never see the links.

Each page (users/page.tsx, files/page.tsx) also repeats the role !== "admin" check on its own. This is on purpose. The gate still holds if you ever move a page out of the group.

// app/dashboard/(admin)/layout.tsx
const session = await getSession();
if (!session) redirect("/auth/sign-in");
if (session.user.role !== "admin") redirect("/dashboard");

The role field comes from the Better Auth admin plugin, set up in lib/auth.ts. The plugin only overrides bannedUserMessage; everything else uses defaults (default role user, admin role admin). It adds the role, banned, banReason, and banExpires columns to the user table, and exposes the authClient.admin.* and auth.api.* methods used on these pages.

The SYSTEM sidebar group

The sidebar (components/dashboard/sidebar.tsx) holds one flat navigationItems array. Each item has a group: "main", "system", or "secondary". For non-admins, the system group is replaced with an empty list:

const isAdmin = session?.user?.role === "admin";
const navGroups = {
  main: byGroup("main"),
  system: isAdmin ? byGroup("system") : [], // hidden for non-admins
  secondary: byGroup("secondary"),
};

The group only renders when it has items (navGroups.system.length > 0), under the label SYSTEM. Files and Users ship in this group.

User management

The Users page (/dashboard/users) lists every account and lets admins act on each one.

The page (app/dashboard/(admin)/users/page.tsx) loads the list on the server with auth.api.listUsers. It pages 50 users at a time, searches by email, and sorts by updatedAt newest-first. It then renders components/users/users-table.tsx. The table columns and the per-row actions menu live in components/users/columns.tsx.

Every action except create runs from the browser through Better Auth's authClient.admin.* methods. There are no custom API routes. An admin can't act on their own row: when isCurrentUser is true (the row's email matches yours), the actions cell renders null, so the menu is hidden.

Capabilities reference

ActionMethod / sourceWhat it does
Toggle roleauthClient.admin.setRoleSwitches the user between user and admin
Ban userauthClient.admin.banUserBans with reason "Administrative action", expiring in 7 days (banExpiresIn)
Unban userauthClient.admin.unbanUserLifts the ban
Reset passwordauthClient.admin.setUserPasswordSets a new 14-char password and copies it to your clipboard
Revoke sessionsauthClient.admin.revokeUserSessionsSigns the user out on every device
ImpersonateauthClient.admin.impersonateUserStarts a session as that user, so you see the app as they do
Create usercreateUser Server ActionSee below
Delete userauthClient.admin.removeUserPermanent; the confirm dialog requires typing DELETE

Creating a user + setup email

Create is the only capability that runs on the server. It has to, because it makes a password and may send an email. The flow:

  1. CreateUserDialog (components/users/dialogs/create-user-dialog.tsx) collects name, email, role, and a Send welcome email switch. The switch maps to the sendEmail field, validated by createUserSchema in lib/validations/user.ts.
  2. The dialog generates a 14-char temporary password in the browser, then calls the createUser Server Action (app/actions/user.ts). A Server Action is a function that runs on the server but is called like a normal function from the client.
  3. The action runs requireAdmin() to block non-admins, then calls auth.api.createUser with data: { emailVerified: true }, so the account is created already verified.
  4. If the switch is on, the action emails the login details using getAccountSetupEmail (lib/email/index.tsx). If off, no email goes out. Instead the dialog copies the temporary password to your clipboard so you can hand it over yourself.
// app/actions/user.ts (gist)
await requireAdmin();
await auth.api.createUser({ body: { name, email, password, role, data: { emailVerified: true } }, headers });
if (sendWelcomeEmail) {
  await sendEmail({ to: email, subject: "Your account has been set up",
    ...(await getAccountSetupEmail(name, email, password)) });
}

File management

The Files page (/dashboard/files) is a browser for the files in your Cloudflare R2 bucket. R2 is Cloudflare's S3-compatible object storage.

The UI lives in app/dashboard/(admin)/files/files-content.tsx. Admins can browse folders, upload into the current folder, create folders, open or copy a file's public URL, delete files or folders, and switch between a list view and an image-grid view. The storage code that talks to R2 lives in lib/server/s3.ts. See File Uploads.

What you can change

Add an admin page. Create app/dashboard/(admin)/your-page/page.tsx. The group layout gates it for you, and breadcrumbs come from the URL. Add a sidebar link by appending to navigationItems in components/dashboard/sidebar.tsx with group: "system". For safety, also copy the in-page role !== "admin" redirect from users/page.tsx.

Add a row action. Open components/users/columns.tsx. Add a handler that calls the authClient.admin.* method you want, plus a matching DropdownMenuItem in the menu. For work that must run on the server (like create), add a Server Action in app/actions/user.ts and guard it with requireAdmin().

Tune admin behavior. Change the banned message in the admin({ ... }) block in lib/auth.ts. To change the ban length or reason, edit the banUser call in columns.tsx (banExpiresIn is in seconds; it ships as 60 * 60 * 24 * 7, i.e. 7 days).

Add a regular (non-admin) page. Put it directly under app/dashboard/, outside (admin). The root dashboard layout's session check protects it. Tag its nav item group: "main" or "secondary".

On this page