# How to Build a Privacy Tools with Replit

- Tool: How to Build with Replit
- Difficulty: Intermediate
- Compatibility: Replit Free
- Last updated: April 2026

## TL;DR

Build a GDPR/CCPA compliance toolkit in Replit using Express and PostgreSQL in 1-2 hours. You'll create cookie consent recording, data export requests, account deletion flows, and a privacy policy acceptance system. Replit Auth handles user identity, and a Scheduled Deployment processes export jobs in the background.

## Before you start

- A Replit account (Free tier is sufficient)
- Basic understanding of what cookies and user data mean for your app
- A SendGrid account (free tier) for deletion confirmation emails
- Knowledge of which database tables in your app store user data (needed for the export and deletion steps)

## Step-by-step guide

### 1. Scaffold the project with Replit Agent

Open Replit, create a new App, and type the following prompt into the Agent. This generates the full Express + PostgreSQL project with all five compliance tables and the core API structure in one shot.

```
// Paste this into Replit Agent to scaffold the privacy tools app
// Build a GDPR/CCPA privacy compliance toolkit with Express and PostgreSQL using Drizzle ORM.
//
// Create these tables:
// 1. consent_records: id serial primary key, user_id text, session_id text, ip_address text,
//    consent_type text (enum: analytics/marketing/functional/all), granted boolean not null,
//    source text (enum: banner/settings/signup), created_at timestamp default now()
// 2. data_export_requests: id serial primary key, user_id text not null, status text default 'pending'
//    (enum: pending/processing/ready/expired/failed), file_url text, requested_at timestamp default now(),
//    completed_at timestamp, expires_at timestamp
// 3. deletion_requests: id serial primary key, user_id text not null, status text default 'pending'
//    (enum: pending/confirmed/processing/completed), reason text, confirmation_token text unique,
//    confirmed_at timestamp, completed_at timestamp, requested_at timestamp default now()
// 4. privacy_policies: id serial primary key, version text unique not null, content text (markdown),
//    effective_date date not null, is_current boolean default false, created_at timestamp default now()
// 5. policy_acceptances: id serial primary key, user_id text not null, policy_version text not null,
//    accepted_at timestamp default now(), unique(user_id, policy_version)
//
// Add Express routes:
// POST /api/consent, GET /api/consent/:userId
// POST /api/data-export/request, GET /api/data-export/:id/status, GET /api/data-export/:id/download
// POST /api/deletion/request, POST /api/deletion/confirm/:token
// GET /api/privacy-policy, POST /api/privacy-policy/accept
// GET /api/admin/consent-stats, GET /api/admin/deletion-requests
//
// Use Replit Auth for user identity. Bind server to 0.0.0.0 port 3000.
```

> Pro tip: After Agent finishes, open Drizzle Studio (Database tab in the sidebar) to verify all five tables were created with the correct columns and types before building on top of them.

**Expected result:** A running Express app with all five tables in PostgreSQL and API routes responding. The terminal shows 'listening on 3000'.

### 2. Add the consent recording and retrieval routes

The consent API is the most frequently called part — every page load may check consent status. This route stores the user's choice and returns their current preferences.

```
const express = require('express');
const { db } = require('../db');
const { consentRecords } = require('../schema');
const { eq, desc } = require('drizzle-orm');

const router = express.Router();

// Record a new consent choice
router.post('/api/consent', express.json(), async (req, res) => {
  const { consentType, granted, source } = req.body;
  const userId = req.user?.id || null;
  const sessionId = req.session?.id || null;

  if (!['analytics', 'marketing', 'functional', 'all'].includes(consentType)) {
    return res.status(400).json({ error: 'Invalid consent_type' });
  }

  try {
    const record = await db.insert(consentRecords).values({
      userId,
      sessionId,
      ipAddress: req.ip,
      consentType,
      granted: Boolean(granted),
      source: source || 'banner',
    }).returning();

    return res.json({ recorded: true, id: record[0].id });
  } catch (err) {
    console.error('[consent] insert error:', err);
    return res.status(500).json({ error: 'Failed to record consent' });
  }
});

// Get current consent status for a user
router.get('/api/consent/:userId', async (req, res) => {
  const rows = await db.select()
    .from(consentRecords)
    .where(eq(consentRecords.userId, req.params.userId))
    .orderBy(desc(consentRecords.createdAt))
    .limit(10);

  // Build a map of most recent consent per type
  const status = {};
  for (const row of rows) {
    if (!status[row.consentType]) {
      status[row.consentType] = row.granted;
    }
  }
  return res.json({ userId: req.params.userId, consent: status });
});

module.exports = router;
```

