# How to Build an Admin Panel with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Core or higher
- Last updated: April 2026

## 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.

## Before you start

- 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)

## Step-by-step guide

### 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 to type into Replit Agent:
// Build a Node.js Express admin panel app with:
// - Built-in PostgreSQL using Drizzle ORM
// - Replit Auth for login (use @replit/replit-auth-express)
// - Drizzle schema in shared/schema.ts with tables:
//   * users: id serial pk, user_id text not null unique, email text, display_name text,
//     role text not null default 'viewer' (admin/editor/viewer), avatar_url text,
//     created_at timestamp default now(), last_login_at timestamp
//   * managed_entities: id serial, type text not null, title text not null,
//     status text default 'active', data jsonb, created_by text, updated_at timestamp default now()
//   * audit_logs: id serial, user_id text references users(user_id), action text not null,
//     entity_type text, entity_id integer, old_value jsonb, new_value jsonb,
//     ip_address text, created_at timestamp default now()
//   * app_settings: id serial, key text unique not null, value text,
//     updated_by text, updated_at timestamp default now()
// - Express routes under /api/admin/* protected by requireRole middleware
// - React frontend with sidebar nav, data table with sorting and pagination, modal edit forms
// - Role badges: admin=red, editor=yellow, viewer=gray
// - 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.

```
import { pgTable, serial, text, integer, jsonb, timestamp, boolean } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  userId: text('user_id').notNull().unique(),
  email: text('email'),
  displayName: text('display_name'),
  role: text('role').notNull().default('viewer'),
  avatarUrl: text('avatar_url'),
  createdAt: timestamp('created_at').defaultNow(),
  lastLoginAt: timestamp('last_login_at'),
});

export const managedEntities = pgTable('managed_entities', {
  id: serial('id').primaryKey(),
  type: text('type').notNull(),
  title: text('title').notNull(),
  status: text('status').default('active'),
  data: jsonb('data'),
  createdBy: text('created_by').notNull(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

export const auditLogs = pgTable('audit_logs', {
  id: serial('id').primaryKey(),
  userId: text('user_id'),
  action: text('action').notNull(),
  entityType: text('entity_type'),
  entityId: integer('entity_id'),
  oldValue: jsonb('old_value'),
  newValue: jsonb('new_value'),
  ipAddress: text('ip_address'),
  createdAt: timestamp('created_at').defaultNow(),
});

export const appSettings = pgTable('app_settings', {
  id: serial('id').primaryKey(),
  key: text('key').notNull().unique(),
  value: text('value'),
  updatedBy: text('updated_by'),
  updatedAt: timestamp('updated_at').defaultNow(),
});
```

> 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.

```
import { db } from '../db.js';
import { users } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

// Retry wrapper for PostgreSQL idle sleep reconnections
async function withRetry(fn, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (err) {
      if (err.code === 'ECONNRESET' && i < retries - 1) {
        await new Promise(r => setTimeout(r, 500));
        continue;
      }
      throw err;
    }
  }
}

export function requireRole(...allowedRoles) {
  return async (req, res, next) => {
    const replitUserId = req.get('X-Replit-User-Id');
    if (!replitUserId) return res.status(401).json({ error: 'Not authenticated' });

    try {
      const [user] = await withRetry(() =>
        db.select().from(users).where(eq(users.userId, replitUserId)).limit(1)
      );

      if (!user) return res.status(403).json({ error: 'User not found in admin panel' });
      if (!allowedRoles.includes(user.role)) {
        return res.status(403).json({ error: 'Insufficient permissions' });
      }

      req.adminUser = user;
      next();
    } catch (err) {
      res.status(500).json({ error: 'Auth check failed' });
    }
  };
}
```

> 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.

