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 upPromise.allon 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.