Skip to content

Repository files navigation

TanStack Start + Hono Turborepo

This template is a PNPM/Turbo monorepo:

  • packages/api: the Hono (OpenAPI) app — the single source of truth for the API
  • packages/cli: the template CLI — dev server and typed API access
  • apps/frontend: TanStack Start React app that renders apps and serves the API

Everything is one Node server: apps/frontend serves the UI, mounts the Hono app at /api (src/routes/api/$.ts) and its MCP server at /mcp (src/routes/mcp.ts). It has to be Node because script execution uses the run sandbox, which needs node:worker_threads.

pnpm dev runs pnpm cli dev, which starts it through portless with HTTPS enabled at https://frontend.localhost (Swagger UI at /api). On non-main branches the branch slug is prepended to the hostname, for example https://my-branch.frontend.localhost.

The frontend is a thin renderer: it talks to the MCP server (/mcp), the same interface agents use, to load apps and run their scripts.

Deploying

The server is built with Nitro and has no host-specific code. pnpm build produces a plain Node server:

pnpm build
pnpm start          # node .output/server/index.mjs, reading the repo-root .env files

It listens on PORT (default 3000) on all interfaces, so the same output runs in a Docker container. On Vercel, set the project's root directory to apps/frontend; Nitro detects Vercel at build time and emits a Node function instead.

Configure DATABASE_URL, BETTER_AUTH_SECRET (for example openssl rand -base64 32) and, to let users pick their gateway, ARCADE_IDENTITY_CLIENT_ID / ARCADE_IDENTITY_CLIENT_SECRET (see Auth) in the environment, and run pnpm db:migrate against the database before deploying a schema change. App URLs use the request's origin; set FRONTEND_URL when that differs from the public URL (for example behind a proxy that terminates TLS).

Preview databases on Vercel

Preview deployments each get their own Neon branch through the Neon-managed Vercel integration. On every preview deployment it branches the Neon project's default branch into preview/<git-branch> and sets DATABASE_URL / DATABASE_URL_UNPOOLED for that deployment. To set it up:

  1. In the Neon Console, open the project → Integrations → Vercel → Add, then Link Existing Neon Account and pick the Vercel project, database and role.
  2. Turn on Automatically delete obsolete Neon branches, so a preview branch is removed after its Git branch is deleted. The integration also points Production and Development's DATABASE_URL at the default branch.
  3. Set BETTER_AUTH_SECRET for the Preview environment in Vercel too. Arcade fetches each deployment's /api/arcade/client.json to sign users in, so Vercel's Deployment Protection has to let that path through (or be off for previews).

apps/frontend/vercel.json runs pnpm db:migrate before the build on preview deployments only, so each branch gets the PR's migrations. Production is still migrated by hand, and nothing migrates on startup. Don't rename a Git branch with a preview database: the integration matches Neon branches by name.

Scripts

/api/scripts is CRUD for saved scripts, and POST /api/scripts/:id/execute runs one in the run QuickJS sandbox. Every tool on the user's MCP gateway is exposed to scripts as tools.<functionName>(args). Names are made into valid identifiers, so Gmail.ListEmails becomes tools.Gmail_ListEmails. GET /api/tools lists them.

const emails = await tools.Gmail_ListEmails({ n_emails: input.count })
return emails

Each script declares an inputSchema and outputSchema (JSON Schema). The input passed to execute is validated against inputSchema and is available to the script as input. Script names are unique slugs; apps run scripts by name.

Tool results come back as structured content, or as parsed JSON text, or as plain text. Script failures, including tool errors, return { ok: false, error: { code, message } }. Scripts run as the signed-in user: the server calls the MCP server with that user's Arcade access token (see Auth). The dev server and pnpm start read the repo-root .env.local and .env.

Auth