```
import { db } from '../db.js';
import { auditLogs, managedEntities } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

// In your PUT /api/admin/entities/:id route:
export async function updateEntity(req, res) {
  const { id } = req.params;
  const { title, status, data } = req.body;

  // 1. Read old value BEFORE mutation
  const [old] = await db.select().from(managedEntities).where(eq(managedEntities.id, Number(id)));
  if (!old) return res.status(404).json({ error: 'Not found' });

  // 2. Apply mutation
  const [updated] = await db
    .update(managedEntities)
    .set({ title, status, data, updatedAt: new Date() })
    .where(eq(managedEntities.id, Number(id)))
    .returning();

  // 3. Write audit log entry in the same logical operation
  await db.insert(auditLogs).values({
    userId: req.adminUser.userId,
    action: 'update',
    entityType: 'managed_entity',
    entityId: Number(id),
    oldValue: old,
    newValue: updated,
    ipAddress: req.ip,
  });

  res.json(updated);
}
```

> 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.

```
// GET /api/admin/users — list all users with pagination
app.get('/api/admin/users', requireRole('admin'), async (req, res) => {
  const limit = parseInt(req.query.limit) || 20;
  const offset = parseInt(req.query.offset) || 0;
  const allUsers = await db.select().from(users).limit(limit).offset(offset);
  res.json(allUsers);
});

// PATCH /api/admin/users/:id/role — change a user's role (admin only)
app.patch('/api/admin/users/:id/role', requireRole('admin'), async (req, res) => {
  const { id } = req.params;
  const { role } = req.body;
  if (!['admin', 'editor', 'viewer'].includes(role)) {
    return res.status(400).json({ error: 'Invalid role' });
  }

  const [old] = await db.select().from(users).where(eq(users.id, Number(id)));
  const [updated] = await db
    .update(users)
    .set({ role })
    .where(eq(users.id, Number(id)))
    .returning();

  await db.insert(auditLogs).values({
    userId: req.adminUser.userId,
    action: 'role_change',
    entityType: 'user',
    entityId: Number(id),
    oldValue: { role: old.role },
    newValue: { role: updated.role },
    ipAddress: req.ip,
  });

  res.json(updated);
});
```

> 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.

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

File: `server/middleware/requireRole.js`

```javascript
import { db } from '../db.js';
import { users } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

async function withRetry(fn, retries = 3) {
  for (let i = 0; i < retries; i++) {
    try {
      return await fn();
    } catch (err) {
      if ((err.code === 'ECONNRESET' || err.code === '57P01') && i < retries - 1) {
        await new Promise(r => setTimeout(r, 500 * (i + 1)));
        continue;
      }
      throw err;
    }
  }
}

export function requireRole(...allowedRoles) {
  return async (req, res, next) => {
    const replitUserId = req.get('X-Replit-User-Id');
    const replitUserName = req.get('X-Replit-User-Name');

    if (!replitUserId) {
      return res.status(401).json({ error: 'Not authenticated. Please log in.' });
    }

    try {
      let [user] = await withRetry(() =>
        db.select().from(users).where(eq(users.userId, replitUserId)).limit(1)
      );

      // Auto-create user row on first admin visit
      if (!user) {
        [user] = await db.insert(users).values({
          userId: replitUserId,
          displayName: replitUserName || replitUserId,
          role: 'viewer',
        }).returning();
      }

      if (!allowedRoles.includes(user.role)) {
        return res.status(403).json({
          error: `This action requires one of: ${allowedRoles.join(', ')}. Your role: ${user.role}`,
        });
      }

      req.adminUser = user;
      next();
    } catch (err) {
      console.error('requireRole error:', err);
      res.status(500).json({ error: 'Authentication check failed' });
    }
  };
}
```

## Common mistakes

- **Setting DATABASE_URL manually in Replit Secrets** — Replit's built-in PostgreSQL auto-injects DATABASE_URL into the environment. Manually setting it can override the correct value with a stale or wrong connection string. Fix: 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.
- **Forgetting to re-add Secrets in Deployment Secrets** — Workspace Secrets (the lock icon) are only available during development. When you deploy, the app runs in a separate environment that doesn't inherit them. Fix: 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.
- **Not wrapping Drizzle calls in a retry helper** — Replit's built-in PostgreSQL goes to sleep after 5 minutes of inactivity. The first admin action of the day hits a broken connection and throws an ECONNRESET error. Fix: 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.
- **Checking role only in the UI, not in the API** — A determined user can call your Express API directly (using fetch or curl) and bypass any React-side role checks entirely. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-replit/admin-panel
© RapidDev — https://www.rapidevelopers.com/how-to-build-replit/admin-panel