> Pro tip: Always record consent even for anonymous users — use the session_id as the identifier. When the user logs in later, link the session to their user_id by updating earlier consent records.

**Expected result:** POST /api/consent with {consentType: 'analytics', granted: true, source: 'banner'} returns {recorded: true, id: 1}.

### 3. Build the data export request and processing system

GDPR requires you to provide users a copy of all their data within 30 days. This step creates the request endpoint that queues an export job, and a background processor that gathers all user data into a downloadable JSON file.

```
// server/routes/dataExport.js — request and download endpoints
const express = require('express');
const { db } = require('../db');
const { dataExportRequests } = require('../schema');
const { eq } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();

router.post('/api/data-export/request', async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });

  const existing = await db.select().from(dataExportRequests)
    .where(eq(dataExportRequests.userId, req.user.id))
    .orderBy(dataExportRequests.requestedAt)
    .limit(1);

  // Prevent spam — one pending request at a time
  if (existing[0]?.status === 'pending' || existing[0]?.status === 'processing') {
    return res.status(429).json({ error: 'Export already in progress', requestId: existing[0].id });
  }

  const record = await withDbRetry(() =>
    db.insert(dataExportRequests).values({
      userId: req.user.id,
      status: 'pending',
    }).returning()
  );

  return res.json({ requestId: record[0].id, message: 'Export queued — ready in ~5 minutes' });
});

router.get('/api/data-export/:id/status', async (req, res) => {
  const row = await db.select().from(dataExportRequests)
    .where(eq(dataExportRequests.id, parseInt(req.params.id)))
    .limit(1);

  if (!row[0]) return res.status(404).json({ error: 'Not found' });
  if (row[0].userId !== req.user?.id) return res.status(403).json({ error: 'Forbidden' });

  return res.json({ status: row[0].status, fileUrl: row[0].fileUrl, expiresAt: row[0].expiresAt });
});

module.exports = router;
```

> Pro tip: The background export processor runs as a Scheduled Deployment. Ask Agent: 'Create a server/jobs/processExports.js script that queries pending data_export_requests, gathers all user data from every table, writes it to a JSON file in /tmp, uploads it to a public URL, and updates the request status to ready with an expires_at 7 days from now.'

**Expected result:** POST /api/data-export/request returns a requestId. GET /api/data-export/:id/status shows 'pending' immediately and 'ready' after the background job runs.

### 4. Add account deletion with confirmation and cascading purge

Deletion is the most critical GDPR right. This flow sends a confirmation email with a unique token, then on confirmation runs a PostgreSQL function that deletes or anonymizes all user data in the correct order.

```
// Ask Agent to add the deletion flow with this prompt:
// Add two routes to server/routes/deletion.js:
//
// POST /api/deletion/request:
//   1. Require Replit Auth (req.user must exist)
//   2. Generate a crypto.randomUUID() as confirmation_token
//   3. Insert into deletion_requests with status='pending' and the token
//   4. Send an email via SendGrid (API key from process.env.SENDGRID_API_KEY in Secrets)
//      with subject 'Confirm your account deletion' and a link:
//      https://${req.get('host')}/api/deletion/confirm/${token}
//   5. Return { message: 'Confirmation email sent' }
//
// POST /api/deletion/confirm/:token:
//   1. Find the deletion_request by confirmation_token where status='pending'
//   2. If not found, return 404
//   3. Update status to 'processing', set confirmed_at = now()
//   4. Run a PostgreSQL function 'purge_user_data(user_id)' via db.execute()
//      that deletes from: policy_acceptances, consent_records, data_export_requests,
//      deletion_requests last (self-reference) — all in order respecting foreign keys
//   5. Update deletion_request status to 'completed'
//   6. Return { message: 'Account deleted' }
//
// Also create the purge_user_data PostgreSQL function in a migration file.
```

> Pro tip: Add SENDGRID_API_KEY to Replit Secrets via the lock icon in the sidebar. After adding it, click Stop → Run to reload the environment so the new key is visible to the running server.

