Skip to content

Frontend architecture (SPA + BFF)

Deployed — the SaaS app runs on Azure Container Apps (staging: open.soundverse.ai).

The web app (soundverse-saas-2.0) is one Next.js 15 project wearing two hats: a browser SPA that is the entire product UI, rendered client-side, and a thin backend-for-frontend (BFF) that proxies every data call to the consumer gRPC gateway. The split is the whole design: the browser never speaks gRPC and never holds a bearer token — it only ever calls /api/* on its own origin, and gRPC, HTTP/2, and the proto types live exclusively on the server.

Stack: TypeScript + React 19 + Next.js 15 (App Router), shipped as an output: "standalone" container on Azure Container Apps. The package.json name is still agent-synth — a leftover from the pre-Next Vite app, not a typo.

Shape: a client SPA behind a catch-all route

Section titled “Shape: a client SPA behind a catch-all route”

Although this is the App Router, almost nothing is server-rendered. The whole product is a client SPA that runs its own location.pathname router, mounted behind a single optional catch-all route. The chain is short but deliberately split so the app is still crawlable:

  1. app/[[...slug]]/page.tsx matches every path and stays a server component. It emits per-route metadata (generateMetadata), a crawlable `<main class="sv-seo">` link graph, and JSON-LD.

  2. It renders the "use client" island app/[[...slug]]/AppShell.tsx, which dynamically imports the SPA with SSR off:

    app/[[...slug]]/AppShell.tsx
    const AppClient = dynamic(() => import("@/src/AppClient"), { ssr: false });
    export default function AppShell() { return <AppClient />; }
  3. src/AppClient.tsx renders src/App.jsx, which parses location.pathname itself (the ROUTE0 block) and keeps the URL in sync with history.replaceState — Next’s router is not used for in-app navigation.

ssr: false is load-bearing, not laziness: the SPA is heavily browser-bound (Web Audio, canvas, imperative DOM injection) and would not survive server rendering. Every product route maps onto the same island — /studio (the DAW), /agent and /agent-one (Agent One chat), and the library shell routes (/home, /library, /explore, /account, /song, …).

flowchart LR
  subgraph app["soundverse-saas-2.0 (same origin)"]
    SPA["Browser SPA<br/>src/App.jsx (ssr:false)"]
    BFF["BFF: app/api/*/route.ts<br/>runProxy + lib/grpc"]
  end
  GW["core-gateway-consumer<br/>consumer.v1 gRPC"]
  AGENT["core-tool-agent<br/>(queue worker)"]
  REDIS[("Redis event stream")]

  SPA -->|"fetch /api/* — JSON, session cookie"| BFF
  BFF -->|"Connect/gRPC + Authorization Bearer"| GW
  GW -->|"QueueTask"| AGENT
  AGENT -->|"XADD events"| REDIS
  GW -->|"XREAD BLOCK"| REDIS
  BFF -->|"SSE: data frames"| SPA

  classDef frontend fill:#6366f1,color:#fff,stroke:#4338ca
  classDef gateway  fill:#0ea5e9,color:#fff,stroke:#0369a1
  classDef data     fill:#8b5cf6,color:#fff,stroke:#6d28d9
  classDef worker   fill:#10b981,color:#fff,stroke:#047857

  class SPA,BFF frontend
  class GW gateway
  class AGENT worker
  class REDIS data

The BFF: /api/* route handlers proxy to gRPC

Section titled “The BFF: /api/* route handlers proxy to gRPC”

Every app/api/*/route.ts is a Node-runtime handler that turns a plain HTTP request into a typed Connect/gRPC call against core-gateway-consumer and serializes the reply back to JSON. This is the only place gRPC, HTTP/2, and proto types exist — the browser bundle never imports them. The plumbing lives in lib/grpc/ and is small on purpose:

File Responsibility
lib/grpc/endpoints.ts resolveEndpoint("core-gateway-consumer") reads CORE_GATEWAY_CONSUMER_GRPC; normalizeBaseUrl turns a scheme-less host:port into a URL — a :443 port → https, anything else → http (h2c, the right default on the Azure intranet).
lib/grpc/transport.ts getTransport(baseUrl) — one cached createGrpcTransport per upstream (a transport owns an HTTP/2 session manager, so it is reused, not recreated per request).
lib/grpc/client.ts Typed Connect clients for the consumer services: AccountService, GenerationService, LibraryService, WorkspaceProjectService.
lib/grpc/auth-metadata.ts Resolves the upstream bearer — from an explicit Authorization: Bearer header for native/mobile clients, else off the encrypted session cookie — or throws UnauthenticatedError → HTTP 401.
lib/grpc/otel-interceptor.ts Injects the active span’s W3C traceparent into outbound gRPC metadata (connect-node dials node:http2 directly, so fetch auto-instrumentation never sees these calls).
lib/grpc/proxy.ts runProxy(req, fn) — the one-liner wrapper: authenticate → call → map a ConnectError to an HTTP status + friendly JSON.

