Still No UI Survives First Contact: A Sequel
Remember when we redesigned our Add Property dialog and wrote about it? Turns out that design also didn't survive. Here's how we got it right (this time, we think).
How we implemented user deletion at Homi with PII anonymization, analytics provider updates, and proper dependency handling—all while maintaining data integrity.
Kristian Elset Bø
Engineer
User deletion is one of those features that seems simple on the surface but reveals its complexity once you start implementing it. At Homi, we needed to build a deletion system that respects user privacy, complies with GDPR regulations, maintains data integrity, and works seamlessly across multiple services.
Here's how we did it—and what we learned along the way.
GDPR Context: The General Data Protection Regulation (GDPR) gives users the "right to be forgotten"—meaning they can request deletion of their personal data. While this doesn't always mean complete data deletion, it requires careful handling of Personally Identifiable Information (PII).
When a user requests account deletion, you can't just run DELETE FROM users WHERE id = ? and call it a day. Here's why:
We chose a hybrid approach that balances compliance with data integrity:
// Soft delete with PII anonymization
const [anonymizedUser] = await db
.update(userTable)
.set({
deletedAt: new Date(),
// Anonymize all PII but keep the ID for referential integrity
email: `deleted-${userId}@deleted.local`,
name: null,
displayName: null,
image: null,
phone: null,
address: null,
city: null,
state: null,
zipCode: null,
country: null,
gender: null,
birthDate: null,
nationality: null,
})
.where(eq(userTable.id, userId))
.returning();
Why this works:
Best Practice: Use soft deletion with PII anonymization instead of hard deletion. This preserves referential integrity while still respecting user privacy.
Here's a critical detail: soft deletion alone isn't enough to prevent re-login.
When we soft delete a user, we update the user record but leave related tables untouched. The problem? Database cascade deletes only trigger on hard deletes (SQL DELETE), not updates.
This means:
deleted-{userId}@deleted.local)If a deleted user tries to "Sign in with Google," the auth system would:
deletedAt set)We explicitly hard delete account and session records:
// Hard delete all account records (prevents OAuth re-login)
await db.delete(accountTable).where(eq(accountTable.userId, userId));
// Hard delete all session records (terminates all active sessions)
await db.delete(sessionTable).where(eq(sessionTable.userId, userId));
// THEN soft delete user with PII anonymization
const [anonymizedUser] = await db.update(userTable).set({...});
Why this works:
Important: Always explicitly delete account and session records during user deletion. Don't rely on cascade deletes with soft deletion - they won't trigger!
Before deleting a user, we check what they own:
export async function checkUserDeletionDependencies(
userId: string,
): Promise<UserDeletionCheck> {
const [ownedCollections, createdListings] = await Promise.all([
// Count collections owned by user
db.$count(
collectionTable,
and(
eq(collectionTable.ownerId, userId),
isNull(collectionTable.deletedAt),
),
),
// Count property listings created by user
db.$count(
propertyListingTable,
and(
eq(propertyListingTable.creatorId, userId),
isNull(propertyListingTable.deletedAt),
),
),
]);
const blockers: string[] = [];
if (ownedCollections > 0) {
blockers.push(`owns ${ownedCollections} active collections`);
}
return {
canDelete: blockers.length === 0,
ownedCollections,
createdListings,
blockers,
};
}
This prevents users from deleting their accounts while they still own collections that other users collaborate on. They must first transfer ownership or delete their collections.
User data doesn't just live in our database. Here's how we handle all the pieces:
Profile images and uploads need to be removed from Vercel Blob:
// Clean up user profile image from blob storage
if (userToDelete.image?.includes("blob.vercel-storage.com")) {
try {
await BlobService.delete(userToDelete.image);
console.log(`🗑️ Cleaned up deleted user's profile image`);
} catch (error) {
console.error(`⚠️ Failed to delete user profile image blob:`, error);
}
}
We fully delete users from Polar (our billing provider):
async function deletePolarCustomer(userId: string): Promise<void> {
const polarClient = new Polar({
accessToken: env_auth.POLAR_ACCESS_TOKEN,
server: env_auth.POLAR_SERVER,
});
try {
await polarClient.customers.deleteExternal({
externalId: userId,
});
console.log(`✅ Deleted Polar customer: ${userId}`);
} catch (error) {
// Handle customer not found gracefully
if (error.message.includes("not found")) {
console.log(`ℹ️ Polar customer not found (likely never created)`);
return;
}
console.error(`⚠️ Failed to delete Polar customer:`, error);
}
}
Here's where it gets interesting. We don't delete users from analytics providers—we update them with anonymized data:
// Update analytics providers with anonymized data
identifyServerUser(userId, anonymizedUser);
// Track user deletion event
trackServerEvent(userId, "user_deleted", undefined);
Why update instead of delete?
Our identifyServerUser function updates three providers:
const serverAnalyticsProviders = [
{
id: "posthog",
identifyServerUser: identifyPosthogUser,
},
{
id: "loops",
identifyServerUser: (userId: string, traits: Partial<UserTraits>) => {
const email = traits.email!; // Now deleted-{userId}@deleted.local
return createOrUpdateLoopsContact(email, { ...traits, userId });
},
},
{
id: "vercel",
// Vercel Analytics doesn't have identify method
},
];
Important: If your jurisdiction requires complete data deletion from all systems, you'll need to use PostHog's delete person API and Loops' delete contact functionality instead of anonymization.
We implemented user deletion in two places:
// packages/api/src/router/userRouter.ts
delete: protectedProcedure
.input(z.string())
.mutation(async ({ ctx, input }) => {
const deletionCheck = await checkUserDeletionDependencies(input);
if (!deletionCheck.canDelete) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Cannot delete user: ${deletionCheck.blockers.join(", ")}`,
});
}
const { anonymizedUser } = await deleteUser(input);
trackServerEvent(ctx.session.user.id, "user_deleted", undefined);
return anonymizedUser;
})
// packages/api/src/router/adminRouter.ts
deleteUser: homiAdminProcedure
.input(z.object({ userId: z.string() }))
.mutation(async ({ ctx, input }) => {
// Prevent admins from deleting themselves
if (input.userId === ctx.session.user.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot delete your own account",
});
}
const deletionCheck = await checkUserDeletionDependencies(input.userId);
// ... same deletion logic
});
Both routes call the same deleteUser() service, ensuring consistency.
We built two deletion interfaces:
At /app/account, users see a red "Danger Zone" card:
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<p className="text-muted-foreground text-sm">
Irreversible and destructive actions for your account.
</p>
</CardHeader>
<CardContent>
<div className="flex flex-col space-y-4">
<p className="text-sm">
Once you delete your account, there is no going back. All your personal
data will be permanently anonymized. You must delete or transfer
ownership of all collections you own before deleting your account.
</p>
<DeleteAccountButton userId={session.user.id} />
</div>
</CardContent>
</Card>
Features:
At /admin/users, admins can delete users with:
Result: A GDPR-compliant deletion system that respects user privacy, maintains data integrity, and works consistently across all services.
Want to see the full implementation? Check out our deletion service to see how we:
No more waiting, the search for your next home starts now. Join thousands of families who've made their home hunting easier with Homi.

Have questions about our implementation? Want to discuss GDPR compliance strategies? Reach out to us at engineering@homi.so or book a chat.
We're always learning and improving our approach to privacy and data protection. If you've implemented user deletion differently, we'd love to hear about it!

Engineering at Homi, building the future of real estate technology.
Continue reading with these related articles
Remember when we redesigned our Add Property dialog and wrote about it? Turns out that design also didn't survive. Here's how we got it right (this time, we think).
How we built a campaign-ready sync system for Loops that computes dynamic user segments on-demand without polluting our database schema or scattering one-off updates throughout our codebase.
How we rebuilt our 'Add Property' dialog three times in one session based on real user feedback. A case study in iterative design and the importance of staying flexible.