# Audit Log UI in React: Filters, Diffs and CSV Export

> Build an audit log UI in React that people can use: who did what to which record, day groups, filters, before and after diffs, CSV export and the event shape.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 10 min
- Canonical: https://wingo-ui.com/blog/audit-log-ui

## TL;DR

A usable audit log UI answers one question per row: who did what, to which record, when, and what changed. Group rows by day, filter by user, action, record type, severity and period, expand a row into a before and after diff, and export exactly the filtered rows. The UI only works if the backend stores each event as one append-only row with the actor, action key, target, changed fields and request context.

Sooner or later a customer asks "who deleted this invoice?" and you open a table of 40,000 rows with columns like `event_type` and `payload`. An audit log UI that people can actually use turns that table into sentences: Jessica Dunn voided Invoice #2026-0398, yesterday at 4:35 PM, from Firefox on Windows, and the status went from Issued to Voided. This guide covers the screen and the data behind it: day groups, filters, before and after diffs, CSV export, the phone layout, and the event shape your backend should store so the UI has something good to show.

Good audit log UI design is mostly about what happens once there are thousands of events: how filters reach the server, what the export contains, how a diff reads on a 390px screen, and what to store so a row can say more than "record updated". The working example throughout is the [Audit Log](https://wingo-ui.com/components/audit-log) component.

## What does a usable audit log UI show on each row?

Each row is one sentence with four parts: **who** (avatar and name, or a clear marker for the system, an API key or an integration), **did what** (a verb, not an event key), **to which record** (a link to it) and **when**. Severity and IP get their own columns because investigators filter and scan by them. Everything else (device, location, request id, the changed fields) lives in the expanded row, because it matters for one event at a time.

Three habits make the difference between a log people read and a log people export to a spreadsheet out of frustration:

1. **Day headings.** "Today", "Yesterday", "Monday, September 21" as sticky headings, with the clock time on each row. The absolute time stays readable without hovering.
2. **Verbs from a dictionary.** Store `invoice.cancelled` in the database and map it to "voided" with an icon and a tone in the UI. The key stays stable when the copy changes.
3. **Weight by risk.** If every row looks the same, the role change and the failed sign-ins hide among hundreds of routine edits. Quiet styling for info, soft badges for notice, warning and critical.

## How do you render a React audit log?

Install the component (it brings the table, filter builder, period picker and CSV helpers with it):

```bash
npx wingo-ui@latest add audit-log
```

Then pass events and an action dictionary. The `actions` map turns keys into verbs, icons and tones, and `group` sorts them in the filter list:

```tsx
"use client";

import { FilePlus2, FileX2, KeyRound, Pencil } from "lucide-react";
import { AuditLog, type AuditActionDef, type AuditEvent } from "@/components/ui/audit-log";

const actions: Record<string, AuditActionDef> = {
  "invoice.issued": { label: "issued", icon: <FilePlus2 />, tone: "info", group: "Invoices" },
  "invoice.cancelled": { label: "voided", icon: <FileX2 />, tone: "warning", group: "Invoices" },
  "client.updated": { label: "updated", icon: <Pencil />, group: "Clients" },
  "member.role_changed": { label: "changed the role of", icon: <KeyRound />, tone: "danger", group: "Team" },
};

export function ActivityLog({ events }: { events: AuditEvent[] }) {
  return (
    <AuditLog
      events={events}
      actions={actions}
      targetTypes={{ invoice: "Invoice", client: "Client", member: "Member" }}
      currency="USD"
      locale="en-US"
      exportFileName="activity-log"
    />
  );
}
```

With no other props you get search (actor, sentence, record and IP, accents ignored), a period picker with quick ranges, filter chips for user, action, record type, severity and IP, sticky day headings, expandable rows and a CSV button. Try it below.

> Live demo (Audit Log): Search for an invoice, add a filter chip for one user, then click a row to open its before and after values and copy the request id. Try it at [Audit Log](https://wingo-ui.com/components/audit-log) and install it with `npx wingo-ui@latest add audit-log`.

## Which filters does an audit trail UI need?

Investigators ask narrow questions: everything Liam did last Tuesday, every deletion this month, every critical event from one IP. The filters are the main tool on this screen, so the defaults cover those questions:

| Filter | Why people use it |
|---|---|
| Period | Every investigation starts with a time window |
| User | One person's actions, including API keys and integrations |
| Action | Deletions only, role changes only |
| Record type | All changes to invoices, or to team members |
| Severity | Warnings and critical events first |
| IP | Was it the usual network or a new one? |

Saved views turn the common questions into one click. Pass `views` with a name and a filter group, built with `createFilterGroup` and `createFilterCondition` from `lib/filters` (the same model the [Filter Builder](https://wingo-ui.com/components/filter-builder) uses):

```tsx
import { AuditLog } from "@/components/ui/audit-log";
import { createFilterCondition, createFilterGroup, type FilterView } from "@/lib/filters";

const views: FilterView[] = [
  {
    id: "risky",
    name: "Warnings and critical",
    filter: createFilterGroup("and", [
      createFilterCondition({ id: "severity", type: "select" }, { operator: "any-of", value: ["warning", "critical"] }),
    ]),
  },
];

<AuditLog events={events} actions={actions} views={views} />;
```

## How do you show what changed in an audit log?

Store the changed fields, not a free-text description. Each event carries a `changes` list of `{ field, label, from, to, type }`, and the row expands into a two-column before and after table. The `type` decides the formatting: `money` prints $7,315.20 in the log's currency, `date` prints a date, `boolean` prints Yes or No, `status` prints a tinted badge, and an empty value is shown muted so "was empty, now set" reads at a glance.

```ts
import type { AuditEvent } from "@/components/ui/audit-log";

const event: AuditEvent = {
  id: "evt_01J9Z",
  at: "2026-09-24T16:12:00Z",
  actor: { id: "anna", name: "Anna Price" },
  action: "client.updated",
  target: { type: "client", id: "c04", label: "Greenleaf Pharmacy", href: "/clients/c04" },
  changes: [
    { field: "balance", label: "Balance", type: "money", from: 7315.2, to: 0 },
    { field: "status", label: "Status", type: "status", from: { label: "Overdue", tone: "danger" }, to: { label: "Paid", tone: "success" } },
  ],
  ip: "86.124.52.18",
  device: "Chrome · macOS",
  requestId: "req_8f2c41",
};
```

The same table is exported as `AuditDiff` if you want it in a record's sidebar or a confirmation dialog. Below 448px of room it stacks the rows, so the diff stays readable on a phone instead of squeezing two columns.

Field diffs break down for long text: a contract clause, an email template, a policy document. Showing two 2,000-character values side by side in a table cell helps nobody. Keep a version id in `metadata`, link the record to its version page, and render the two versions in a [Diff Viewer](https://wingo-ui.com/components/diff-viewer) with word highlights. The [React diff viewer guide](https://wingo-ui.com/blog/react-diff-viewer) covers that component in depth.

> Live demo (Diff Viewer): Compare two versions of an agreement: only the changed words are marked, and the chevrons jump between changes. Try it at [Diff Viewer](https://wingo-ui.com/components/diff-viewer) and install it with `npx wingo-ui@latest add diff-viewer`.

## What event shape should the backend store?

One append-only row per event, written in the same transaction as the change it describes. If the write to the log can fail while the change succeeds, the log is incomplete and nobody can trust it. A Postgres table that maps one to one onto `AuditEvent`:

```sql
create table audit_events (
  id          text primary key,              -- a sortable id (ULID, UUIDv7)
  at          timestamptz not null default now(),
  actor_id    text not null,
  actor_name  text not null,                 -- the name at the time of the event
  actor_kind  text not null default 'user',  -- user | system | api | integration
  action      text not null,                 -- invoice.cancelled
  target_type text,
  target_id   text,
  target_label text,
  severity    text not null default 'info',  -- info | notice | warning | critical
  changes     jsonb,                         -- [{ field, label, from, to, type }]
  ip          inet,
  user_agent  text,
  request_id  text,
  metadata    jsonb
);

create index audit_events_at on audit_events (at desc);
create index audit_events_target on audit_events (target_type, target_id, at desc);
create index audit_events_actor on audit_events (actor_id, at desc);

-- the app role can add rows, never edit or delete them
revoke update, delete on audit_events from app_user;
```

A few decisions behind that shape:

- **Copy the actor name and target label.** People get renamed and records get deleted. The log has to say what was true at the time, so it cannot depend on a join.
- **Keep the action key machine-readable.** `resource.verb` keys group naturally (the component's record type filter falls back to the part before the dot) and survive copy changes.
- **Leave secrets out of `changes`.** The [OWASP Logging Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html) (read November 2026) lists passwords, access tokens, session ids, encryption keys, payment card data and sensitive personal data among the things to remove, mask or hash before logging. For a password change, log `{ field: "password", from: null, to: "changed" }` and nothing else.
- **Record reads that matter.** Exports, viewing another user's data and support access belong in the log too, not just writes.

## How do you page and filter a large audit log on the server?

In the default `mode="client"`, the component searches and filters the events you pass, which is right for a bounded window such as the last 90 days. It renders at most `maxRows` events (200 by default) and says so under the list, so a big client-side array still costs you the download and the filtering. Once the table grows past that, switch to `mode="server"`: the component stops filtering and calls `onQueryChange` 300ms after the search, period or filter last changed, with `{ search, period, filter }`. You fetch, then pass the page back with `totalCount`, `hasMore` and `onLoadMore`, and `refreshing` keeps the rows on screen with a thin progress bar while the request runs.

```tsx
"use client";

import { useState } from "react";
import { AuditLog, type AuditEvent, type AuditLogQuery } from "@/components/ui/audit-log";
import { serializeFilter } from "@/lib/filters";

export function ServerAuditLog({ first }: { first: { events: AuditEvent[]; total: number; cursor: string | null } }) {
  const [page, setPage] = useState(first);
  const [query, setQuery] = useState<AuditLogQuery | null>(null);
  const [refreshing, setRefreshing] = useState(false);

  async function load(q: AuditLogQuery | null, cursor: string | null) {
    const params = new URLSearchParams();
    if (q?.search) params.set("q", q.search);
    if (q?.period?.from) params.set("from", q.period.from.toISOString());
    if (q?.period?.to) params.set("to", q.period.to.toISOString());
    if (q) params.set("filter", serializeFilter(q.filter));
    if (cursor) params.set("cursor", cursor);
    const res = await fetch(`/api/audit-events?${params}`);
    return (await res.json()) as typeof first;
  }

  return (
    <AuditLog
      mode="server"
      events={page.events}
      totalCount={page.total}
      refreshing={refreshing}
      onQueryChange={async (q) => {
        setQuery(q);
        setRefreshing(true);
        try {
          setPage(await load(q, null));
        } finally {
          setRefreshing(false);
        }
      }}
      hasMore={page.cursor !== null}
      onLoadMore={async () => {
        const next = await load(query, page.cursor);
        setPage((prev) => ({ ...next, events: [...prev.events, ...next.events] }));
      }}
    />
  );
}
```

On the server, `parseFilter` reads the filter back, and you translate its conditions (fields `actor`, `action`, `type`, `severity`, `ip`) into a parameterized `where` clause. Page with a cursor on `(at, id)` rather than an offset, so new events arriving at the top do not shift the pages.

## How should the CSV export work?

The built-in button downloads the events that match the current search, period and filters, as `audit-log-2026-11-06.csv`. Cells that start with `=`, `+`, `-` or `@` are prefixed so a spreadsheet does not run them as formulas, and `csvDelimiter=";"` makes the file open correctly in Excel set to a European locale. The time column uses the viewer's local time, to the minute.

That is right for "send me what I am looking at". It is the wrong file for a security questionnaire, which wants every matching event, in UTC, with seconds. Pass `onExport`: it receives the visible events and the current query, and a returned promise puts the button in its loading state while your server builds the file. Log the export itself as an event (`audit_log.exported`), since a download of the full history is exactly the kind of read a reviewer wants to see.

## What does an audit log look like on a phone?

Below 768px the table becomes a feed of day groups. Each row is the sentence and the time; a tap expands the diff and the details inline, the record link moves into the details, and the period and filters open as bottom sheets. Load more becomes a full-width button, or loads on scroll with `autoLoadMore`. This follows the same rule as any [responsive table](https://wingo-ui.com/blog/responsive-table-mobile-cards): on a phone, rows become cards instead of a sideways scroll.

## When should you use a timeline or a data table instead?

Use a [Timeline](https://wingo-ui.com/components/timeline) for the history of one record: the activity panel on a client page, where twenty events of one customer read better as a vertical story with icons than as a filterable table. Feed it from the same `audit_events` rows, filtered by `target_type` and `target_id` (that is what the second index is for).

Use a [Data Table](https://wingo-ui.com/components/data-table) when the screen is mostly about columns: sorting by any field, selecting rows, hiding columns. It is the general tool; the audit log is the specialized one with sentences, diffs and day groups built in. If what you need is an event log UI for raw application logs (lines of text with levels, streaming in), the [log viewer guide](https://wingo-ui.com/blog/react-log-viewer) is the better fit.

If you are building the whole team admin screen, the [role management UI tutorial](https://wingo-ui.com/blog/role-management-ui) shows the audit log as one tab next to members and a permissions matrix, all in the [Users Admin Page](https://wingo-ui.com/components/users-admin-page) block. More guides like this one are in the [component guides category](https://wingo-ui.com/blog/category/components), starting from the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide). When people need to combine conditions (this user AND this record type, OR any delete), the [React filter builder](https://wingo-ui.com/blog/react-filter-builder) guide covers AND / OR chips and saved views.

## Where to go from here

Start with the event table: write one row per change inside the same transaction, with the actor name, an action key, the target and the changed fields. Once that is in place, the screen is mostly configuration. The Audit Log, Diff Viewer, Timeline and Data Table are Pro components (plans start at $8 a month on the [pricing page](https://wingo-ui.com/pricing)); the docs and demos are public, so you can try every prop before you decide, and a free account gets you the source of the 75 free items.

## Components in this post

- [Audit Log](https://wingo-ui.com/components/audit-log) (Pro): A React audit log table: who did what, when and to which record, with filters, day groups, before / after diffs and a CSV export. Install: `npx wingo-ui@latest add audit-log`
- [Diff Viewer](https://wingo-ui.com/components/diff-viewer) (Pro): A React diff viewer for two texts: split or unified views, word highlights, collapsed context, syntax colors and jumps between changes. Install: `npx wingo-ui@latest add diff-viewer`
- [Timeline](https://wingo-ui.com/components/timeline) (Pro): A React timeline component for activity feeds, audit trails, changelogs and order tracking, with icons, relative times and day groups. Install: `npx wingo-ui@latest add timeline`
- [Data Table](https://wingo-ui.com/components/data-table) (Pro): A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: `npx wingo-ui@latest add data-table`

## FAQ

### What should an audit log entry contain?

The actor (person, API key or integration), an action key such as invoice.cancelled, the target record with its type and id, an exact timestamp, the changed fields with before and after values, and request context: IP, user agent and a request id. Severity is optional but makes filtering for risky events much faster.

### What is the difference between an audit log and an activity feed?

An activity feed is for coordination and may summarize or skip minor events. An audit log is a complete, append-only record that a security reviewer or customer can rely on, so it needs exact times, precise attribution, filters and an export.

### Should an audit log show relative times like 5 min ago?

Relative times are fine for today's rows when the absolute time is still visible nearby, for example under a day heading. For reviews and exports, show the full date and time and say which time zone it is in.

### How do I show an audit log on a phone?

Turn the table into a feed of day groups where each row is one sentence (actor, verb, record) with the time, and tap to expand the diff and details inline. Open filters and the period picker as bottom sheets instead of popovers.

### Should the CSV export run in the browser or on the server?

The browser is fine for the rows already loaded, such as the last 90 days. For a full compliance export, build the file on the server so it covers every matching event, uses UTC timestamps and is itself recorded as an audit event.

---

Source: https://wingo-ui.com/blog/audit-log-ui