**Expected result:** POST /api/deletion/request sends a confirmation email. Following the link calls /api/deletion/confirm/:token and returns {message: 'Account deleted'} after all rows are purged.

### 5. Build the privacy policy versioning and consent banner

Privacy policies change over time — every user must accept the current version. This step adds the policy management routes and the React cookie consent banner that shows on first visit.

```
// Ask Agent to build the privacy policy system and React frontend with this prompt:
// Add to the Express app:
// 1. GET /api/privacy-policy — returns the current policy (is_current=true)
// 2. POST /api/privacy-policy/accept — inserts into policy_acceptances for the logged-in user
//    if they haven't already accepted this version (check unique constraint)
// 3. GET /api/admin/consent-stats — returns aggregate counts: total consent records,
//    breakdown by consent_type and granted (true/false), grouped using SQL COUNT + GROUP BY
// 4. GET /api/admin/deletion-requests — lists all deletion_requests with status != 'completed'
//
// Build a React frontend with:
// a) A CookieBanner component (fixed bottom bar) showing 'We use cookies' text,
//    Accept All, Reject All, and Customize buttons. On choice, POST /api/consent.
//    Store the choice in localStorage so the banner doesn't show again.
// b) A PrivacySettings page with toggle switches per consent category
//    (analytics, marketing, functional), a 'Request My Data' button (POST /api/data-export/request),
//    and a 'Delete My Account' button (POST /api/deletion/request).
// c) A PolicyAcceptanceModal that checks GET /api/privacy-policy on login,
//    compares version to what's stored in localStorage, and shows the modal if not accepted.
// d) An AdminPrivacy page with consent stats pie chart and deletion requests table.
```

**Expected result:** The app shows a cookie consent banner on first visit. The Privacy Settings page has working toggle switches. Clicking 'Request My Data' queues an export.

## Complete code example

File: `server/routes/consent.js`

```javascript
const express = require('express');
const { db } = require('../db');
const { consentRecords, policyAcceptances, privacyPolicies } = require('../schema');
const { eq, desc, and, count } = require('drizzle-orm');
const { withDbRetry } = require('../lib/retryDb');

const router = express.Router();

const VALID_CONSENT_TYPES = ['analytics', 'marketing', 'functional', 'all'];

router.post('/api/consent', express.json(), async (req, res) => {
  const { consentType, granted, source } = req.body;
  if (!VALID_CONSENT_TYPES.includes(consentType)) {
    return res.status(400).json({ error: 'Invalid consent_type' });
  }
  try {
    const record = await withDbRetry(() =>
      db.insert(consentRecords).values({
        userId: req.user?.id || null,
        sessionId: req.headers['x-session-id'] || null,
        ipAddress: req.ip,
        consentType,
        granted: Boolean(granted),
        source: source || 'banner',
      }).returning()
    );
    return res.json({ recorded: true, id: record[0].id });
  } catch (err) {
    console.error('[consent] error:', err.message);
    return res.status(500).json({ error: 'Failed to record consent' });
  }
});

router.get('/api/consent/:userId', async (req, res) => {
  const rows = await db.select()
    .from(consentRecords)
    .where(eq(consentRecords.userId, req.params.userId))
    .orderBy(desc(consentRecords.createdAt))
    .limit(20);
  const status = {};
  for (const row of rows) {
    if (!(row.consentType in status)) status[row.consentType] = row.granted;
  }
  return res.json({ userId: req.params.userId, consent: status });
});

router.post('/api/privacy-policy/accept', express.json(), async (req, res) => {
  if (!req.user) return res.status(401).json({ error: 'Login required' });
  const [policy] = await db.select().from(privacyPolicies)
    .where(eq(privacyPolicies.isCurrent, true)).limit(1);
  if (!policy) return res.status(404).json({ error: 'No current policy' });
  await db.insert(policyAcceptances)
    .values({ userId: req.user.id, policyVersion: policy.version })
    .onConflictDoNothing();
  return res.json({ accepted: true, version: policy.version });
});

router.get('/api/admin/consent-stats', async (req, res) => {
  const rows = await db.select({
    consentType: consentRecords.consentType,
module.exports = router;
```

## Common mistakes

- **Logging consent client-side only (in localStorage/cookies)** — Browser storage can be cleared. If a user disputes what they consented to, you need a server-side audit trail with timestamp and IP address to prove consent was given. Fix: Always POST to /api/consent for every consent choice. Use localStorage only as a cache to avoid re-showing the banner — never as the source of truth.
- **Forgetting to add Deployment Secrets after adding Workspace Secrets** — Replit's workspace Secrets and deployment Secrets are completely separate. SENDGRID_API_KEY added in the workspace won't be available in the deployed app — deletion confirmation emails will silently fail. Fix: After testing locally, open the Publish pane, go to Secrets, and add SENDGRID_API_KEY again with the same value. Redeploy after adding.
- **Running the data export query synchronously in the request handler** — Gathering all user data from every table can take 5-30 seconds for active users. A synchronous request will time out in Replit's 20-second limit and block other requests. Fix: Use the queue pattern: return a requestId immediately, process the export in a background Scheduled Deployment, and let users poll /api/data-export/:id/status.
- **Deleting the deletion_requests row before it's fully processed** — If the PostgreSQL purge function fails halfway through and you've already deleted the request, you have no record of the user's consent to delete and no way to resume. Fix: Keep deletion_requests rows forever (status='completed'). For GDPR compliance you can anonymize the user_id in the record after completion, but never hard-delete it.

## Best practices

- Store consent server-side in consent_records for every choice — localStorage is only a UX cache to avoid re-showing banners.
- Use Drizzle ORM's onConflictDoNothing() for policy_acceptances inserts — the unique constraint prevents double-recording if the user clicks accept twice.
- Wrap the deletion cascade in a PostgreSQL function run as a single transaction — if any table delete fails, the entire operation rolls back and the user's account is preserved.
- Set an expires_at on data export files (7 days is common) and clean up old files with a Scheduled Deployment to avoid storage creep.
- Add SENDGRID_API_KEY to both Workspace Secrets and Deployment Secrets — they are completely separate in Replit and the deployed app won't see workspace keys.
- Test your deletion flow with a real test account before going to production — create a user, add data across all tables, run deletion, then verify every table is clean.
- Use Replit Agent to generate the purge_user_data PostgreSQL function — describe every table that has a user_id column and Agent will generate the correct delete order respecting foreign key constraints.

## Frequently asked questions

### Does this toolkit make my app fully GDPR compliant?

It gives you the technical infrastructure GDPR requires: consent recording, data access (export), data erasure (deletion), and policy versioning. Full compliance also requires legal steps like appointing a Data Protection Officer if required and updating your terms of service — the toolkit covers the technical side.

### What happens if the deletion job fails halfway through?

The purge_user_data PostgreSQL function runs inside a transaction. If any DELETE fails (e.g., a foreign key constraint you missed), the entire transaction rolls back and the user's data is preserved. The deletion_request status stays at 'processing' — you can re-run the function after fixing the issue.

### Can I use this on Replit Free tier?

Yes. The main app runs on Replit Free's Autoscale deployment. The Scheduled Deployment for export processing also runs on Free tier. The only paid component is SendGrid (which has a free tier of 100 emails/day — plenty for deletion confirmation emails).

### How do I handle users who haven't logged in (anonymous sessions)?

Record their session_id from a UUID cookie in the consent_records table with user_id left null. If they later create an account, a merge endpoint can update all consent_records with that session_id to set the newly linked user_id.

### What's the difference between Workspace Secrets and Deployment Secrets in Replit?

Workspace Secrets are only available while you're working in the editor. Deployment Secrets are injected into the deployed app. They are completely separate — add SENDGRID_API_KEY in both locations to make deletion emails work both locally and in production.

### How long do data exports stay available for download?

Set expires_at to 7 days after the export completes (a common GDPR practice). Run a Scheduled Deployment daily that finds expired exports (expires_at < now()), deletes the file, and sets status to 'expired'. The user can request a fresh export at any time.

### Can RapidDev help build a custom privacy compliance system for my app?

Yes. RapidDev has built compliance toolkits for 600+ apps and can extend this foundation with multi-tenant consent management, automated regulatory reporting, and custom deletion workflows for your specific data model. Free consultation available.

### Should I deploy on Autoscale or Reserved VM?

Autoscale works for this use case. The main consent and policy routes are stateless and handle cold starts well. The export processing job runs as a separate Scheduled Deployment so cold starts on the main app don't affect background processing.

---

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