How to implement useful audit logs in a Next.js app with Prisma: schema, helper, retention and common pitfalls.
Audit logs are the record of who did what and when in your app. They sound boring until the day a B2B customer asks "who deleted my project?" or a security researcher spots suspicious access. Without an audit log: you have no answer. With one: you do.
This guide explains how to implement audit logs in a Next.js app with Prisma in a way that's useful in production and doesn't become a runaway table.
Don't log everything. That floods the DB and tanks performance. Log:
Do NOT log:
model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now())
userId String?
organizationId String?
action String // e.g., 'user.banned', 'project.deleted'
resource String? // e.g., 'project:abc-123'
metadata Json? // additional data
ip String?
userAgent String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
organization Organization? @relation(fields: [organizationId], references: [id], onDelete: SetNull)
@@index([organizationId, createdAt])
@@index([userId, createdAt])
@@index([action, createdAt])
}onDelete: SetNull is important: if the user is deleted, the log stays. Audit logs must outlive the
actors they record.
src/lib/audit/log.ts:
import { db } from '@/lib/db/client';
import { headers } from 'next/headers';
export async function logAudit(params: {
userId?: string;
organizationId?: string;
action: string;
resource?: string;
metadata?: Record<string, unknown>;
}) {
const h = await headers();
const ip = h.get('x-forwarded-for') ?? h.get('x-real-ip') ?? null;
const userAgent = h.get('user-agent') ?? null;
await db.auditLog.create({
data: {
...params,
metadata: params.metadata as never,
ip,
userAgent,
},
});
}And use it:
await logAudit({
userId: session.user.id,
organizationId: orgId,
action: 'project.deleted',
resource: `project:${projectId}`,
metadata: { projectName: project.name },
});Always use resource.verb:
user.created, user.banned, user.deletedproject.created, project.deleted, project.transferredsubscription.created, subscription.canceledauth.login.success, auth.login.failedConsistency from day 1. If each dev names their own way, in 6 months you can't find anything.
The audit log must NOT block the main operation. If the log fails, the operation continues.
Wrong:
await db.project.delete({ where: { id } });
await logAudit({ action: 'project.deleted', resource: `project:${id}` });If logAudit fails, it throws and the response to the client is error → but the project is ALREADY
deleted. Confused customer.
Right:
await db.project.delete({ where: { id } });
logAudit({ action: 'project.deleted', resource: `project:${id}` }).catch(console.error);Or better: an async queue (Inngest, BullMQ) for audit logs.
'use client';
export function AuditLogTable({ logs }: { logs: AuditLog[] }) {
return (
<table>
<thead>
<tr>
<th>Date</th>
<th>User</th>
<th>Action</th>
<th>Resource</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{logs.map((log) => (
<tr key={log.id}>
<td>{log.createdAt.toLocaleString()}</td>
<td>{log.user?.email ?? 'system'}</td>
<td>
<code>{log.action}</code>
</td>
<td>{log.resource}</td>
<td>{log.ip}</td>
</tr>
))}
</tbody>
</table>
);
}With filters by user, date, action, and org.
Logs grow. Decide how long you keep them:
Scheduled job that deletes old logs:
await db.auditLog.deleteMany({
where: { createdAt: { lt: subDays(new Date(), 365) } },
});If you must keep everything for compliance, export to S3 before deleting.
1. Logging passwords or tokens: never put sensitive fields in metadata. Filter before logging.
2. Table without indexes: in production with millions of rows, a query without an index takes
minutes. Index by organizationId, createdAt.
3. Logging from noisy middlewares: if you log every request, in 1 month you have a table of millions of useless rows. Be selective.
Audit logs are 1-2 hours of implementation if you do it from the start, or a huge pain if you add them when there's already production data. Worth doing on day 1.
And when a customer asks "who did X?", you have an answer. That, in B2B, is gold.
Subscribe for more tutorials and tips on building products with AI
The actual integration of Tailwind v4 + shadcn 2.3.0 in Next.js 16 App Router. OKLCH, FOUC-free dark mode, Geist with next/font, and the silent hsl(var()) bug that breaks your styles without warning.