Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

GDPR compliance in Next.js SaaS -- account deletion, data export, and right to erasure with Drizzle ORM

June 24, 2026
nextjsdrizzle-ormsaasgdprauth

If your SaaS has any users in the EU, GDPR applies to you. That means two specific user rights you have to support:

  1. Right to erasure -- a user can request that you delete all their data.
  2. Right to data portability -- a user can request a copy of all their data.

Most founders ship auth, subscriptions, and email first. GDPR compliance gets pushed to "we'll handle it when someone asks." Then someone asks, and you have no idea which tables hold their data.

Here is how to build both features cleanly using Claude Code Boilerplate with Drizzle ORM, so you are ready before anyone asks.

What you are building

  • A DELETE /api/user endpoint that hard-deletes all user data
  • A GET /api/user/export endpoint that returns a JSON archive of everything tied to the user
  • Cascade deletes in Drizzle ORM so removing a user does not leave orphaned rows
  • A confirmation gate so users cannot accidentally delete their account

Step 1: Map your data

Before you write a single line, list every table that holds user data:

users
posts
comments
likes

Your SaaS will have more. The point is to know all the places before you delete.

Step 2: Add cascade deletes to your schema

Open each child table and make sure the foreign key cascades on delete:

// modules/post/post.schema.ts
export const postTable = pgTable('posts', {
  id: uuid('id').primaryKey().defaultRandom(),
  authorId: uuid('author_id')
    .notNull()
    .references(() => userTable.id, { onDelete: 'cascade' }),
  // ...
});

Do the same for comments, likes, and any other child table. With cascades in place, a single delete on the user row removes everything automatically.

Run the migration:

npm run db:generate
npm run db:migrate

Step 3: Add the delete service method

// modules/user/user.service.ts
export async function deleteAccount(userId: string): Promise<void> {
  const user = await userRepo.findById(userId);
  if (!user) throw new HttpError(404, 'User not found');
  await userRepo.deleteById(userId);
}

The repo method is a single delete -- the cascade handles the rest:

// modules/user/user.repo.ts
export async function deleteById(id: string): Promise<void> {
  await db.delete(userTable).where(eq(userTable.id, id));
}

Step 4: Add the delete route

// app/api/user/route.ts
import { getUserFromRequest } from '@/lib/auth';
import { deleteAccount } from '@/modules/user/user.service';
import { handleError } from '@/lib/errors';
import { NextRequest, NextResponse } from 'next/server';
 
export async function DELETE(req: NextRequest) {
  try {
    const user = getUserFromRequest(req);
    await deleteAccount(user.id);
    return NextResponse.json({ deleted: true });
  } catch (error: unknown) {
    return handleError(error);
  }
}

Only the authenticated user can delete their own account -- getUserFromRequest throws a 401 if the token is missing or invalid.

Step 5: Add data export

The export endpoint collects everything tied to the user and returns it as a downloadable JSON file:

// app/api/user/export/route.ts
import { getUserFromRequest } from '@/lib/auth';
import { handleError } from '@/lib/errors';
import { exportUserData } from '@/modules/user/user.service';
import { NextRequest, NextResponse } from 'next/server';
 
export async function GET(req: NextRequest) {
  try {
    const user = getUserFromRequest(req);
    const data = await exportUserData(user.id);
    return new NextResponse(JSON.stringify(data, null, 2), {
      headers: {
        'Content-Type': 'application/json',
        'Content-Disposition': `attachment; filename="my-data-${user.id}.json"`,
      },
    });
  } catch (error: unknown) {
    return handleError(error);
  }
}

The service method builds the export from parallel queries:

// modules/user/user.service.ts
export async function exportUserData(userId: string) {
  const [user, posts, comments] = await Promise.all([
    userRepo.findById(userId),
    postRepo.findByAuthorId(userId),
    commentRepo.findByAuthorId(userId),
  ]);
  if (!user) throw new HttpError(404, 'User not found');
  const { passwordHash: _, ...safeUser } = user; // never export the hash
  return { user: safeUser, posts, comments };
}

Step 6: Add a confirmation gate in the UI

Account deletion should require explicit confirmation. Here is a minimal version using the shadcn Dialog component:

// components/account/DeleteAccountDialog.tsx
'use client';
 
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { deleter } from '@/lib/fetcher';
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
 
export function DeleteAccountDialog() {
  const [open, setOpen] = useState(false);
  const router = useRouter();
 
  async function handleDelete() {
    await deleter('/api/user');
    router.push('/');
  }
 
  return (
    <>
      <Button variant="destructive" onClick={() => setOpen(true)}>
        Delete account
      </Button>
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Delete your account?</DialogTitle>
          </DialogHeader>
          <p>This removes all your data permanently. This cannot be undone.</p>
          <DialogFooter>
            <Button variant="outline" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button variant="destructive" onClick={handleDelete}>
              Yes, delete everything
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </>
  );
}

Optional: soft-delete with a grace period

If you want to give users 30 days to change their mind, add a deleted_at column on the user table instead of a hard delete. Mark the account deleted, block access immediately, and run a Vercel cron job to hard-delete after 30 days. The background-jobs post on this blog covers the cron pattern in detail.

What this gives you

By the time a user clicks "delete my account," the entire chain -- confirmation, cascade delete, data export -- is self-service. No support ticket. No manual DB query. No legal exposure from data you forgot to remove.

GDPR compliance is not a checklist item you negotiate with lawyers -- it is a set of features you build. With these two endpoints, you have the ones that matter most.


Ready to ship a compliant SaaS without spending weeks on plumbing? Clone Claude Code Boilerplate and have auth, email, payments, and GDPR-ready account management running before lunch.