DEV Community

vapmail16
vapmail16

Posted on

The #1 Thing Missing From Every SaaS Starter Kit

I've evaluated dozens of SaaS starter kits. Next.js boilerplates, Express templates, Rails scaffolds — you name it.

They all nail the same things: authentication, a pretty dashboard, Stripe integration, maybe some admin CRUD. And they all skip the same thing.

Compliance.

Not "we added a cookie banner" compliance. I mean actual GDPR data export, right-to-deletion, consent management, audit logging, field-level encryption, breach notification — the stuff that turns a weekend project into something you can actually sell to businesses in the EU (or anywhere with privacy laws, which is increasingly everywhere).

I spent months building these features into a SaaS template. Here's what I learned and why most starter kits get this wrong.


The "We'll Add It Later" Trap

Every developer who's shipped a SaaS product knows this conversation:

"We'll handle GDPR later."
"Let's just get to market first."
"We only have 50 users, nobody's going to request a data export."

Then you land your first enterprise client. Their procurement team sends over a 40-page security questionnaire. Question 7: "Describe your data subject access request (DSAR) process." Question 23: "How do you handle data retention and deletion?" Question 31: "Provide evidence of audit logging for PII access."

And suddenly "later" is now, and you're bolting compliance onto an architecture that was never designed for it.

The fix isn't complicated — but it needs to be baked in from the start, not layered on after.


What Real GDPR Data Export Looks Like in Code

Most starter kits that claim "GDPR support" give you a checkbox component and call it done. Here's what an actual data export implementation looks like.

First, the service that collects everything you store about a user:

export const generateDataExport = async (requestId: string) => {
  const request = await prisma.dataExportRequest.findUnique({
    where: { id: requestId },
    include: { user: true },
  });

  if (!request) {
    throw new NotFoundError('Export request not found');
  }

  await prisma.dataExportRequest.update({
    where: { id: requestId },
    data: { status: DataExportStatus.PROCESSING },
  });

  const userId = request.userId;

  // Collect ALL user data — not just the profile
  const [user, sessions, auditLogs, notifications, payments, subscriptions, consentRecords] =
    await Promise.all([
      prisma.user.findUnique({ where: { id: userId } }),
      prisma.session.findMany({ where: { userId } }),
      prisma.auditLog.findMany({ where: { userId } }),
      prisma.notification.findMany({ where: { userId } }),
      prisma.payment.findMany({ where: { userId }, include: { refunds: true } }),
      prisma.subscription.findMany({ where: { userId } }),
      prisma.consentRecord.findMany({ where: { userId } }),
    ]);

  const exportData = {
    user: {
      id: user?.id,
      email: user?.email,
      name: user?.name,
      role: user?.role,
      createdAt: user?.createdAt,
    },
    sessions: sessions.map((s) => ({
      id: s.id,
      createdAt: s.createdAt,
      expiresAt: s.expiresAt,
      userAgent: s.userAgent,
      ipAddress: s.ipAddress,
    })),
    auditLogs: auditLogs.map((a) => ({
      action: a.action,
      resource: a.resource,
      createdAt: a.createdAt,
      ipAddress: a.ipAddress,
    })),
    payments: payments.map((p) => ({
      amount: p.amount,
      currency: p.currency,
      status: p.status,
      createdAt: p.createdAt,
      refunds: p.refunds,
    })),
    consents: consentRecords,
    exportMetadata: {
      requestId,
      generatedAt: new Date().toISOString(),
      format: 'JSON',
    },
  };

  // Update request, set 7-day download window
  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + 7);

  await prisma.dataExportRequest.update({
    where: { id: requestId },
    data: {
      status: DataExportStatus.COMPLETED,
      completedAt: new Date(),
      downloadUrl: `/api/gdpr/exports/${requestId}/download`,
      fileSize: Buffer.byteLength(JSON.stringify(exportData), 'utf8'),
      expiresAt,
    },
  });

  return exportData;
};
Enter fullscreen mode Exit fullscreen mode

A few things to notice:

  • You must export everything — sessions, audit logs, payment history, consent records, IP addresses. Not just the profile. GDPR Article 15 is specific about this.
  • Download links expire — you don't want a permanent URL to someone's entire data sitting in their email forever.
  • The request itself is tracked — status goes from PENDING → PROCESSING → COMPLETED (or FAILED), with timestamps and audit logging at each step.

