Audit logs record who did what and when, which is essential for security, compliance, and debugging. Here is how to build a robust audit logging system.
Step 1: Database Schema
// db/schema.ts
import { pgTable, text, timestamp, uuid, jsonb, inet } from "drizzle-orm/pg-core";
export const auditLogs = pgTable("audit_logs", {
id: uuid("id").defaultRandom().primaryKey(),
userId: text("user_id"),
userEmail: text("user_email"),
action: text("action").notNull(),
resource: text("resource").notNull(),
resourceId: text("resource_id"),
details: jsonb("details").$type<Record<string, unknown>>(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
timestamp: timestamp("timestamp").defaultNow().notNull(),
});
Step 2: Audit Logger
// lib/audit.ts
import { db } from "@/db";
import { auditLogs } from "@/db/schema";
import { headers } from "next/headers";
interface AuditEvent {
userId?: string;
userEmail?: string;
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
}
export async function audit(event: AuditEvent) {
const headersList = await headers();
const ipAddress =
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
headersList.get("x-real-ip") ??
"unknown";
const userAgent = headersList.get("user-agent") ?? undefined;
await db.insert(auditLogs).values({
...event,
ipAddress,
userAgent,
});
}
// Convenience methods
export const auditActions = {
async userSignedIn(userId: string, email: string) {
await audit({
userId,
userEmail: email,
action: "user.signed_in",
resource: "session",
});
},
async userSignedOut(userId: string) {
await audit({
userId,
action: "user.signed_out",
resource: "session",
});
},
async resourceCreated(
userId: string,
resource: string,
resourceId: string,
details?: Record<string, unknown>
) {
await audit({
userId,
action: `${resource}.created`,
resource,
resourceId,
details,
});
},
async resourceUpdated(
userId: string,
resource: string,
resourceId: string,
changes: Record<string, { from: unknown; to: unknown }>
) {
await audit({
userId,
action: `${resource}.updated`,
resource,
resourceId,
details: { changes },
});
},
async resourceDeleted(
userId: string,
resource: string,
resourceId: string
) {
await audit({
userId,
action: `${resource}.deleted`,
resource,
resourceId,
});
},
async permissionChanged(
userId: string,
targetUserId: string,
oldRole: string,
newRole: string
) {
await audit({
userId,
action: "permission.changed",
resource: "user",
resourceId: targetUserId,
details: { oldRole, newRole },
});
},
async apiKeyCreated(userId: string, keyId: string) {
await audit({
userId,
action: "api_key.created",
resource: "api_key",
resourceId: keyId,
});
},
async exportRequested(userId: string, exportType: string) {
await audit({
userId,
action: "export.requested",
resource: "export",
details: { type: exportType },
});
},
};
Step 3: Usage in Server Actions
// app/projects/actions.ts
"use server";
import { db } from "@/db";
import { projects } from "@/db/schema";
import { eq } from "drizzle-orm";
import { auditActions } from "@/lib/audit";
import { auth } from "@/lib/auth";
export async function updateProject(projectId: string, data: { name: string; description: string }) {
const user = await auth();
if (!user) throw new Error("Unauthorized");
// Fetch current state for change tracking
const [current] = await db
.select()
.from(projects)
.where(eq(projects.id, projectId));
if (!current) throw new Error("Project not found");
// Track changes
const changes: Record<string, { from: unknown; to: unknown }> = {};
if (current.name !== data.name) {
changes.name = { from: current.name, to: data.name };
}
if (current.description !== data.description) {
changes.description = { from: current.description, to: data.description };
}
// Update
await db
.update(projects)
.set(data)
.where(eq(projects.id, projectId));
// Audit log
if (Object.keys(changes).length > 0) {
await auditActions.resourceUpdated(user.id, "project", projectId, changes);
}
}
export async function deleteProject(projectId: string) {
const user = await auth();
if (!user) throw new Error("Unauthorized");
await db.delete(projects).where(eq(projects.id, projectId));
await auditActions.resourceDeleted(user.id, "project", projectId);
}
Step 4: Audit Log Query API
// app/api/audit-logs/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { auditLogs } from "@/db/schema";
import { desc, eq, and, gte, lte, like, or } from "drizzle-orm";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const userId = searchParams.get("userId");
const action = searchParams.get("action");
const resource = searchParams.get("resource");
const from = searchParams.get("from");
const to = searchParams.get("to");
const search = searchParams.get("search");
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
const limit = Math.min(100, Math.max(1, Number(searchParams.get("limit") ?? 50)));
const conditions = [];
if (userId) conditions.push(eq(auditLogs.userId, userId));
if (action) conditions.push(eq(auditLogs.action, action));
if (resource) conditions.push(eq(auditLogs.resource, resource));
if (from) conditions.push(gte(auditLogs.timestamp, new Date(from)));
if (to) conditions.push(lte(auditLogs.timestamp, new Date(to)));
if (search) {
conditions.push(
or(
like(auditLogs.action, `%${search}%`),
like(auditLogs.resource, `%${search}%`),
like(auditLogs.userEmail, `%${search}%`)
)
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const logs = await db
.select()
.from(auditLogs)
.where(where)
.orderBy(desc(auditLogs.timestamp))
.limit(limit)
.offset((page - 1) * limit);
return NextResponse.json({ logs, page, limit });
}
Step 5: Audit Log Dashboard
// app/dashboard/audit/page.tsx
"use client";
import { useState, useEffect } from "react";
interface AuditLog {
id: string;
userId: string | null;
userEmail: string | null;
action: string;
resource: string;
resourceId: string | null;
details: Record<string, unknown> | null;
ipAddress: string | null;
timestamp: string;
}
export default function AuditDashboard() {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [filters, setFilters] = useState({
action: "",
resource: "",
search: "",
from: "",
to: "",
});
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
fetch(`/api/audit-logs?${params}`)
.then((r) => r.json())
.then((d) => setLogs(d.logs))
.finally(() => setLoading(false));
}, [filters]);
return (
<div className="container py-10">
<h1 className="mb-6 text-2xl font-bold">Audit Logs</h1>
{/* Filters */}
<div className="mb-6 grid gap-3 sm:grid-cols-4">
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value }))}
className="rounded-lg border px-3 py-2 text-sm"
/>
<select
value={filters.resource}
onChange={(e) => setFilters((f) => ({ ...f, resource: e.target.value }))}
className="rounded-lg border px-3 py-2 text-sm"
>
<option value="">All resources</option>
<option value="project">Projects</option>
<option value="user">Users</option>
<option value="session">Sessions</option>
<option value="api_key">API Keys</option>
</select>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters((f) => ({ ...f, from: e.target.value }))}
className="rounded-lg border px-3 py-2 text-sm"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value }))}
className="rounded-lg border px-3 py-2 text-sm"
/>
</div>
{/* Log table */}
<div className="overflow-hidden rounded-lg border">
<table className="w-full text-sm">
<thead className="border-b bg-gray-50">
<tr>
<th className="px-4 py-3 text-left">Time</th>
<th className="px-4 py-3 text-left">User</th>
<th className="px-4 py-3 text-left">Action</th>
<th className="px-4 py-3 text-left">Resource</th>
<th className="px-4 py-3 text-left">IP</th>
<th className="px-4 py-3 text-left">Details</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
Loading...
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No audit logs found.
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs text-gray-500">
{new Date(log.timestamp).toLocaleString()}
</td>
<td className="px-4 py-3 text-xs">
{log.userEmail ?? log.userId ?? "System"}
</td>
<td className="px-4 py-3">
<code className="rounded bg-gray-100 px-1.5 py-0.5 text-xs">
{log.action}
</code>
</td>
<td className="px-4 py-3 text-xs">
{log.resource}
{log.resourceId && (
<span className="text-gray-400"> #{log.resourceId.slice(0, 8)}</span>
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-500">
{log.ipAddress}
</td>
<td className="px-4 py-3 text-xs">
{log.details && (
<details>
<summary className="cursor-pointer text-blue-600">View</summary>
<pre className="mt-1 max-w-xs overflow-auto rounded bg-gray-50 p-2 text-xs">
{JSON.stringify(log.details, null, 2)}
</pre>
</details>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
Step 6: Export Audit Logs
// app/api/audit-logs/export/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { auditLogs } from "@/db/schema";
import { desc, and, gte, lte } from "drizzle-orm";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const from = searchParams.get("from");
const to = searchParams.get("to");
const conditions = [];
if (from) conditions.push(gte(auditLogs.timestamp, new Date(from)));
if (to) conditions.push(lte(auditLogs.timestamp, new Date(to)));
const logs = await db
.select()
.from(auditLogs)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(auditLogs.timestamp))
.limit(10000);
// CSV format
const csvHeader = "Timestamp,User,Action,Resource,Resource ID,IP Address\n";
const csvRows = logs
.map(
(log) =>
`"${log.timestamp}","${log.userEmail ?? log.userId ?? ""}","${log.action}","${log.resource}","${log.resourceId ?? ""}","${log.ipAddress ?? ""}"`
)
.join("\n");
return new NextResponse(csvHeader + csvRows, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="audit-logs-${new Date().toISOString().split("T")[0]}.csv"`,
},
});
}
Need Security and Compliance Features?
We implement audit logging, access controls, and compliance tooling for regulated industries. Contact us to discuss your requirements.