How to add an embedded audit log to a React SaaS app
Covers
React, TypeScript, tenant scoping, and audit event design
A credible audit log is more than a table of events. Build the write path, tenant-aware viewer, visibility model, and privacy escape hatch as one customer-facing feature.
An embedded audit log is a complete product feature
An application log helps your engineers debug a service. A customer-facing audit log answers a different question: who did what, to which customer resource, and when? The customer expects that answer to remain trustworthy across user actions, background jobs, approvals, and operational failures.
That makes the React timeline the visible end of a larger system. Before building the table, define four connected paths:
- 1 A trusted server records meaningful domain outcomes with stable event names and idempotency keys.
- 2 Every event is stored inside an enforced organization boundary.
- 3 The signed-in viewer reaches the log through a server proxy that supplies their identity and permissions.
- 4 The React UI provides filters, chronological context, details, loading and failure states without becoming an authorization boundary.
01 · RECORD
Trusted server
Writes the completed domain fact.
02 · PRESERVE
Tenant event store
Scopes, deduplicates, and retains it.
03 · AUTHORIZE
Session proxy
Binds the organization and viewer.
04 · EXPLAIN
React viewer
Filters and inspects visible events.
The implementation below uses incld to combine its schedules, approvals, and bulk-operation lifecycle events with events from your application. The event contract and trust boundary still apply if you build the storage and viewer yourself.
Design the audit event contract before the UI
Audit events should describe completed facts, not implementation noise. Prefer a stable, past-tense namespace such as
report.exported
over a sentence that changes whenever product copy changes. Keep the human label in the React layer.
| Field | Production rule |
|---|---|
| type | Use a stable dotted name for the completed fact, such as member.invited. |
| actor | Take the user or service identity from trusted server context, never a browser field. |
| subject | Store the application resource type and ID customers will investigate. |
| organization | Enforce it as a storage and query boundary, not only a display filter. |
| occurredAt | Record when the domain event happened; keep ingestion time separately. |
| data | Include useful, non-secret context that explains the change without copying the record. |
| visibility | Decide whether every authorized tenant viewer, participants, or named viewers may read it. |
| idempotency key | Tie retries to the one logical outcome so the timeline does not duplicate it. |
Record the outcome on a trusted server path
Write the audit event after the domain operation reaches the state named by the event. If an export is merely queued, record report.export_requested. Record
report.exported
only when the export actually completes.
import { Incld } from "@incld/client"
export async function recordReportExported({ session, report, exportJob }) {
const tenantIncld = new Incld({
apiKey: process.env.INCLD_SECRET_KEY!,
scope: {
organizationId: session.organizationId,
userId: session.user.id,
},
})
return tenantIncld.auditEvents.create(
{
type: "report.exported",
subjectType: "report",
subjectId: report.id,
visibility: "participants",
participantIds: report.viewerIds,
data: { format: exportJob.format, rowCount: exportJob.rowCount },
occurredAt: exportJob.completedAt,
},
{ idempotencyKey: `report-exported:${exportJob.id}` },
)
}
The scoped server client supplies the organization and actor. The idempotency key comes from the export job, so retrying the completion handler returns the original logical event instead of producing a second row. The event is visible to the actor and the report’s known viewers.
incld also writes lifecycle events for schedules, approvals, and bulk operations. Manual application events use the
custom
component, which lets one timeline show both product infrastructure and domain outcomes.
Bind organization and viewer identity at the server proxy
Install the browser client, shared React provider, and audit components. The secret key remains on your server.
npm install @incld/client @incld/react @incld/react-audit
Create one server-only integration that resolves the active user and organization from your existing session. Its authorization callback should grant
audit.read
only to users who may open the customer audit log. Grant
audit.create
separately if the browser is allowed to record a user-initiated event.
import "server-only"
import { createIncld } from "@incld/client"
import { auth } from "@/lib/auth"
import { can } from "@/lib/permissions"
export const incld = createIncld({
apiKey: process.env.INCLD_SECRET_KEY!,
webhookSecret: process.env.INCLD_WEBHOOK_SECRET!,
async resolveContext() {
const session = await auth()
if (!session?.user.id || !session.organizationId) return null
return {
user: { id: session.user.id },
organization: { id: session.organizationId },
permissions: session.user.permissions,
}
},
async authorize({ context, operation }) {
return can(context.user.id, context.organization.id, operation)
},
})
import { incld } from "@/lib/incld"
export const dynamic = "force-dynamic"
export const { GET, POST, PATCH, DELETE } = incld.routes
The proxy strips protected identity fields from browser input. On audit reads it injects the organization and current viewer; on browser-authorized writes it injects the current actor. Changing a query string or JSON field therefore cannot switch the tenant or impersonate another user.
Embed filters, timeline, and event details in React
Mount the shared provider once around the authenticated part of the application. It points browser requests at the proxy route and controls theming, error reporting, and background refresh.
"use client"
import { IncldProvider, type IncldProviderProps } from "@incld/react"
export function Providers({ children }: { children: IncldProviderProps["children"] }) {
return (
<IncldProvider
baseUrl="/api/incld"
appearance={{ colorScheme: "system", accentColor: "indigo" }}
onError={(error) => console.error(error.code, error.requestId)}
>
{children}
</IncldProvider>
)
}
Compose the audit page from the filters, timeline, and details components. Keeping selection in your page makes it easy to place details in a drawer, modal, or second column without coupling that decision to the data layer.
"use client"
import { useState } from "react"
import type { AuditEvent, ListAuditEventsParams } from "@incld/client"
import {
AuditEventDetails,
AuditFilters,
AuditTimeline,
} from "@incld/react-audit"
import "@incld/react/styles.css"
import "@incld/react-audit/styles.css"
export function CustomerAuditLog() {
const [filters, setFilters] = useState<ListAuditEventsParams>({
components: ["schedules", "approvals", "bulk", "custom"],
})
const [selected, setSelected] = useState<AuditEvent>()
return (
<section aria-labelledby="audit-log-title">
<h1 id="audit-log-title">Audit log</h1>
<AuditFilters value={filters} onChange={setFilters} />
<AuditTimeline
filters={filters}
pageSize={25}
onSelect={setSelected}
/>
{selected && <AuditEventDetails event={selected} />}
</section>
)
}
AuditFilters
renders controls for component, event-type prefix, and start date. The timeline also accepts exact type, actor, subject, end date, limit, and cursor filters. Use
renderItem
when customer-facing labels or linked resource names need to replace the default row while preserving the same scoped query.
Mounted views refresh through the provider, pause when the tab is hidden, and refresh when it becomes visible. Keep empty, loading, and retry states in the final design: an empty audit log and a failed audit request must not look identical.
Choose event visibility explicitly
Tenant scope answers which customer owns the event. Visibility answers which people inside that customer may read it. Treat them as separate controls.
| Visibility | Use it when | Viewer rule |
|---|---|---|
| project | Tenant administrators may inspect the shared operational history. | Your proxy authorizes audit.read for the viewer. |
| participants | Only people involved in a request, report, or approval should see it. | The actor or a participant ID matches the viewer. |
| restricted | A sensitive event belongs to an explicitly named review group. | The actor or an allowed viewer ID matches the viewer. |
Defaulting every event to project-wide visibility can expose sensitive activity to a broader customer role than intended. Defaulting everything to participants can make an administrator’s incident investigation incomplete. Choose at event-design time, document the rule beside the event type, and test both allowed and denied viewers.
Plan for corrections, erasure, and partial failure
Normal audit history should be append-only. If a label or business fact was wrong, append a correction event that references the same subject. Rewriting history silently makes later investigations harder to trust.
Privacy erasure is different. For a data-subject request or accidentally captured sensitive value, a trusted organization-scoped server may tombstone an event. incld erases identity, viewer lists, idempotency material, and payload data while preserving the event envelope and appending an accountable erasure event.
const privacyIncld = new Incld({
apiKey: process.env.INCLD_SECRET_KEY!,
scope: { organizationId: privacyRequest.organizationId },
})
await privacyIncld.auditEvents.tombstone(eventId, {
reason: "data_subject_erasure",
actorId: privacyOperator.id,
})
| Failure | Expected product behavior |
|---|---|
| Domain action succeeds; audit write times out | Retry with the same event idempotency key. |
| Audit write succeeds; response is lost | The same retry returns the original event. |
| Viewer loses permission | The next proxy request fails authorization; React state is not authority. |
| Selected event is outside viewer scope | The direct lookup returns not found or forbidden inside the same tenant boundary. |
| Event contains unnecessary PII | Tombstone it, append the erasure record, then fix the writer contract. |
A compact production checklist
- 1 Event types are stable completed facts with documented actor, subject, data, and visibility rules.
- 2 Organization and actor identity come from trusted server scope on every write.
- 3 Each logical event has a stable idempotency key and a deliberate occurredAt value.
- 4 The browser reaches audit data only through an authenticated, authorized server proxy.
- 5 Viewer and organization IDs cannot be supplied or overridden by React props, query strings, or JSON.
- 6 List and direct event lookups enforce the same tenant and viewer boundary.
- 7 The React page distinguishes loading, empty, error, and populated states.
- 8 Payloads omit secrets and unnecessary personal data.
- 9 Corrections append history; an authorized server workflow handles PII tombstones.
- 10 Cross-tenant, disallowed-viewer, duplicate-delivery, and erasure paths have tests.
The key design is simple: your server decides what happened and who is asking; the audit service preserves and scopes the record; React makes that record useful to the customer. Keep those responsibilities separate and the embedded viewer becomes a trustworthy feature instead of a decorative activity feed.
Embed one customer-facing timeline across your SaaS operations.
incld combines lifecycle and application events with tenant-aware visibility, React UI, and a server-only privacy path.
Related engineering notes
How to build customer-facing recurring schedules in Next.js
The cron expression is the easy bit. Here is the complete path from a customer choosing a time to your application safely doing the work.
IANA timezones, DST, misfires, and overlap policies for SaaS schedules
“Every day at 9” is a product promise, not a UTC interval. Model the promise first, then decide what should happen when time or execution gets awkward.