Anyone with an Arcade account signs in with it, the way MCP clients sign in to Arcade's gateways, and their tool calls run as them, on an MCP gateway they pick. blurb.page signs users in with two of Arcade's authorization servers, linked to one user:

  • arcade, the OAuth server MCP clients use ($ARCADE_CLOUD_URL/oauth2). Every Arcade account can authorize clients on it, so this is the sign-in. Its access token identifies the user (its sub and email) and calls their MCP gateway. Nothing has to be set up on Arcade's side.
  • arcade-identity, Arcade's identity provider ($ARCADE_IDENTITY_URL), which the dashboard signs in with. It's linked right after sign-in (the same Arcade login, so it passes straight through) and reads the user's organizations, projects and gateways from Arcade's APIs, which don't take MCP tokens. It needs an OAuth2 client in Arcade's Ory project: ARCADE_IDENTITY_CLIENT_ID and ARCADE_IDENTITY_CLIENT_SECRET, with client_secret_post, JWT access tokens and these redirect URIs: <origin>/api/auth/callback/arcade-identity for each deployed origin, and http://127.0.0.1:5173/api/auth/callback/arcade-identity and http://127.0.0.1:5173/api/arcade/identity-callback for the dev server. Without it, everyone uses the default gateway.

Users pick a gateway from the navbar: any gateway in their projects that signs users in with Arcade (gateways that take an API key can't run tools as them). Their choice is kept in user_gateways; until they pick one, tool calls go to MCP_URL, or else Arcade's global gateway ($ARCADE_API_URL/mcp/arcade), which runs tools in their default project.

Arcade's URLs default to production. ARCADE_CLOUD_URL, ARCADE_API_URL and ARCADE_IDENTITY_URL point blurb.page at another stack, for example staging (https://cloud.bosslevel.dev, https://api.bosslevel.dev and https://auth.bosslevel.dev); set all three together, since accounts don't carry over between stacks.

Better Auth (packages/api/src/auth, mounted at /api/auth) keeps users, sessions and the encrypted Arcade tokens in Postgres, and refreshes the tokens when they expire. Everything under /api, except auth itself, needs a signed-in user, and so does /mcp:

  • The frontend calls /mcp with its session cookie.
  • Other MCP clients (agents) use OAuth: blurb.page is also an authorization server for its own /mcp (Better Auth's MCP plugin), so a client registers itself, sends the user to /login and /consent, and then acts as them.

Arcade's MCP OAuth server identifies blurb.page by a client ID per origin. On a public origin it's the URL of a client metadata document blurb.page serves (/api/arcade/client.json), which Arcade fetches. Arcade can't fetch a local one, so the dev server registers a client instead and keeps it in the arcade_clients table. Arcade only redirects over http to bare loopback hosts, so signing in on the portless https://*.localhost URL goes back through http://127.0.0.1:$PORT, which forwards to the portless URL.

Apps

An app is a json-render spec — a flat JSON tree of shadcn/ui components with state, visibility conditions and event bindings — served by the frontend at /apps/<name>. Specs declare named queries (scripts that load data, refetched when their input changes) and mutations (scripts run by buttons and forms, which refetch the queries they invalidate). Results are rendered in tables, charts, metrics and forms; data is only read inside a Query element, so loading and error states are always handled.

Apps are created and changed over MCP. An agent needs only the MCP URL (http://127.0.0.1:5173/mcp in dev, or /mcp on the deployed origin) and signs in when it connects: the server's instructions explain the workflow, and get_app_guide documents the spec format, every component and action, and common patterns with a full example. create_app / update_app validate specs (components, props, actions, script names, queries and mutations, and the element tree) and return readable errors.

The catalog lives in packages/api/src/ui and is shared with the frontend as @template/api/ui.

SVGs

Diagrams, illustrations and icons are SVGs stored in their own table and shown in any app by name with the Svg component ({ "type": "Svg", "props": { "name": "runtime-diagram" } }), so one SVG can be reused across apps and updated in place. /api/svgs is CRUD for them, and the MCP server has list_svgs, get_svg, create_svg, update_svg and delete_svg. App validation rejects an Svg whose name doesn't exist.

The frontend draws each SVG as an <img>, so its scripts, event handlers and external resources never run or load, and its ids and styles can't clash with the page. The theme's colors are passed in as CSS variables, so an SVG that uses currentColor, var(--foreground) or var(--chart-1) matches the app.

CLI

packages/cli is exposed at the repo root as pnpm cli:

pnpm cli dev                 # the app on portless, opens the browser when ready
pnpm cli dev --no-open       # …without opening the browser
pnpm cli api stats           # GET /api/stats
pnpm cli api stats --json    # raw JSON
pnpm cli api openapi         # GET /api/openapi.json
pnpm cli api docs --open     # Swagger UI
pnpm cli api tools           # MCP tools available to scripts
pnpm cli api scripts list
pnpm cli api scripts create --name inbox --file inbox.js
pnpm cli api scripts get|update|delete <id>
pnpm cli api scripts execute <id> --input '{"count": 5}'
pnpm cli api svgs list
pnpm cli api svgs create --name runtime-diagram --description "…" --file diagram.svg
pnpm cli api svgs get|update|delete <id>

pnpm dev is pnpm cli dev: it starts the portless HTTPS proxy, registers the branch's alias for the app, runs its dev script behind it, waits for the URL to answer and opens it in a browser, then removes the alias on exit. It also points the blurb-page entry in the repo's .mcp.json at the MCP endpoint (http://127.0.0.1:5173/mcp), so MCP clients opened in the checkout, like Claude Code, use it. Other entries are kept, and it uses the loopback URL because Node-based clients don't trust the portless CA. Running the dev script directly (pnpm --filter @template/frontend dev) skips portless and serves plain HTTP on port 5173.

The api commands call the same Hono app through the typed RPC client, so they stay in sync with packages/api. They can't sign in yet, so everything but openapi and docs answers 401 now that the API needs a signed-in user. They target --base-url, or $TEMPLATE_API_BASE_URL, or the portless app URL. Node does not read the system trust store, so the CLI adds the portless CA (~/.portless/ca.pem) to its own trust list to reach https://*.localhost.

The package is independently publishable: pnpm --filter @template/cli build bundles it with tsdown, inlining workspace dependencies so commander and hono are the only runtime dependencies of the published template binary.

Database

Drizzle + Postgres, in packages/api/src/db. Everything connects through a plain DATABASE_URL (postgres.js); routes use the shared client from getDb().

  • Without DATABASE_URL, everything falls back to a local PGlite database. Run pnpm db:local to serve it at postgres://postgres:postgres@127.0.0.1:5433/postgres.
  • pnpm db:generate / pnpm db:migrate / pnpm db:studio read DATABASE_URL from the environment, then from the repo-root .env.local or .env. They use DATABASE_URL_UNPOOLED (Neon's direct connection) when it's set.
  • The server reads it from the same files. Migrations never run on startup.

Commands

pnpm dev
pnpm cli
pnpm build
pnpm typecheck
pnpm lint
pnpm fmt
pnpm start

Docs

Docs are Notion-like pages at /docs/<name> that people edit in the browser (a Tiptap editor: type / for blocks) and agents write over MCP (get_doc_guide, create_doc, update_doc, …). A doc is stored as Markdown; a fenced code block with the language ui holds a json-render spec (the same format as an app's) and renders as a live component:

Open issues by label:

```ui
{ "root": "chart", "elements": { … }, "queries": { … } }
```

A ui block can also mount a saved app by name, { "app": "issue-tracker" }, which shows the app as it is now (type / and pick it under Components). Saving validates every ui block like an app spec, including that the scripts it runs exist, and that mounted apps exist.

Apps fill whatever they're shown in: a ScrollArea without a height and Slides take the space their parent has left, and the containers around them stretch to pass it down ([data-fill] in index.css). Nothing measures the window. On an app's page that's the area under the navbar; in a doc, a block holding one is 600px tall unless it sets "height".

Releases

Packages

Contributors

Languages

Generated from kamath/template