React x Node

A React frontend talking to a Node/Express backend that records audit events via sdk-node and serves the React bundle on the same port. Comes in single-tenant and multi-tenant variants.

Source: https://github.com/everscribe/examples/tree/main/react-fe-with-node-be

Stack

Layer What it is
Frontend React 18, Vite, @everscribe/components-react
Backend Node 20+, Express, tsx, @everscribe/sdk-node (Recorder + Minter), express.static('frontend/dist')
Domain In-memory secrets vault
Audit panel <AuditTrail /> mounted under the vault UI

Run it

git clone https://github.com/everscribe/examples
cd examples/react-fe-with-node-be/single-tenant   # or .../multi-tenant

cp .env.example .env   # EVERSCRIBE_PROJECT_ID + EVERSCRIBE_API_KEY
npm install
cd frontend && npm install && npm run build && cd ..

npm start

Server on :3000 serves the React bundle and the API.

Backend integration

Construct the recorder once at boot:

import { create } from "@everscribe/sdk-node";
import * as recorder from "@everscribe/sdk-node/recorder";
import * as minter from "@everscribe/sdk-node/minter";

const es = create(process.env.EVERSCRIBE_PROJECT_ID!, process.env.EVERSCRIBE_API_KEY!);
const rec = es.newRecorder({ flushInterval: 2_000 });
const m = new minter.Client(process.env.EVERSCRIBE_PROJECT_ID!, process.env.EVERSCRIBE_API_KEY!);

Recording from a route handler (the reveal verb):

app.post("/api/secrets/:id/reveal", async (req, res) => {
    const actor = actorFor(req);
    try {
        const secret = await vault.reveal(req.params.id);
        await rec.record({
            action: "secret.reveal",
            actor,
            target: { type: "secret", id: req.params.id },
            result: { status: "ok" },
        });
        res.json({ value: secret.value });
    } catch (err) {
        await rec.record({
            action: "secret.reveal",
            actor,
            target: { type: "secret", id: req.params.id },
            result: { status: "error", message: String(err) },
        });
        res.status(500).json({ error: String(err) });
    }
});

record() is buffered — the await is on enqueueing, not the HTTP flush. Latency stays sub-millisecond.

Minting an embed token (multi-tenant customer view):

app.get("/api/embed-token/customer", async (req, res) => {
    const user = userFor(req);
    try {
        const token = await m.mintToken({
            tenantId: user.tenantId,
            expiresIn: 60 * 60 * 1000,
            allowedColumns: ["occurred_at", "action", "actor", "target"],
        });
        res.json({ token });
    } catch {
        res.status(500).json({ error: "mint failed" });
    }
});

The admin view drops tenantId so the same component renders every event in the project.

Frontend integration

Same one-liner as React + Go:

import { AuditTrail } from "@everscribe/components-react";
import "@everscribe/components-styles/default.css";

<AuditTrail tokenEndpoint="/api/embed-token" />

For the multi-tenant customer view, attach the demo auth header via onTokenExpired:

<AuditTrail
    onTokenExpired={async () => {
        const res = await fetch("/api/embed-token/customer", {
            credentials: "include",
            headers: { "X-Demo-Actor": currentUser.id },
        });
        return (await res.json()).token;
    }}
/>

Single-Tenant vs Multi-Tenant

Single-tenant Multi-tenant
Token endpoint One — /api/embed-token Two — /api/embed-token/customer, /api/embed-token/admin
UI One vault view Tab strip: Customer view · Admin view
Seeded data One implicit tenant Acme, Initech
Audit-panel scope Whole project Customer: tenant-scoped · Admin: unscoped

What to take away

  • record() returns a promise but doesn't await the network. It awaits the enqueue. Don't pile up Promise.all on it expecting back-pressure.
  • The Recorder is one object per process. Construct once at boot, reuse across every request.
  • Frontend code is identical to the Go-backend variant. Backend choice doesn't touch the UI.

What next