React x Rust

A React frontend talking to a Rust/axum backend that records audit events via sdk-rust 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-rust-be

Stack

Layer What it is
Frontend React 18, Vite, @everscribe/components-react
Backend Rust (axum + tokio), everscribe (path = "../../../sdk-rust", features = ["axum"]), tower-http ServeDir for static
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-rust-be/single-tenant   # or .../multi-tenant

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

cargo run

Server on :9000 serves the React bundle and the API. The SDK is a path dependency on the sibling repo (everscribe = { path = "../../../sdk-rust" }).

Backend integration

Construct the recorder and minter once at boot, then add the middleware layer:

let es = everscribe::new(project_id, api_key)?;
let rec = es.new_recorder(Default::default());
let minter = es.new_minter(Default::default());

// EverscribeLayer installs a per-request event (actor + origin) and
// auto-records it on response finish when the handler set an action.
let api = Router::new()
    .route("/secrets/:id/reveal", post(reveal))
    // ...
    .layer(EverscribeLayer::new(rec, resolve_actor))
    .with_state(state);

Recording from a handler — enrich CurrentEvent; the layer records on finish:

use everscribe::axum::CurrentEvent;
use everscribe::event::Target;

async fn reveal(State(st): State<AppState>, ev: CurrentEvent, Path(id): Path<String>) -> Response {
    ev.with(|e| e.target = Target::new("secret", &id));
    let Some(s) = st.vault.lock().unwrap().secrets.get(&id).cloned() else {
        return (StatusCode::NOT_FOUND, "not found").into_response();
    };
    ev.with(|e| { e.action = "secret.reveal".into(); e.with_field("name", &s.name); });
    Json(json!({ "value": s.value })).into_response()
}

record is buffered and non-blocking (a background tokio task does the HTTP flush). The middleware fills result from the response status, so success and error paths both record correctly.

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

use everscribe::minter::TokenOptions;
use std::time::Duration;

async fn embed_token_customer(State(st): State<AppState>, ev: CurrentEvent) -> Response {
    let me = st.actor_user(&ev).unwrap();
    let opts = TokenOptions {
        tenant_id: me.tenant_id.clone(),
        expires_in: Duration::from_secs(60 * 60),
        allowed_columns: Some(vec!["occurred_at".into(), "action".into(), "actor".into()]),
        ..Default::default()
    };
    match st.minter.mint_token(&opts).await {
        Ok(token) => Json(json!({ "token": token })).into_response(),
        Err(_) => (StatusCode::BAD_GATEWAY, "mint failed").into_response(),
    }
}

The admin view drops tenant_id so the same component renders every event.

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", {
            method: "POST",
            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(...).await only enqueues — a background tokio task does the network flush, so it never blocks the request path.
  • Construct one recorder at boot and share it; close().await on shutdown to drain.
  • Frontend code is identical to the Go/Node/Python-backend variants.

What next