Skip to main content
RapidDev - Software Development Agency

How to Build a Admin Panel with Replit

Build a full-featured admin panel in Replit in 1-2 hours using Express, PostgreSQL, and Replit Auth. You'll get a role-based CRUD interface with user management, an audit log, and a data table — all from one browser-based IDE with no local setup required.

What you'll build

  • Express REST API with role-based access middleware (admin/editor/viewer)
  • PostgreSQL schema via Drizzle ORM with users, managed_entities, audit_logs, and app_settings tables
  • Replit Auth integration for zero-config login — no Passport.js setup needed
  • Audit log that captures old and new values on every mutation in the same database transaction
  • React admin UI with sidebar navigation, sortable data table, modal edit forms, and role badges
  • App settings CRUD for key-value configuration managed from the admin panel
  • PostgreSQL connection retry wrapper to handle the 5-minute idle sleep on Replit's built-in DB
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate13 min read1-2 hoursReplit Core or higherApril 2026RapidDev Engineering Team
TL;DR

Build a full-featured admin panel in Replit in 1-2 hours using Express, PostgreSQL, and Replit Auth. You'll get a role-based CRUD interface with user management, an audit log, and a data table — all from one browser-based IDE with no local setup required.

What you're building

Every app that generates data — users, orders, content, submissions — eventually needs a back-office interface to manage it without writing SQL by hand. An admin panel is that interface: a secure, role-aware dashboard where you and your team can view, edit, and delete records, manage user roles, and audit every change made.

Replit Agent makes the initial build fast. One prompt generates an Express app with Drizzle ORM connecting to the built-in PostgreSQL database, Replit Auth wired up for login, and a React frontend with a sidebar layout. Because every team member already has a Replit account, there's no need for custom auth — Replit Auth provides the user identity your middleware uses to enforce roles.

