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
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
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.
1// Prompt to type into Replit Agent:2// Build a Node.js Express admin panel app with:3// - Built-in PostgreSQL using Drizzle ORM4// - 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 timestamp9// * 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 middleware17// - React frontend with sidebar nav, data table with sorting and pagination, modal edit forms18// - Role badges: admin=red, editor=yellow, viewer=gray19// - Health check at GET /api/healthPro 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.
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.
1import { pgTable, serial, text, integer, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core';23export 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});1314export 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});2324export 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});3536export 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.
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.
1import { db } from '../db.js';2import { users } from '../../shared/schema.js';3import { eq } from 'drizzle-orm';45// Retry wrapper for PostgreSQL idle sleep reconnections6async 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}1920export 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' });2425 try {26 const [user] = await withRetry(() =>27 db.select().from(users).where(eq(users.userId, replitUserId)).limit(1)28 );2930 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 }3435 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.
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.
1import { db } from '../db.js';2import { auditLogs, managedEntities } from '../../shared/schema.js';3import { eq } from 'drizzle-orm';45// 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;910 // 1. Read old value BEFORE mutation11 const [old] = await db.select().from(managedEntities).where(eq(managedEntities.id, Number(id)));12 if (!old) return res.status(404).json({ error: 'Not found' });1314 // 2. Apply mutation15 const [updated] = await db16 .update(managedEntities)17 .set({ title, status, data, updatedAt: new Date() })18 .where(eq(managedEntities.id, Number(id)))19 .returning();2021 // 3. Write audit log entry in the same logical operation22 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 });3132 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.
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.
1// GET /api/admin/users — list all users with pagination2app.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});89// 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 }1617 const [old] = await db.select().from(users).where(eq(users.id, Number(id)));18 const [updated] = await db19 .update(users)20 .set({ role })21 .where(eq(users.id, Number(id)))22 .returning();2324 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 });3334 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.
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.
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 manually8//9// Deployment Secrets (set separately from workspace secrets):10// Any external API keys must be re-added under Deployment → Secrets11// because workspace Secrets do NOT carry over to deployments automatically12//13// After deployment:14// 1. Share the deployed URL with your team members15// 2. Each person logs in with their Replit account16// 3. Their user row is created automatically on first login17// 4. Go to Drizzle Studio on the deployed app (or promote via admin panel)18// to set their rolePro 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
1import { db } from '../db.js';2import { users } from '../../shared/schema.js';3import { eq } from 'drizzle-orm';45async 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}1819export 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');2324 if (!replitUserId) {25 return res.status(401).json({ error: 'Not authenticated. Please log in.' });26 }2728 try {29 let [user] = await withRetry(() =>30 db.select().from(users).where(eq(users.userId, replitUserId)).limit(1)31 );3233 // Auto-create user row on first admin visit34 if (!user) {35 [user] = await db.insert(users).values({36 userId: replitUserId,37 displayName: replitUserName || replitUserId,38 role: 'viewer',39 }).returning();40 }4142 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 }4748 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation