Full-stack Next.js app
Every subsystem in one project, with a Next.js frontend talking to a Yatta backend — and one trace tying the two together.
Layout
Two processes. The frontend is Next.js; the backend is Yatta on its own port. They are separate deployables, which means the frontend can be a CDN cache and the backend can scale on its own.
my-app/├── web/ Next.js frontend│ ├── app/│ │ ├── login/page.tsx│ │ ├── dashboard/page.tsx│ │ └── layout.tsx│ ├── components/│ │ ├── UploadDropzone.tsx│ │ ├── JobProgress.tsx│ │ └── LiveFeed.tsx│ ├── lib/│ │ ├── api.ts│ │ └── observe.ts ← RUM agent│ └── .env.local NEXT_PUBLIC_API_URL=http://localhost:4000│└── api/ Yatta backend ├── src/main.ts ├── yatta/ │ ├── func/ │ │ ├── auth.ts db.ts cache.ts jobs.ts │ │ ├── events.ts storage.ts mail.ts observe.ts │ └── backend/ │ ├── auth.ts documents.ts realtime.ts feedback.ts └── .env API_URL, AUTH_SECRET, DATABASE_URLCORS, once
With credentials on, the allowed origin has to be exact. A wildcard is rejected by the browser and would be wrong anyway.
import { createAPI } from "yatta/api"; const api = createAPI("/api"); // Exact origins, and credentials so the session cookie travels.api.cors({ origin: [ process.env.WEB_ORIGIN ?? "http://localhost:3000", "https://app.example.com", ], credentials: true, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],}); export default api;Auth, shared between both apps
The backend owns the session. The frontend never sees a token — it calls withCredentials and the cookie does the work.
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; export class ApiError extends Error { constructor(readonly status: number, message: string, readonly body?: unknown) { super(message); }} async function request<T>(path: string, init: RequestInit = {}): Promise<T> { const res = await fetch(`${BASE}${path}`, { ...init, // The session cookie must travel; without this every call is anonymous. credentials: "include", headers: { ...(init.body ? { "content-type": "application/json" } : {}), ...init.headers, }, }); if (!res.ok) { throw new ApiError(res.status, res.statusText, await res.json().catch(() => null)); } return res.status === 204 ? (undefined as T) : res.json();} export const api = { get: <T>(p: string) => request<T>(p), post: <T>(p: string, body?: unknown) => request<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }), patch: <T>(p: string, body: unknown) => request<T>(p, { method: "PATCH", body: JSON.stringify(body) }), del: <T>(p: string) => request<T>(p, { method: "DELETE" }),};One trace across both
Propagate the traceparent yourself. There is no browser agent in the framework — rumScript() exists but nothing loads it and there is no ingest endpoint, so emitting it would be a no-op. What does work is minting a W3C header on the client; the server adopts it and the SQLite query it caused lands in the same trace.
"use client"; const hex = (bytes: number) => Array.from(crypto.getRandomValues(new Uint8Array(bytes))) .map((b) => b.toString(16).padStart(2, "0")) .join(""); export function traceparent() { // W3C trace-context: version 00, 16-byte trace id, 8-byte span id, flags 01 // (sampled). Reuse the existing value so every fetch in this page shares a // trace instead of starting a new one per call. const existing = typeof sessionStorage !== "undefined" ? sessionStorage.getItem("yatta-traceparent") : null; if (existing) return existing; const header = `00-${hex(16)}-${hex(8)}-01`; if (typeof sessionStorage !== "undefined") { sessionStorage.setItem("yatta-traceparent", header); } return header;} export function TraceProvider({ children }: { children: React.ReactNode }) { return <>{children}</>;}A route that uses most of it
Auth, validation, database, events, jobs, cache and observability in one handler — which is what a real endpoint looks like.
import { createAPI, HttpError } from "yatta/api";import { z } from "zod";import { and } from "yatta/db";import { db } from "../func/db";import { auth } from "../func/auth";import { cache } from "../func/cache";import { jobs } from "../func/jobs";import { events } from "../func/events";import { storage } from "../func/storage";import { mail } from "../func/mail";import { observer } from "../func/observe";import { requireAuth } from "../func/middleware"; const api = createAPI("/api/documents"); api.use(requireAuth); const CreateSchema = z.object({ title: z.string().min(1).max(200), body: z.string().max(100_000), notifyEmail: z.string().email().optional(),}); api.post("/", async (ctx) => { const user = ctx.state.user as { id: string; email: string }; // 422 with the failing field, not a 500 from deep inside the ORM. const parsed = CreateSchema.safeParse(await ctx.json()); if (!parsed.success) { throw new ValidationError("Invalid document", parsed.error.issues); } return observer.traceDb("insert", async (span) => { const document = await db.documents.insert({ userId: user.id, title: parsed.data.title, body: parsed.data.body, status: "draft", }); // Anything can react — indexing, notifications, webhooks. await events.emit("document.created", { documentId: document.id, userId: user.id, title: document.title, }); // Slow follow-up work, off the request path. await jobs.enqueue("document:thumbnail", { documentId: document.id }); if (parsed.data.notifyEmail) { await jobs.enqueue("document:notify", { documentId: document.id, to: parsed.data.notifyEmail, }); } span.setAttribute("document.id", document.id); return Response.json(document, { status: 201 }); });}); api.get("/", async (ctx) => { const user = ctx.state.user as { id: string }; // Hot read, cached per user and invalidated on write. const key = `docs:list:${user.id}`; const cached = await cache.get<unknown[]>(key); if (cached) return Response.json(cached); const rows = await db.documents .where((f) => f.userId.isEqualTo(user.id)) .orderBy({ updatedAt: "desc" }) .limit(50) .all(); await cache.set(key, rows, { ttl: "30s", tags: [`docs:${user.id}`] }); return Response.json(rows);});The worker side
export interface JobHandlers { "document:thumbnail": { documentId: string }; "document:notify": { documentId: string; to: string };} jobs.handle("document:thumbnail", async ({ documentId }, ctx) => { const doc = await db.documents.findById(documentId); if (!doc) return; ctx.progress(30, "Rendering"); const pdf = await renderPdf(doc); const key = `documents/${doc.userId}/${documentId}.pdf`; await storage.put(key) .from(pdf) .withContentType("application/pdf") .asPrivate() .maxSize("10mb"); await db.documents.where((f) => f.id.isEqualTo(documentId)).update({ pdfKey: key, status: "ready", }); ctx.progress(100, "Done");}); jobs.handle("document:notify", async ({ documentId, to }) => { const doc = await db.documents.findById(documentId); await mail.send({ to, subject: `Your document is ready`, template: "document-ready", props: { title: doc.title, documentId: doc.id }, });});Realtime in React
The SSE endpoint streams job progress and realtime events into React state, so the UI needs no polling.
"use client"; import { useEffect, useState } from "react"; interface Progress { percent: number; message: string } export function JobProgress({ url }: { url: string }) { const [progress, setProgress] = useState<Progress>({ percent: 0, message: "Queued" }); const [done, setDone] = useState(false); useEffect(() => { // EventSource reconnects on its own; close it or it retries forever. const es = new EventSource(url, { withCredentials: true }); es.addEventListener("progress", (e) => { setProgress(JSON.parse(e.data)); }); es.addEventListener("done", () => { setDone(true); es.close(); }); return () => es.close(); }, [url]); if (done) return <p className="text-sm text-emerald-600">Ready</p>; return ( <div> <div className="h-1.5 w-full rounded bg-black/10"> <div className="h-full rounded bg-emerald-500 transition-all" style={{ width: `${progress.percent}%` }} /> </div> <p className="mt-1.5 text-xs text-black/50">{progress.message}</p> </div> );}"use client"; import { useEffect, useState } from "react"; interface FeedItem { id: string; title: string; at: string } export function LiveFeed({ room }: { room: string }) { const [items, setItems] = useState<FeedItem[]>([]); useEffect(() => { const es = new EventSource( `${process.env.NEXT_PUBLIC_API_URL}/api/feed/${room}`, { withCredentials: true }, ); es.addEventListener("document.created", (e) => { const doc = JSON.parse(e.data) as FeedItem; // Newest first, bounded so a long session does not grow forever. setItems((prev) => [doc, ...prev].slice(0, 50)); }); return () => es.close(); }, [room]); return ( <ul> {items.map((i) => ( <li key={i.id}>{i.title}</li> ))} </ul> );}Closing the loop
The agent reports back a trace id; the user reports a problem with the same id attached. That is the whole point of tracing from the browser.
"use client"; import { useState } from "react";import { api } from "@/lib/api"; export function FeedbackButton() { const [open, setOpen] = useState(false); const [message, setMessage] = useState(""); const [sent, setSent] = useState(false); async function submit() { await api.post("/api/feedback", { message, rating: 2, // The browser agent generated this; it ties the report to the exact // request the user was looking at. traceId: window.yatta?.traceId?.(), url: location.href, }); setSent(true); } if (sent) return <p className="text-sm">Thanks — that is genuinely useful.</p>; return ( <div> <button onClick={() => setOpen(true)}>Report a problem</button> {open && ( <div> <textarea value={message} onChange={(e) => setMessage(e.target.value)} /> <button onClick={submit}>Send</button> </div> )} </div> );}import { createAPI } from "yatta/api";import { observer } from "../func/observe"; const api = createAPI("/api/feedback"); api.post("/", async (ctx) => { const body = (await ctx.json()) as Record<string, unknown>; const message = String(body.message ?? "").trim(); if (!message) { return Response.json({ error: "message is required" }, { status: 400 }); } // There is no feedback store in the framework — user feedback is not a // shipped feature. The audit log is the built-in place to record an event // like this; persist it yourself if you need to query it later. observer.recordAudit({ action: "feedback.submit", target: typeof body.traceId === "string" ? body.traceId : undefined, actor: "frontend", traceId: typeof body.traceId === "string" ? body.traceId : undefined, }); return Response.json({ ok: true }, { status: 201 });});traceId with the feedback is what turns “it was broken this morning” into a specific failing trace you can open. It is two lines and it changes every support conversation.