The architecture is straightforward: Express routes under /api/admin/* are protected by a requireRole() middleware that checks the current user's role from the database. A React frontend built by Agent handles the UI, with a data table per entity type, modal forms for editing, and a role badge system. The audit log captures mutations in the same PostgreSQL transaction as the write itself — so no action ever goes unlogged.

Final result

A production-ready admin panel with role-based route protection, full CRUD on managed entities and users, a complete audit log with before/after values, and app settings management — all running on Replit's built-in PostgreSQL.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Replit AuthAuth
ReactFrontend

Prerequisites

  • A Replit Core account (Replit Auth and built-in PostgreSQL are included)
  • Basic understanding of what a REST API and database are (no coding experience needed)
  • Identify at least one resource type in your app that you want to manage (users, orders, posts, etc.)
  • Any external API keys your admin will consume stored in Replit Secrets (lock icon in sidebar)

Build steps

1

Prompt Replit Agent to scaffold the project

Open a new Replit and use the Agent tab to generate the full Express + PostgreSQL project structure in one shot. Be specific about the schema and roles so Agent generates the right foundation.

prompt.txt
1// Prompt to type into Replit Agent:
2// Build a Node.js Express admin panel app with:
3// - Built-in PostgreSQL using Drizzle ORM
4// - Replit Auth for login (use @replit/replit-auth-express)
5// - Drizzle schema in shared/schema.ts with tables:
6// * users: id serial pk, user_id text not null unique, email text, display_name text,
7// role text not null default 'viewer' (admin/editor/viewer), avatar_url text,
8// created_at timestamp default now(), last_login_at timestamp
9// * managed_entities: id serial, type text not null, title text not null,
10// status text default 'active', data jsonb, created_by text, updated_at timestamp default now()
11// * audit_logs: id serial, user_id text references users(user_id), action text not null,
12// entity_type text, entity_id integer, old_value jsonb, new_value jsonb,
13// ip_address text, created_at timestamp default now()
14// * app_settings: id serial, key text unique not null, value text,
15// updated_by text, updated_at timestamp default now()
16// - Express routes under /api/admin/* protected by requireRole middleware
17// - React frontend with sidebar nav, data table with sorting and pagination, modal edit forms
18// - Role badges: admin=red, editor=yellow, viewer=gray
19// - Health check at GET /api/health

Pro tip: If Agent generates a generic Express app without the Drizzle schema, click 'Edit' in the Agent chat and add 'Use the exact schema I described — do not simplify it'. Agent tends to reduce complexity unless you're explicit.

Expected result: Replit creates the project with server/index.js, shared/schema.ts, client/src/, and runs npm install automatically. You should see the Express server start in the Shell panel.

2

Define the Drizzle schema and run migrations

Open shared/schema.ts and verify the tables match the brief. Then use Drizzle Studio (built into Replit) to confirm the tables were created in PostgreSQL.

shared/schema.ts
1import { pgTable, serial, text, integer, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core';
2
3export const users = pgTable('users', {
4 id: serial('id').primaryKey(),
5 userId: text('user_id').notNull().unique(),
6 email: text('email'),
7 displayName: text('display_name'),
8 role: text('role').notNull().default('viewer'),
9 avatarUrl: text('avatar_url'),
10 createdAt: timestamp('created_at').defaultNow(),
11 lastLoginAt: timestamp('last_login_at'),
12});
13
14export const managedEntities = pgTable('managed_entities', {
15 id: serial('id').primaryKey(),
16 type: text('type').notNull(),
17 title: text('title').notNull(),
18 status: text('status').default('active'),
19 data: jsonb('data'),
20 createdBy: text('created_by').notNull(),
21 updatedAt: timestamp('updated_at').defaultNow(),
22});
23
24export const auditLogs = pgTable('audit_logs', {
25 id: serial('id').primaryKey(),
26 userId: text('user_id'),
27 action: text('action').notNull(),
28 entityType: text('entity_type'),
29 entityId: integer('entity_id'),
30 oldValue: jsonb('old_value'),
31 newValue: jsonb('new_value'),
32 ipAddress: text('ip_address'),
33 createdAt: timestamp('created_at').defaultNow(),
34});
35
36export const appSettings = pgTable('app_settings', {
37 id: serial('id').primaryKey(),
38 key: text('key').notNull().unique(),
39 value: text('value'),
40 updatedBy: text('updated_by'),
41 updatedAt: timestamp('updated_at').defaultNow(),
42});

Pro tip: After saving schema.ts, open the Shell panel and run: npx drizzle-kit push. This pushes the schema to PostgreSQL without generating migration files — perfect for Replit development.

Expected result: Drizzle Studio (click the database icon in the left sidebar) shows all four tables with the correct columns.

3

Build the requireRole middleware and admin routes

This middleware is the security backbone. It reads the Replit Auth user ID from the session, looks up the user's role in the database, and blocks access if the role isn't sufficient.

server/middleware/requireRole.js
1import { db } from '../db.js';
2import { users } from '../../shared/schema.js';
3import { eq } from 'drizzle-orm';
4
5// Retry wrapper for PostgreSQL idle sleep reconnections
6async function withRetry(fn, retries = 3) {
7 for (let i = 0; i < retries; i++) {
8 try {
9 return await fn();
10 } catch (err) {
11 if (err.code === 'ECONNRESET' && i < retries - 1) {
12 await new Promise(r => setTimeout(r, 500));
13 continue;
14 }
15 throw err;
16 }
17 }
18}
19
20export function requireRole(...allowedRoles) {
21 return async (req, res, next) => {
22 const replitUserId = req.get('X-Replit-User-Id');
23 if (!replitUserId) return res.status(401).json({ error: 'Not authenticated' });
24
25 try {
26 const [user] = await withRetry(() =>
27 db.select().from(users).where(eq(users.userId, replitUserId)).limit(1)
28 );
29
30 if (!user) return res.status(403).json({ error: 'User not found in admin panel' });
31 if (!allowedRoles.includes(user.role)) {
32 return res.status(403).json({ error: 'Insufficient permissions' });
33 }
34
35 req.adminUser = user;
36 next();
37 } catch (err) {
38 res.status(500).json({ error: 'Auth check failed' });
39 }
40 };
41}

Pro tip: The X-Replit-User-Id header is injected by Replit Auth automatically when the user is logged in. No token parsing needed — Replit handles the session.

Expected result: Importing requireRole and adding it to a route like app.get('/api/admin/users', requireRole('admin', 'editor'), handler) returns 401 for unauthenticated requests and 403 for wrong roles.

4

Add the audit log middleware to capture mutations

Every PUT, PATCH, and DELETE admin route should write an audit_log entry. The cleanest approach is an Express afterResponse pattern — read the old value before the mutation, then log old + new in the same route handler.

server/routes/entities.js
1import { db } from '../db.js';
2import { auditLogs, managedEntities } from '../../shared/schema.js';
3import { eq } from 'drizzle-orm';
4
5// In your PUT /api/admin/entities/:id route:
6export async function updateEntity(req, res) {
7 const { id } = req.params;
8 const { title, status, data } = req.body;
9
10 // 1. Read old value BEFORE mutation
11 const [old] = await db.select().from(managedEntities).where(eq(managedEntities.id, Number(id)));
12 if (!old) return res.status(404).json({ error: 'Not found' });
13
14 // 2. Apply mutation
15 const [updated] = await db
16 .update(managedEntities)
17 .set({ title, status, data, updatedAt: new Date() })
18 .where(eq(managedEntities.id, Number(id)))
19 .returning();
20
21 // 3. Write audit log entry in the same logical operation
22 await db.insert(auditLogs).values({
23 userId: req.adminUser.userId,
24 action: 'update',
25 entityType: 'managed_entity',
26 entityId: Number(id),
27 oldValue: old,
28 newValue: updated,
29 ipAddress: req.ip,
30 });
31
32 res.json(updated);
33}

Pro tip: For DELETE routes, do the same pattern but set newValue to null. This gives you a full before/after record for every change — invaluable when something goes wrong.

Expected result: After making a PUT request to update an entity, a new row appears in the audit_logs table visible in Drizzle Studio with the old and new values as JSON.

5

Add user management routes and first admin bootstrap

Add the user management endpoints and manually set your own account to admin in Drizzle Studio. Once you're admin, you can promote other users through the panel.

server/routes/admin-users.js
1// GET /api/admin/users — list all users with pagination
2app.get('/api/admin/users', requireRole('admin'), async (req, res) => {
3 const limit = parseInt(req.query.limit) || 20;
4 const offset = parseInt(req.query.offset) || 0;
5 const allUsers = await db.select().from(users).limit(limit).offset(offset);
6 res.json(allUsers);
7});
8
9// PATCH /api/admin/users/:id/role — change a user's role (admin only)
10app.patch('/api/admin/users/:id/role', requireRole('admin'), async (req, res) => {
11 const { id } = req.params;
12 const { role } = req.body;
13 if (!['admin', 'editor', 'viewer'].includes(role)) {
14 return res.status(400).json({ error: 'Invalid role' });
15 }
16
17 const [old] = await db.select().from(users).where(eq(users.id, Number(id)));
18 const [updated] = await db
19 .update(users)
20 .set({ role })
21 .where(eq(users.id, Number(id)))
22 .returning();
23
24 await db.insert(auditLogs).values({
25 userId: req.adminUser.userId,
26 action: 'role_change',
27 entityType: 'user',
28 entityId: Number(id),
29 oldValue: { role: old.role },
30 newValue: { role: updated.role },
31 ipAddress: req.ip,
32 });
33
34 res.json(updated);
35});

Pro tip: To bootstrap your first admin: open Drizzle Studio, find your row in the users table (your user_id appears in the X-Replit-User-Id header when logged in), and manually set role to 'admin'. After that, use the panel to manage everyone else.

Expected result: GET /api/admin/users returns a paginated list of all users. PATCH /api/admin/users/1/role with { "role": "editor" } updates the role and writes an audit entry.

6

Deploy on Autoscale and test with your team

Admin panels have sporadic traffic — a few people, a few times a day. Autoscale deployment is ideal: it scales to zero when idle (keeping costs minimal) and the 10-30 second cold start is acceptable for an internal tool.

prompt.txt
1// Before deploying, verify these settings in .replit:
2// [deployment]
3// run = ["node", "server/index.js"]
4// deploymentTarget = "autoscale"
5//
6// In Replit Secrets panel (lock icon), add any external API keys your admin consumes:
7// DATABASE_URL is auto-provided by Replit — do NOT set it manually
8//
9// Deployment Secrets (set separately from workspace secrets):
10// Any external API keys must be re-added under Deployment → Secrets
11// because workspace Secrets do NOT carry over to deployments automatically
12//
13// After deployment:
14// 1. Share the deployed URL with your team members
15// 2. Each person logs in with their Replit account
16// 3. Their user row is created automatically on first login
17// 4. Go to Drizzle Studio on the deployed app (or promote via admin panel)
18// to set their role

Pro tip: After deploying, test role enforcement by logging in with a second Replit account in a private browser window. Try to access admin-only routes — you should see a 403 response, confirming the middleware is working.

Expected result: The deployed URL loads the admin panel. Team members can log in with Replit Auth. Role assignments you make in the user management section take effect immediately on the next request.

Complete code

server/middleware/requireRole.js
1import { db } from '../db.js';
2import { users } from '../../shared/schema.js';
3import { eq } from 'drizzle-orm';
4
5async function withRetry(fn, retries = 3) {
6 for (let i = 0; i < retries; i++) {
7 try {
8 return await fn();
9 } catch (err) {
10 if ((err.code === 'ECONNRESET' || err.code === '57P01') && i < retries - 1) {
11 await new Promise(r => setTimeout(r, 500 * (i + 1)));
12 continue;
13 }
14 throw err;
15 }
16 }
17}
18
19export function requireRole(...allowedRoles) {
20 return async (req, res, next) => {
21 const replitUserId = req.get('X-Replit-User-Id');
22 const replitUserName = req.get('X-Replit-User-Name');
23
24 if (!replitUserId) {
25 return res.status(401).json({ error: 'Not authenticated. Please log in.' });
26 }
27
28 try {
29 let [user] = await withRetry(() =>
30 db.select().from(users).where(eq(users.userId, replitUserId)).limit(1)
31 );
32
33 // Auto-create user row on first admin visit
34 if (!user) {
35 [user] = await db.insert(users).values({
36 userId: replitUserId,
37 displayName: replitUserName || replitUserId,
38 role: 'viewer',
39 }).returning();
40 }
41
42 if (!allowedRoles.includes(user.role)) {
43 return res.status(403).json({
44 error: `This action requires one of: ${allowedRoles.join(', ')}. Your role: ${user.role}`,
45 });
46 }
47
48 req.adminUser = user;
49 next();
50 } catch (err) {
51 console.error('requireRole error:', err);
52 res.status(500).json({ error: 'Authentication check failed' });
53 }
54 };
55}

Customization ideas

Soft delete with recycle bin

Add a deleted_at column to managed_entities instead of hard-deleting rows. The main data table hides deleted rows by default. Add a 'Recycle Bin' section that shows soft-deleted records with a Restore button.

CSV export for any data table

Add an Export button to the data table that hits a GET /api/admin/entities/export endpoint. The route streams rows as CSV using Node.js streams, with proper Content-Disposition headers for browser download.

IP-based access restriction

Add an allowed_ips JSONB column to app_settings. The requireRole middleware checks req.ip against the allowlist and blocks access from unrecognized addresses — useful for corporate IP enforcement.

Full-text search across entities

Add a PostgreSQL GIN index on managed_entities using to_tsvector('english', title || ' ' || coalesce(data::text, '')). Add a GET /api/admin/search?q= endpoint that queries this index and returns results grouped by entity type.

Common pitfalls

Pitfall: Setting DATABASE_URL manually in Replit Secrets

How to avoid: Never set DATABASE_URL in the Secrets panel. Drizzle and pg will find it automatically. If your db connection is failing, check the Shell output for the actual error — it's usually a schema issue, not a missing env var.

Pitfall: Forgetting to re-add Secrets in Deployment Secrets

How to avoid: After deploying, go to Deployments → your deployment → Secrets and add all the same keys you set in the workspace Secrets panel. DATABASE_URL is the exception — it's auto-injected.

Pitfall: Not wrapping Drizzle calls in a retry helper

How to avoid: Wrap every Drizzle query with the withRetry() function shown in Step 3. The retry catches ECONNRESET, waits 500ms, and tries again — the reconnection happens transparently.

Pitfall: Checking role only in the UI, not in the API

How to avoid: Always put requireRole() on the Express route, not just on the React component. UI checks are for UX — API middleware is the actual security.

Best practices

  • Put requireRole() middleware on every /api/admin/* route — never rely on frontend-only role checks for security.
  • Write audit log entries in the same route handler as the mutation, before sending the response, so every write is always logged.
  • Use withRetry() on all Drizzle queries to handle Replit PostgreSQL's 5-minute idle sleep reconnection gracefully.
  • Store all external API keys in Replit Secrets (lock icon) — never hardcode them in route files.
  • Bootstrap your first admin by manually editing your role in Drizzle Studio, then use the panel to manage all other users.
  • Deploy on Autoscale for admin panels — sporadic internal traffic makes scale-to-zero ideal, and 10-30 second cold starts are fine for internal tools.
  • Add pagination (limit/offset) to all list routes from the start — the users and audit_logs tables grow quickly and unbounded queries will slow down.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building an Express + PostgreSQL admin panel with Drizzle ORM on Replit. I have a requireRole middleware that checks the user's role from the database on every request. Help me design an audit log system where every PUT and DELETE route automatically captures the old value before mutation and the new value after, and inserts both into an audit_logs table in the same operation — without duplicating this logic in every route handler.

Build Prompt

Add a real-time activity feed to the admin panel. Build a GET /api/admin/activity/stream SSE endpoint that holds the connection open and emits a new event every time a row is inserted into audit_logs. On the React side, use EventSource to connect and append new activity entries to the top of an activity list in the sidebar without page refresh.

Frequently asked questions

Do I need a paid Replit plan for Replit Auth?

Replit Auth is available on Replit Core and higher plans. The free tier doesn't include Auth. Replit Core ($25/month) also gives you the built-in PostgreSQL with 10GB storage, which is what this guide uses.

How do I set the first admin user if everyone starts as viewer?

Open Drizzle Studio (click the database icon in the Replit sidebar), navigate to the users table, find your own row (your user_id is shown in the X-Replit-User-Id header), and manually set the role column to 'admin'. After that, use the User Management section of the admin panel to assign roles to other users.

Can I use this admin panel for a multi-tenant SaaS?

Yes. Add an org_id column to both the users table and managed_entities table. Update requireRole to also check that the requesting user's org_id matches the resource's org_id. Every query then filters by both role and org_id, keeping each tenant's data isolated.

Why does the first request of the day fail with a connection error?

Replit's built-in PostgreSQL goes to sleep after 5 minutes of inactivity. The fix is the withRetry() wrapper shown in Step 3 — it catches the ECONNRESET error, waits 500ms for the DB to wake up, and retries. The second attempt always succeeds.

Should I deploy on Autoscale or Reserved VM?

Autoscale for most admin panels. Admin tools are used by a small team a few times a day — the 10-30 second cold start on first daily visit is acceptable for an internal tool, and you pay nearly nothing when the app is idle. Only switch to Reserved VM if you need webhooks or real-time features.

Can I audit reads, not just writes?

Yes. Add a request logger middleware that inserts into audit_logs for GET requests on sensitive routes. Filter by action='read' in the audit log viewer. For high-traffic endpoints, log only reads on sensitive resources (like user PII) rather than every GET to avoid bloating the table.

Can RapidDev help me build a custom admin panel for my app?

Yes. RapidDev has built 600+ apps including admin panels with custom resource management, advanced role hierarchies, and integrated audit reporting. Reach out for a free consultation to discuss your specific requirements.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.