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.

Both live in the route group app/dashboard/(admin)/, so their URLs stay /dashboard/users and /dashboard/files. What the group buys you is the shared layout, which is the real gate:

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

That runs on the server before any page in the group renders. Each page (users/page.tsx, files/page.tsx) also repeats the role !== "admin" check on its own, so the gate still holds if you ever move a page out of the group. The sidebar hides the SYSTEM nav group from non-admins, but treat that as cosmetic rather than a second lock. Typing the URL still lands on the layout check.

The role field comes from the Better Auth admin plugin, set up in lib/auth.ts. The plugin is passed roles: defaultRoles and a custom 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.

User management

The Users page (/dashboard/users) lists every account and gives each row an actions menu.

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, which hit the catch-all /api/auth route handler. There's no custom API route for user management. Each handler checks the call's error and surfaces it as a toast, so a server-side rejection never reads as success. You get no menu at all on your own row: when isCurrentUser is true (the row's email matches yours), the actions cell renders null. Deleting a user also triggers the cleanup in databaseHooks.user.delete (API keys, subscription and verification rows, avatar), described under Authentication.

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, copies it to your clipboard (or shows it in a toast if the clipboard is blocked), then calls revokeUserSessions so sessions opened with the old password close. If that revoke call fails, the reset still stands and a warning toast says the sessions could not be revoked
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. The hooks.before middleware in lib/auth.ts rejects it while the user has an active, trialing or past-due subscription

Creating a user + setup email

Create is the only capability that goes through a custom Server Action instead of authClient.admin.*. 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. The action imports the inferred type but never re-parses the schema on the server, so add that yourself if you extend the form.
  2. The dialog generates a 14-char temporary password in the browser, then calls the createUser Server Action (app/actions/user.ts).
  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 it's off, no email goes out. Instead the dialog copies the temporary password to your clipboard so you can hand it over yourself.

File management

The Files page (/dashboard/files) browses your Cloudflare R2 bucket.

The UI lives in app/dashboard/(admin)/files/files-content.tsx. Admins can walk folders, create them, upload into the current one, filter the visible list by name (spaces in the filter match hyphens in filenames), open or copy a file's public URL, and delete a file or a whole folder. The two view buttons promise more than they deliver. There's no image grid. Both render the same table, and the toggle swaps the file icon on image rows for a thumbnail. The page stores that preference per folder in localStorage. Uploads go straight to R2 through a presigned URL, three files at a time.

The page calls four Server Actions in app/actions/files.ts: getFiles, getFileUploadUrl, createFolderAction, and deleteFile. Every one of them calls requireAdmin(), so the route gate isn't the only thing protecting the bucket. 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