# How to Build a Customer Portal with Replit

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

## TL;DR

Build a branded customer self-service portal in Replit in 1-2 hours using Express, PostgreSQL, and Passport.js. Your customers log in to view their own orders, invoices, support tickets, and documents — no emailing your team required. Every query is scoped to the authenticated customer's ID.

## Before you start

- A Replit Core account (required for Replit Auth and built-in PostgreSQL)
- A list of your customers' emails to pre-register their accounts
- Optional: a SendGrid or Resend API key for magic link login (store in Replit Secrets)
- Know what data you want to show customers: orders, invoices, tickets, documents, or a subset

## Step-by-step guide

### 1. Scaffold the project with dual-auth architecture

This is the one project where you intentionally combine two auth systems. Replit Auth for admin routes (/api/admin/*), Passport.js for customer portal routes (/api/portal/*). Agent sets both up in one prompt.

```
// Prompt to type into Replit Agent:
// Build a Node.js Express customer portal with DUAL authentication:
// - Replit Auth for admin routes (/api/admin/*)
// - Passport.js local strategy for customer portal routes (/api/portal/*)
// Built-in PostgreSQL with Drizzle ORM.
// Schema in shared/schema.ts:
// * customers: id serial pk, email text not null unique, name text not null,
//   company text, password_hash text not null, magic_link_token text,
//   magic_link_expires_at timestamp, created_at timestamp default now()
// * orders: id serial pk, customer_id integer references customers not null,
//   order_number text unique not null, items jsonb not null,
//   total integer not null, status text default 'processing',
//   tracking_number text, created_at timestamp default now()
// * invoices: id serial pk, customer_id integer references customers not null,
//   invoice_number text unique, amount integer not null, status text default 'unpaid',
//   due_date timestamp, paid_at timestamp, pdf_url text
// * tickets: id serial pk, customer_id integer references customers not null,
//   subject text not null, status text default 'open', priority text default 'medium',
//   created_at timestamp default now()
// * ticket_messages: id serial pk, ticket_id integer references tickets not null,
//   sender_type text not null, message text not null, created_at timestamp default now()
// * documents: id serial pk, customer_id integer references customers not null,
//   title text not null, file_url text not null, category text, uploaded_at timestamp default now()
// Install: passport, passport-local, bcrypt, express-jwt, jsonwebtoken
```

> Pro tip: Run npx drizzle-kit push after the schema is created. Verify tables in Drizzle Studio. Then manually insert your first test customer row with a bcrypt-hashed password to test login before building the full UI.

**Expected result:** Project structure with shared/schema.ts containing all six tables. server/auth/ has both Passport.js config and JWT middleware.

### 2. Implement customer authentication with Passport.js and JWT

Customers log in with email and password. Passport verifies credentials against the customers table (bcrypt comparison). On success, a JWT is issued. The JWT is then used to authenticate all portal API calls.

```
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { db } from '../db.js';
import { customers } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

// Configure Passport local strategy
passport.use(new LocalStrategy(
  { usernameField: 'email' },
  async (email, password, done) => {
    try {
      const [customer] = await db.select().from(customers).where(eq(customers.email, email.toLowerCase()));
      if (!customer) return done(null, false, { message: 'Invalid email or password' });

      const isValid = await bcrypt.compare(password, customer.passwordHash);
      if (!isValid) return done(null, false, { message: 'Invalid email or password' });

      return done(null, customer);
    } catch (err) {
      return done(err);
    }
  }
));

// POST /api/auth/login
export async function customerLogin(req, res, next) {
  passport.authenticate('local', { session: false }, (err, customer, info) => {
    if (err) return next(err);
    if (!customer) return res.status(401).json({ error: info?.message || 'Authentication failed' });

    const token = jwt.sign(
      { customerId: customer.id, email: customer.email },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );

    res.json({ token, customer: { id: customer.id, name: customer.name, email: customer.email } });
  })(req, res, next);
}

// JWT middleware for portal routes
export function requireCustomer(req, res, next) {
  const auth = req.headers.authorization;
  if (!auth?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token provided' });

  try {
    const payload = jwt.verify(auth.slice(7), process.env.JWT_SECRET);
    req.customerId = payload.customerId;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}
```

> Pro tip: Add JWT_SECRET to Replit Secrets (lock icon 🔒): generate a strong random value using crypto.randomBytes(64).toString('hex') in the Shell. Without this secret, JWTs can't be signed or verified and the entire auth system fails.

### 3. Build portal routes with mandatory customer_id scoping

Every portal route reads req.customerId from the JWT middleware and adds it to every query. This is the non-negotiable security pattern — skip it once and customers can see each other's data.

```
import { db } from '../db.js';
import { orders, invoices, tickets, ticketMessages, documents } from '../../shared/schema.js';
import { eq, and, desc } from 'drizzle-orm';

// GET /api/portal/orders — customer sees only THEIR orders
export async function getMyOrders(req, res) {
  const customerOrders = await db
    .select()
    .from(orders)
    .where(eq(orders.customerId, req.customerId)) // MANDATORY: scope to authenticated customer
    .orderBy(desc(orders.createdAt));
  res.json(customerOrders);
}

// GET /api/portal/orders/:id — with customer_id guard
export async function getMyOrder(req, res) {
  const [order] = await db
    .select()
    .from(orders)
    .where(and(
      eq(orders.id, parseInt(req.params.id)),
      eq(orders.customerId, req.customerId) // prevents customer A accessing customer B's order
    ));
  if (!order) return res.status(404).json({ error: 'Order not found' });
  res.json(order);
}

// POST /api/portal/tickets — open a support ticket
export async function createTicket(req, res) {
  const { subject, message, priority } = req.body;
  const [ticket] = await db.insert(tickets).values({
    customerId: req.customerId,
    subject, priority: priority || 'medium', status: 'open',
  }).returning();

  // First message is the issue description
  await db.insert(ticketMessages).values({
    ticketId: ticket.id, senderType: 'customer', message,
  });

  res.status(201).json(ticket);
}

// GET /api/portal/tickets/:id/messages — get conversation thread
export async function getTicketMessages(req, res) {
  const [ticket] = await db.select().from(tickets).where(
    and(eq(tickets.id, parseInt(req.params.id)), eq(tickets.customerId, req.customerId))
  );
  if (!ticket) return res.status(404).json({ error: 'Ticket not found' });

  const messages = await db.select().from(ticketMessages)
    .where(eq(ticketMessages.ticketId, ticket.id))
    .orderBy(ticketMessages.createdAt);
  res.json(messages);
}
```

> Pro tip: Write a helper that always appends the customer_id condition: const myFilter = (table) => and(eq(table.customerId, req.customerId), ...otherConditions). This makes scoping harder to accidentally forget.

**Expected result:** GET /api/portal/orders with a valid JWT returns only the authenticated customer's orders. Attempting to access another customer's order ID returns 404 (not 403, to avoid revealing that the resource exists).

### 4. Add the admin side and deploy on Autoscale

Admin routes use Replit Auth. From the admin dashboard, you can reply to tickets, upload documents, update order status, and create/manage customer accounts — all secured by your Replit identity.

```
// Admin reply to a ticket (protected by Replit Auth)
app.post('/api/admin/tickets/:id/reply', async (req, res) => {
  const adminId = req.get('X-Replit-User-Id');
  if (!adminId) return res.status(401).json({ error: 'Not authenticated' });
  // TODO: add role check if you have multiple admin users

  const { message } = req.body;
  const [msg] = await db.insert(ticketMessages).values({
    ticketId: parseInt(req.params.id),
    senderType: 'agent',
    message,
  }).returning();

  // Update ticket status to in_progress on first reply
  await db.update(tickets)
    .set({ status: 'in_progress' })
    .where(and(eq(tickets.id, parseInt(req.params.id)), eq(tickets.status, 'open')));

  res.json(msg);
});

// Deployment notes:
// 1. Add to Replit Secrets (lock icon 🔒):
//    JWT_SECRET=<64-byte-hex-string>
// 2. After deploying, add SAME keys to Deployment Secrets
// 3. Deploy on Autoscale — customer portals have unpredictable traffic
//    and cold starts are masked by the portal login page
//
// Pre-load customers:
// Either create a POST /api/admin/customers route that bcrypt-hashes the password
// and creates the row, or manually insert test customers via Drizzle Studio
// using: INSERT INTO customers (email, name, company, password_hash) VALUES
// ('customer@example.com', 'Jane Doe', 'ACME Corp', '<bcrypt_hash>');
```

> Pro tip: To generate a bcrypt hash for a test customer password, open the Replit Shell and run: node -e "const bcrypt = require('bcrypt'); bcrypt.hash('test123', 12).then(h => console.log(h));". Copy the output into your database.

**Expected result:** Admin replies via /api/admin/tickets/:id/reply appear in the ticket conversation. The customer sees the agent reply in the support thread with a different styling (right-aligned agent bubbles vs left-aligned customer bubbles).

## Complete code example

File: `server/auth/customerAuth.js`

```javascript
import passport from 'passport';
import { Strategy as LocalStrategy } from 'passport-local';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { db } from '../db.js';
import { customers } from '../../shared/schema.js';
import { eq } from 'drizzle-orm';

passport.use(new LocalStrategy(
  { usernameField: 'email', passwordField: 'password' },
  async (email, password, done) => {
    try {
      const [customer] = await db.select().from(customers)
        .where(eq(customers.email, email.toLowerCase().trim()));
      if (!customer) return done(null, false, { message: 'Invalid email or password' });
      const valid = await bcrypt.compare(password, customer.passwordHash);
      if (!valid) return done(null, false, { message: 'Invalid email or password' });
      return done(null, customer);
    } catch (err) { return done(err); }
  }
));

export function customerLogin(req, res, next) {
  passport.authenticate('local', { session: false }, (err, customer, info) => {
    if (err) return next(err);
    if (!customer) return res.status(401).json({ error: info?.message || 'Login failed' });
    const token = jwt.sign(
      { customerId: customer.id, email: customer.email },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );
    res.json({ token, name: customer.name, email: customer.email });
  })(req, res, next);
}

export function requireCustomer(req, res, next) {
  const h = req.headers.authorization;
  if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Authentication required' });
  try {
    const p = jwt.verify(h.slice(7), process.env.JWT_SECRET);
    req.customerId = p.customerId;
    next();
  } catch {
    res.status(401).json({ error: 'Token expired or invalid. Please log in again.' });
  }
}
```

## Common mistakes

- **Forgetting WHERE customer_id = req.customerId on portal queries** — Without customer_id scoping, any authenticated customer can see any other customer's orders by guessing an order ID. This is a critical data isolation bug. Fix: Add the customer_id filter to every single portal query using AND eq(table.customerId, req.customerId). Return 404 (not 403) when not found, to avoid confirming the resource exists.
- **Using Replit Auth for customer login** — Replit Auth is for Replit account holders. Your customers don't have Replit accounts. Using it forces customers to create Replit accounts just to view their orders — a huge friction barrier. Fix: Use Passport.js with a local email+password strategy for customer auth. Replit Auth stays on admin routes only.
- **Not adding JWT_SECRET to Deployment Secrets** — JWTs are signed with JWT_SECRET. If the deployed app uses a different secret than development (or no secret), all customer login tokens are immediately invalid after deployment. Fix: Add JWT_SECRET to Deployment Secrets after deploying. Regenerate customer JWT tokens if the secret changes.

## Best practices

- Always scope portal queries with AND customer_id = req.customerId — never trust the resource ID alone.
- Use Passport.js (not Replit Auth) for customer authentication — your customers are not Replit users.
- Return 404 (not 403) for portal resources that don't belong to the authenticated customer, to avoid leaking resource existence.
- Store JWT_SECRET in Replit Secrets and add it again to Deployment Secrets after deploying.
- Hash passwords with bcrypt at cost factor 12 — never store or compare plain text passwords.
- Deploy on Autoscale — customer portals have unpredictable traffic and cold starts are hidden by the login page.
- Wrap database calls in withRetry() to handle Replit PostgreSQL's 5-minute idle sleep reconnection.

## Frequently asked questions

### Why use Passport.js instead of Replit Auth for customers?

Replit Auth requires all users to have Replit accounts. Your customers are regular people — they shouldn't need to create a Replit account just to check their order status. Passport.js with a local email/password strategy lets you manage customer accounts entirely in your own database.

### How do I create customer accounts?

Build a POST /api/admin/customers route protected by Replit Auth. It accepts name, email, and a temporary password, hashes the password with bcrypt (cost factor 12), and inserts the customer row. Send the customer a welcome email with their temporary password and a link to the portal login page.

### Can customers reset their own passwords?

Add a POST /api/auth/forgot-password route that generates a unique token, stores it with a 1-hour expiry in the customers table, and emails a reset link. A PUT /api/auth/reset-password route accepts the token, validates it's not expired, hashes the new password, and updates the customers row.

### What's the PostgreSQL idle sleep issue and how do I fix it?

Replit's built-in PostgreSQL goes to sleep after 5 minutes of no queries. The first customer login attempt of the day hits a broken connection. Fix it by wrapping all Drizzle queries in a withRetry() function that catches ECONNRESET errors and retries after 500ms.

### Can RapidDev help build a customer portal for my service business?

Yes. RapidDev has built 600+ apps including customer portals with document management, invoice payment, and support ticket systems tailored to specific service industries. Reach out for a free consultation.

### How do I add documents to the portal for a specific customer?

Build a POST /api/admin/customers/:id/documents route that accepts a file URL and category. Insert into the documents table with customer_id set to the specified customer. The customer then sees the document in their portal Documents tab. For file uploads, use Replit Object Storage or Cloudflare R2.

### Is it possible to have the customer and admin sides on the same deployed URL?

Yes — that's exactly how this app is built. /api/portal/* routes use Passport.js JWT auth. /api/admin/* routes use Replit Auth. Both share the same Express server and database. React routes on the frontend separate the customer-facing portal UI from the admin dashboard UI.

---

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