Skip to main content
Back to Blog
Tutorials
5 min read
November 28, 2024

How to Implement Audit Logging in Next.js

Add comprehensive audit logging to your Next.js application for security compliance, debugging, and user activity tracking.

Ryel Banfield

Founder & Lead Developer

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.

audit loggingsecuritycomplianceNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles