Skip to main content
RapidDev - Software Development Agency

How to Build a Customer Portal with Replit

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.

What you'll build

  • Passport.js local auth for customer accounts — separate from Replit Auth (customers are not Replit users)
  • PostgreSQL schema with customers, orders, invoices, tickets, ticket_messages, and documents tables
  • JWT middleware that scopes every /api/portal/* query to the authenticated customer's ID
  • Customer-facing tabbed interface: Orders, Invoices, Support, Documents
  • Support ticket system with a conversation thread (customer ↔ agent message bubbles)
  • Order detail view with status tracking and item breakdown from JSONB column
  • Admin side under /api/admin/* protected by Replit Auth (you = admin, customers = Passport.js)
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate12 min read1-2 hoursReplit Core or higherApril 2026RapidDev Engineering Team
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.

What you're building

A customer portal gives your clients a branded login area where they can independently view their orders, invoices, tickets, and documents. Without one, customers email you for every status update — burning your time and theirs. With one, they check themselves and only contact you when they actually need help.

This build has two authentication layers running side by side. Replit Auth handles your own admin login — you are a Replit user who manages the admin side. Customer authentication uses Passport.js with a local email+password strategy — your customers are not Replit users and authenticate differently. Both layers protect separate route namespaces.

The most critical architectural rule: every single database query in portal routes must include WHERE customer_id = [authenticated customer ID]. There is no ORM-level automatic scoping. If you forget this clause on even one endpoint, customer A can read customer B's orders by guessing an order ID.

Final result

A deployed customer portal where your clients log in with email and password to view orders, invoices, support tickets, and documents — all scoped to their account — while you manage everything from the admin side using Replit Auth.

Tech stack

ReplitIDE & Hosting
ExpressBackend Framework
PostgreSQLDatabase
Drizzle ORMDatabase ORM
Passport.jsCustomer Auth
Replit AuthAdmin Auth
ReactFrontend

Prerequisites

  • 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

Build steps

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.txt
1// Prompt to type into Replit Agent:
2// Build a Node.js Express customer portal with DUAL authentication:
3// - Replit Auth for admin routes (/api/admin/*)
4// - Passport.js local strategy for customer portal routes (/api/portal/*)
5// Built-in PostgreSQL with Drizzle ORM.
6// Schema in shared/schema.ts:
7// * customers: id serial pk, email text not null unique, name text not null,
8// company text, password_hash text not null, magic_link_token text,
9// magic_link_expires_at timestamp, created_at timestamp default now()
10// * orders: id serial pk, customer_id integer references customers not null,
11// order_number text unique not null, items jsonb not null,
12// total integer not null, status text default 'processing',
13// tracking_number text, created_at timestamp default now()
14// * invoices: id serial pk, customer_id integer references customers not null,
15// invoice_number text unique, amount integer not null, status text default 'unpaid',
16// due_date timestamp, paid_at timestamp, pdf_url text
17// * tickets: id serial pk, customer_id integer references customers not null,
18// subject text not null, status text default 'open', priority text default 'medium',
19// created_at timestamp default now()
20// * ticket_messages: id serial pk, ticket_id integer references tickets not null,
21// sender_type text not null, message text not null, created_at timestamp default now()
22// * documents: id serial pk, customer_id integer references customers not null,
23// title text not null, file_url text not null, category text, uploaded_at timestamp default now()
24// 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.

server/auth/customerAuth.js
1import passport from 'passport';
2import { Strategy as LocalStrategy } from 'passport-local';
3import bcrypt from 'bcrypt';
4import jwt from 'jsonwebtoken';
5import { db } from '../db.js';
6import { customers } from '../../shared/schema.js';
7import { eq } from 'drizzle-orm';
8
9// Configure Passport local strategy
10passport.use(new LocalStrategy(
11 { usernameField: 'email' },
12 async (email, password, done) => {
13 try {
14 const [customer] = await db.select().from(customers).where(eq(customers.email, email.toLowerCase()));
15 if (!customer) return done(null, false, { message: 'Invalid email or password' });
16
17 const isValid = await bcrypt.compare(password, customer.passwordHash);
18 if (!isValid) return done(null, false, { message: 'Invalid email or password' });
19
20 return done(null, customer);
21 } catch (err) {
22 return done(err);
23 }
24 }
25));
26
27// POST /api/auth/login
28export async function customerLogin(req, res, next) {
29 passport.authenticate('local', { session: false }, (err, customer, info) => {
30 if (err) return next(err);
31 if (!customer) return res.status(401).json({ error: info?.message || 'Authentication failed' });
32
33 const token = jwt.sign(
34 { customerId: customer.id, email: customer.email },
35 process.env.JWT_SECRET,
36 { expiresIn: '7d' }
37 );
38
39 res.json({ token, customer: { id: customer.id, name: customer.name, email: customer.email } });
40 })(req, res, next);
41}
42
43// JWT middleware for portal routes
44export function requireCustomer(req, res, next) {
45 const auth = req.headers.authorization;
46 if (!auth?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token provided' });
47
48 try {
49 const payload = jwt.verify(auth.slice(7), process.env.JWT_SECRET);
50 req.customerId = payload.customerId;
51 next();
52 } catch {
53 res.status(401).json({ error: 'Invalid or expired token' });
54 }
55}

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.

server/routes/portal.js
1import { db } from '../db.js';
2import { orders, invoices, tickets, ticketMessages, documents } from '../../shared/schema.js';
3import { eq, and, desc } from 'drizzle-orm';
4
5// GET /api/portal/orders — customer sees only THEIR orders
6export async function getMyOrders(req, res) {
7 const customerOrders = await db
8 .select()
9 .from(orders)
10 .where(eq(orders.customerId, req.customerId)) // MANDATORY: scope to authenticated customer
11 .orderBy(desc(orders.createdAt));
12 res.json(customerOrders);
13}
14
15// GET /api/portal/orders/:id — with customer_id guard
16export async function getMyOrder(req, res) {
17 const [order] = await db
18 .select()
19 .from(orders)
20 .where(and(
21 eq(orders.id, parseInt(req.params.id)),
22 eq(orders.customerId, req.customerId) // prevents customer A accessing customer B's order
23 ));
24 if (!order) return res.status(404).json({ error: 'Order not found' });
25 res.json(order);
26}
27
28// POST /api/portal/tickets — open a support ticket
29export async function createTicket(req, res) {
30 const { subject, message, priority } = req.body;
31 const [ticket] = await db.insert(tickets).values({
32 customerId: req.customerId,
33 subject, priority: priority || 'medium', status: 'open',
34 }).returning();
35
36 // First message is the issue description
37 await db.insert(ticketMessages).values({
38 ticketId: ticket.id, senderType: 'customer', message,
39 });
40
41 res.status(201).json(ticket);
42}
43
44// GET /api/portal/tickets/:id/messages — get conversation thread
45export async function getTicketMessages(req, res) {
46 const [ticket] = await db.select().from(tickets).where(
47 and(eq(tickets.id, parseInt(req.params.id)), eq(tickets.customerId, req.customerId))
48 );
49 if (!ticket) return res.status(404).json({ error: 'Ticket not found' });
50
51 const messages = await db.select().from(ticketMessages)
52 .where(eq(ticketMessages.ticketId, ticket.id))
53 .orderBy(ticketMessages.createdAt);
54 res.json(messages);
55}

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.

server/routes/admin.js
1// Admin reply to a ticket (protected by Replit Auth)
2app.post('/api/admin/tickets/:id/reply', async (req, res) => {
3 const adminId = req.get('X-Replit-User-Id');
4 if (!adminId) return res.status(401).json({ error: 'Not authenticated' });
5 // TODO: add role check if you have multiple admin users
6
7 const { message } = req.body;
8 const [msg] = await db.insert(ticketMessages).values({
9 ticketId: parseInt(req.params.id),
10 senderType: 'agent',
11 message,
12 }).returning();
13
14 // Update ticket status to in_progress on first reply
15 await db.update(tickets)
16 .set({ status: 'in_progress' })
17 .where(and(eq(tickets.id, parseInt(req.params.id)), eq(tickets.status, 'open')));
18
19 res.json(msg);
20});
21
22// Deployment notes:
23// 1. Add to Replit Secrets (lock icon 🔒):
24// JWT_SECRET=<64-byte-hex-string>
25// 2. After deploying, add SAME keys to Deployment Secrets
26// 3. Deploy on Autoscale — customer portals have unpredictable traffic
27// and cold starts are masked by the portal login page
28//
29// Pre-load customers:
30// Either create a POST /api/admin/customers route that bcrypt-hashes the password
31// and creates the row, or manually insert test customers via Drizzle Studio
32// using: INSERT INTO customers (email, name, company, password_hash) VALUES
33// ('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

server/auth/customerAuth.js
1import passport from 'passport';
2import { Strategy as LocalStrategy } from 'passport-local';
3import bcrypt from 'bcrypt';
4import jwt from 'jsonwebtoken';
5import { db } from '../db.js';
6import { customers } from '../../shared/schema.js';
7import { eq } from 'drizzle-orm';
8
9passport.use(new LocalStrategy(
10 { usernameField: 'email', passwordField: 'password' },
11 async (email, password, done) => {
12 try {
13 const [customer] = await db.select().from(customers)
14 .where(eq(customers.email, email.toLowerCase().trim()));
15 if (!customer) return done(null, false, { message: 'Invalid email or password' });
16 const valid = await bcrypt.compare(password, customer.passwordHash);
17 if (!valid) return done(null, false, { message: 'Invalid email or password' });
18 return done(null, customer);
19 } catch (err) { return done(err); }
20 }
21));
22
23export function customerLogin(req, res, next) {
24 passport.authenticate('local', { session: false }, (err, customer, info) => {
25 if (err) return next(err);
26 if (!customer) return res.status(401).json({ error: info?.message || 'Login failed' });
27 const token = jwt.sign(
28 { customerId: customer.id, email: customer.email },
29 process.env.JWT_SECRET,
30 { expiresIn: '7d' }
31 );
32 res.json({ token, name: customer.name, email: customer.email });
33 })(req, res, next);
34}
35
36export function requireCustomer(req, res, next) {
37 const h = req.headers.authorization;
38 if (!h?.startsWith('Bearer ')) return res.status(401).json({ error: 'Authentication required' });
39 try {
40 const p = jwt.verify(h.slice(7), process.env.JWT_SECRET);
41 req.customerId = p.customerId;
42 next();
43 } catch {
44 res.status(401).json({ error: 'Token expired or invalid. Please log in again.' });
45 }
46}

Customization ideas

Magic link login

Add a POST /api/auth/magic-link endpoint that generates a one-time token, stores it in magic_link_token with a 15-minute expiry, and emails it to the customer. A GET /api/auth/verify?token= route validates the token and issues a JWT. This removes the need for passwords.

Document download with signed URLs

Instead of storing direct file URLs in the documents table, store object storage paths. The GET /api/portal/documents/:id/download endpoint generates a short-lived signed URL (15 minutes) that's valid only for that customer. This prevents URL sharing between customers.

Invoice payment via Stripe

For unpaid invoices, add a Pay Now button that calls POST /api/portal/invoices/:id/pay. This creates a Stripe Checkout Session for the invoice amount. The webhook on checkout.session.completed updates the invoice status to 'paid' and records paid_at.

Common pitfalls

Pitfall: Forgetting WHERE customer_id = req.customerId on portal queries

How to avoid: 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.

Pitfall: Using Replit Auth for customer login

How to avoid: Use Passport.js with a local email+password strategy for customer auth. Replit Auth stays on admin routes only.

Pitfall: Not adding JWT_SECRET to Deployment Secrets

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a customer portal with Express and PostgreSQL. Customers authenticate with Passport.js (local strategy, email/password), and I issue a JWT on login. I have portal routes that need to return data scoped to the authenticated customer. Help me write a requireCustomer middleware that extracts the JWT from the Authorization header, verifies it using jsonwebtoken, and attaches the customerId payload to req.customerId. Then show me how to use req.customerId to scope a Drizzle ORM query on the orders table so customers can only see their own orders.

Build Prompt

Add a customer activity dashboard to the portal home page. Show a summary card with: open ticket count, unpaid invoice total in dollars, last order status and date, and number of documents. Fetch all this in one GET /api/portal/dashboard route using parallel Drizzle queries (Promise.all). Cache the result for 5 minutes using a Map keyed by customer_id to avoid hitting the database on every page load.

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.

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.