A typical route reads as one line of intent:

app/api/library/route.ts
export async function GET(req: Request) {
return runProxy(req, async (opts) => {
const resp = await getLibraryClient().listLibrary({ /* …query… */ }, opts);
return toJson(ListLibraryResponseSchema, resp);
});
}

runProxy faithfully maps each Connect Code to the real HTTP status (NotFound→404, PermissionDenied→403, ResourceExhausted→429, Unauthenticated→401, DeadlineExceeded→504, …) and unpacks the gateway’s structured ErrorDetail into a JSON body of { error, code, friendlyMessage, retryable, requestId, traceId }. The browser-side ApiError class reads that same shape back out, so a component gets a clean error with a copyable trace id — see Observability & tracing.

The full route → consumer.v1 RPC table lives in the BFF routes map; the mechanics of adding one are in Add or extend a consumer-facing API. A representative slice:

HTTP route consumer.v1 RPC
POST /api/generation GenerationService.CreateGeneration
GET /api/generation/:taskId/stream (SSE) GenerationService.StreamGeneration
GET /api/tools GenerationService.ListTools
GET /api/account/balance AccountService.GetTokenBalance
GET /api/library · POST /api/library/upload LibraryService.ListLibrary · UploadFile
GET / POST /api/workspaces WorkspaceProjectService.ListWorkspaces · CreateWorkspace

Agent One chat is where the client/server split pays off. The flow lives in src/agentone/ChatPage.jsx (UI) and src/agentone/agentLLM.js (transport). A turn is two HTTP calls:

  1. POST creates the generation. createGeneration({ toolId, projectId, messageId, payloadJson })POST /api/generationCreateGeneration, returning a taskId. If toolId is omitted the BFF falls back to the server-side AGENT_TOOL_ID. projectId is required and ownership-checked by the gateway, which adopts the project’s workspace — the client-sent workspaceId is not trusted.

  2. GET opens the SSE stream. generationStreamUrl(taskId, afterSeq) opens /api/generation/:taskId/stream as Server-Sent Events. Each data: frame is the JSON of one GenerationEvent.

The agent (core-tool-agent) multiplexes both its reasoning text and its tool-call lifecycle onto a single PARTIAL_OUTPUT event type, distinguished by a kind field inside partialOutputJson. consumeAgentStream parses each frame and dispatches typed callbacks:

kind Meaning Callback
tool_call a sub-tool was invoked onToolCall
tool_progress sub-tool progress tick onToolProgress
tool_result sub-tool finished onToolResult
thinking reasoning trace delta (accumulated)
compaction history was summarised (a “Compacting…” chip) (status)
status DB-authoritative working label (see below) onStatus
(no kind) an answer-text delta onTextDelta

The nested tool-call → second billing pipeline behind tool_call is covered in The agent + MCP path.

Two robustness properties make the live view converge with a reload:

  • DB-authoritative status. The gateway’s StreamGeneration synthesizes a status event on every connect when GetTask reports the task is DISPATCHED/PROCESSING, and re-emits one on idle ticks. A fresh connect therefore behaves exactly like a reload-resume even when the Redis relay is momentarily empty — the fix for the intermittent “planning… forever” symptom on staging. The event carries no seq, so it never advances the resume cursor, and older clients ignore the unknown kind (services deploy independently). See SSE DB-authoritative status for the tracing story.
  • Durable steps are the source of truth. The terminal output’s metadata_json carries an authoritative steps[] timeline. onTerminal reconciles the live step subset against it (by call_id), so a reconnect that lands after completion still renders every middle step instead of jumping from the first tool to the final card. The live stream is a progressive accelerator; the durable output is what the finish path trusts.

A failed run surfaces friendlyMessage plus a traceId; the TraceBadge component renders the first 8 chars as a small clickable badge (copy-to-clipboard + a deep link to the trace UI), so a support screenshot is enough to debug. A message persisted as MESSAGE_STATUS_FAILED renders its stored errorMessage rather than an eternal spinner.