Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Audit logging in Next.js App Router with Drizzle ORM -- tracking user actions for SaaS

June 18, 2026
nextjsdrizzle-ormsaasdatabaseaudit-log

Audit logging in Next.js App Router with Drizzle ORM -- tracking user actions for SaaS

When a user says "I didn't delete that," you want to be able to prove them right or wrong. An audit log solves that -- and it covers compliance requirements, debugging production issues, and building a user-visible activity feed.

This post shows the full pattern: schema, repository, service helper, and a server component that renders the log.

The schema

Add an audit_logs table. Each row records who did what to what, and when.

// modules/auditLog/auditLog.schema.ts
import { pgTable, uuid, text, jsonb, timestamp, index } from 'drizzle-orm/pg-core';
import { userTable } from '../user/user.schema';
 
export const auditLogTable = pgTable(
  'audit_logs',
  {
    id: uuid('id').defaultRandom().primaryKey(),
    actorId: uuid('actor_id').references(() => userTable.id, { onDelete: 'set null' }),
    action: text('action').notNull(),
    resourceType: text('resource_type').notNull(),
    resourceId: text('resource_id').notNull(),
    metadata: jsonb('metadata'),
    createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  },
  (t) => [
    index('audit_logs_actor_id_idx').on(t.actorId),
    index('audit_logs_resource_idx').on(t.resourceType, t.resourceId),
    index('audit_logs_created_at_idx').on(t.createdAt),
  ]
);

The metadata column holds arbitrary JSON -- useful for recording before/after values without designing a separate column for every case.

Register the table in db/drizzle.ts and run the migration:

npm run db:generate
npm run db:migrate

The repository

Insert and query -- nothing else.

// modules/auditLog/auditLog.repo.ts
import { db } from '@/db/drizzle';
import { auditLogTable } from './auditLog.schema';
import { desc, eq, and } from 'drizzle-orm';
import type { AuditLogInsert, AuditLogFilter } from './auditLog.types';
 
export const auditLogRepo = {
  async create(data: AuditLogInsert) {
    const [row] = await db.insert(auditLogTable).values(data).returning();
    return row;
  },
 
  async findByResource(filter: AuditLogFilter) {
    return db
      .select()
      .from(auditLogTable)
      .where(
        and(
          eq(auditLogTable.resourceType, filter.resourceType),
          eq(auditLogTable.resourceId, filter.resourceId)
        )
      )
      .orderBy(desc(auditLogTable.createdAt))
      .limit(filter.limit ?? 50);
  },
 
  async findByActor(actorId: string) {
    return db
      .select()
      .from(auditLogTable)
      .where(eq(auditLogTable.actorId, actorId))
      .orderBy(desc(auditLogTable.createdAt))
      .limit(100);
  },
};

The types

// modules/auditLog/auditLog.types.ts
export interface AuditLogInsert {
  actorId?: string | null;
  action: string;
  resourceType: string;
  resourceId: string;
  metadata?: Record<string, unknown>;
}
 
export interface AuditLogFilter {
  resourceType: string;
  resourceId: string;
  limit?: number;
}

The service helper

A single log() function that every other service can call. It never throws -- a failed audit write should not break the operation being logged.

// modules/auditLog/auditLog.service.ts
import { auditLogRepo } from './auditLog.repo';
import type { AuditLogInsert } from './auditLog.types';
 
export const auditLogService = {
  async log(entry: AuditLogInsert): Promise<void> {
    try {
      await auditLogRepo.create(entry);
    } catch (err) {
      console.error('[audit] failed to write log entry', err);
    }
  },
 
  async getForResource(resourceType: string, resourceId: string) {
    return auditLogRepo.findByResource({ resourceType, resourceId });
  },
 
  async getForActor(actorId: string) {
    return auditLogRepo.findByActor(actorId);
  },
};

The try/catch is intentional. An audit write failure is a logging concern, not a user-facing error.

Calling it from another service

Add audit calls after the primary operation succeeds. The actor comes from the route via getUserFromRequest -- services should never call that themselves.

// modules/post/post.service.ts (excerpt)
import { auditLogService } from '../auditLog/auditLog.service';
 
export const postService = {
  async deletePost(postId: string, actorId: string) {
    const post = await postRepo.findById(postId);
    if (!post) throw new HttpError(404, 'Post not found');
    if (post.authorId !== actorId) throw new HttpError(403, 'Forbidden');
 
    await postRepo.delete(postId);
 
    await auditLogService.log({
      actorId,
      action: 'post.deleted',
      resourceType: 'post',
      resourceId: postId,
      metadata: { title: post.title },
    });
  },
};

Use dot-separated action names: post.deleted, user.role_changed, billing.subscription_cancelled. They are easy to filter and read in the UI without parsing.

Displaying the log

A server component that renders the log for a given resource -- no client-side JavaScript needed.

// components/AuditLogList.tsx
import { auditLogService } from '@/modules/auditLog/auditLog.service';
 
interface Props {
  resourceType: string;
  resourceId: string;
}
 
export async function AuditLogList({ resourceType, resourceId }: Props) {
  const entries = await auditLogService.getForResource(resourceType, resourceId);
 
  if (entries.length === 0) {
    return <p className="text-muted-foreground text-sm">No activity yet.</p>;
  }
 
  return (
    <ul className="space-y-2">
      {entries.map((entry) => (
        <li key={entry.id} className="flex items-start gap-3 text-sm">
          <span className="text-muted-foreground shrink-0 tabular-nums">
            {new Date(entry.createdAt).toLocaleString()}
          </span>
          <span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
            {entry.action}
          </span>
          {entry.metadata && (
            <span className="text-muted-foreground truncate">
              {JSON.stringify(entry.metadata)}
            </span>
          )}
        </li>
      ))}
    </ul>
  );
}

Drop it into any server component page with two props:

<AuditLogList resourceType="post" resourceId={post.id} />

What to log

Log the actions that matter for support and compliance:

  • Auth events: login, logout, password reset
  • Resource mutations: create, update, delete
  • Permission changes: role assignment, org membership
  • Billing events: subscription created, cancelled, payment failed

Skip high-frequency reads -- they produce noise without signal and inflate the table fast.

Takeaway

One auditLogService.log() call at the end of each mutating service method, actor passed in from the route, action name in dot notation. The try/catch in the service means a write failure is logged but never surfaces to the user. From there, AuditLogList drops into any server component page with two props -- no extra API route, no client hooks, no loading state to manage.