The route that serves the download is equally important. It validates ownership, checks expiry, and sets proper headers:

router.get(
  '/exports/:id/download',
  asyncHandler(async (req, res) => {
    const jsonString = await gdprService.getExportDataForDownload(
      req.params.id,
      req.user!.id // ensures only the data owner can download
    );

    const filename = `data-export-${req.params.id}.json`;
    res.setHeader('Content-Type', 'application/json');
    res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
    res.send(jsonString);
  })
);
Enter fullscreen mode Exit fullscreen mode

Data Deletion Is Harder Than You Think

"Just delete the user row." If only.

Real right-to-erasure means you need two modes:

  • Soft delete (anonymize): Replace PII with anonymized values, keep the record for accounting/legal obligations. You can't delete a payment record — your accountant needs it — but you can strip the name and email.
  • Hard delete: Actually remove everything. Cascade through sessions, notifications, audit logs, payments, consents.

And crucially, deletion requires email confirmation before it executes. You don't want a compromised session to wipe a user's entire account:

export const requestDataDeletion = async (
  userId: string,
  deletionType: DeletionType = DeletionType.SOFT,
  reason?: string
) => {
  const confirmationToken = crypto.randomBytes(32).toString('hex');

  const deletionRequest = await prisma.dataDeletionRequest.create({
    data: {
      userId,
      deletionType,
      reason,
      confirmationToken,
      status: DataDeletionStatus.PENDING,
    },
  });

  // Send confirmation email — deletion only proceeds after click
  await sendDataDeletionRequestConfirmationEmail({
    to: user.email,
    name: user.name,
    confirmationLink: `${frontendUrl}/gdpr?confirmDeletion=${confirmationToken}`,
  });

  await createAuditLog({
    userId,
    action: 'DATA_DELETION_REQUESTED',
    resource: 'data_deletion_requests',
    resourceId: deletionRequest.id,
    details: { deletionType, reason },
  });

  return deletionRequest;
};
Enter fullscreen mode Exit fullscreen mode

Every step is audit-logged. The confirmation token is cryptographically random. The request sits in PENDING until the user clicks the email link. This is the kind of thing that takes a couple of days to get right — and that's before you write the tests.


The Iceberg Below the Surface

Data export and deletion are the visible parts. Underneath, you need:

  • Consent management with versioned consent records (users agreed to v2.1 of your privacy policy on this date, from this IP).
  • Audit logging on every PII access — not just writes, but reads. Who looked at what, when, and why.
  • Field-level encryption for ultra-sensitive fields (phone numbers, addresses, tax IDs) as a second layer beyond database encryption.
  • Data retention policies that automatically purge expired data.
  • Breach notification workflows — GDPR requires reporting within 72 hours.
  • CSRF protection on every state-changing endpoint, because a compliance feature that can be exploited via cross-site request forgery isn't really compliant.
  • PII masking in logs so your error tracking doesn't accidentally become a data breach.

Each of these is individually straightforward. Together, they represent weeks of careful engineering. And they all need tests — you can't just eyeball compliance.


Why Starter Kits Skip This

I get it. Compliance features don't make for exciting demo videos. Nobody screenshots a consent versioning system for their landing page. "Look at this beautiful audit log table" doesn't go viral on Twitter.

But here's the business reality: compliance is a feature that sells. Enterprise buyers specifically ask for it. EU-based customers require it by law. And the fines for getting it wrong are not theoretical — they're in the headlines every month.

The starter kits that skip compliance are optimizing for the first sale. The ones that include it are optimizing for every sale after that.


What I'd Tell My Past Self

If you're building a SaaS template (or choosing one), here's the order I'd prioritize compliance:

  1. Audit logging — add it first, to everything. You'll thank yourself when debugging and when filling out security questionnaires.
  2. Data export — GDPR Article 15 requests are the most common. Have a one-click solution ready.
  3. Consent management — versioned, timestamped, with IP. Not a single boolean "agreed to terms."
  4. Data deletion — with soft/hard modes and email confirmation. Test the cascading deletes thoroughly.
  5. Encryption — field-level for PII, on top of whatever your database provides.
  6. Everything else — retention, breach notification, CSRF, monitoring. Important, but the first five cover 80% of what procurement teams ask about.

Build it in from day one. It's 10x easier than retrofitting it after you have real users and real data.


What compliance features do you wish your starter had? I'm genuinely curious — drop a comment below.

Top comments (0)