If your SaaS has any users in the EU, GDPR applies to you. That means two specific user rights you have to support:
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.
DELETE /api/user endpoint that hard-deletes all user dataGET /api/user/export endpoint that returns a JSON archive of everything tied to the userBefore 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.
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
// 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));
}
// 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.
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 };
}
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>
</>
);
}
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.
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.