Using Vite + React with Agentuity

Build a Vite React SPA with a Bun server boundary for secrets and Agentuity service clients

A Vite React app runs entirely in the browser, which means it cannot safely hold API keys or call Agentuity services directly. The pattern here pairs the SPA with a thin server.ts that handles /api/* routes, holds secrets, and proxies the Vite dev server in development. In deployed builds the same process serves the static bundle.

Create the Framework App

npm create vite@latest my-vite-app -- --template react-ts
cd my-vite-app

Then add Agentuity and create a small server boundary:

npm install -D @agentuity/cli
npx agentuity project import --validate-only
npx agentuity project import

Use the package manager your Vite app uses. The CLI install is project-local: -D or --dev records @agentuity/cli in devDependencies, which keeps the CLI version in the app's lockfile. Bun's equivalent is lowercase -d.

Package managerAdd local CLIRun local CLIUndo local install
npmnpm install -D @agentuity/clinpx agentuity ...npm uninstall @agentuity/cli
Bunbun add -d @agentuity/clibunx agentuity ...bun remove @agentuity/cli
pnpmpnpm add -D @agentuity/clipnpm exec agentuity ...pnpm remove @agentuity/cli
Yarnyarn add -D @agentuity/cliyarn exec agentuity ...yarn remove @agentuity/cli

A global install is optional. It only makes bare agentuity ... work outside a project; it does not import or register the app. You can switch back by adding the dev dependency again and using the package-manager exec wrapper. The global install can stay installed, or you can remove it with the package manager that created it. See Local versus global CLI for the trade-offs.

Agentuity does not scaffold Vite + React from the create flow today.

FilePurpose
src/App.tsxBrowser UI
server.tsBun server: handles /api/* and serves static files in deployed builds
vite.config.tsVite configuration

Add to an Existing App

Install the CLI as a dev dependency, then import the project:

npm install -D @agentuity/cli
npx agentuity project import --validate-only   # dry run: checks config only
npx agentuity project import                   # registers the project and writes agentuity.json

Use the package-manager table above if the app is not npm-based.

agentuity dev can supply AGENTUITY_SDK_KEY to the process that runs your dev script.

What Agentuity adds

AreaVite keepsAgentuity adds
routingbrowser SPA routesa server boundary for secrets and /api/* routes
local devVite dev serveragentuity dev supplies service credentials
buildVite browser bundleagentuity build runs the build and packages the Bun server entry
deploydist/server.js plus static assetsagentuity deploy ships the packaged app

Add a Server Boundary

Browser code cannot use AGENTUITY_SDK_KEY or provider secrets safely. Move those calls into server.ts and call them from React over fetch.

npm install @agentuity/aigateway @agentuity/keyvalue zod
npm install -D @types/bun

The server below handles POST /api/translate, stores session history in KV, calls AI Gateway with the project SDK key, and falls back to a Vite dev proxy in development.

typescriptserver.ts
import { AIGatewayClient } from '@agentuity/aigateway';
import { KeyValueClient } from '@agentuity/keyvalue';
import { z } from 'zod';
 
const DEFAULT_MODEL = 'openai/gpt-5.4-mini';
const HISTORY_NAMESPACE = 'translation-history';
const SESSION_COOKIE = 'agentuity_session';
const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days
const PORT = Number.parseInt(process.env.PORT ?? '3000', 10);
const NODE_ENV = Bun.env.NODE_ENV ?? process.env.NODE_ENV;
 
interface HistoryEntry {
  readonly model: string;
  readonly sessionId: string;
  readonly text: string;
  readonly timestamp: string;
  readonly toLanguage: string;
  readonly translation: string;
}
 
interface HistoryState {
  readonly history: readonly HistoryEntry[];
  readonly translationCount: number;
}
 
const requestSchema = z.object({
  text: z.string(),
  toLanguage: z.string(),
  model: z.string().optional(),
});
 
const kv = new KeyValueClient();
const gateway = new AIGatewayClient();
 
function getSessionId(request: Request): string {
  const cookie = request.headers.get('cookie') ?? '';
  const match = cookie.match(/(?:^|; )agentuity_session=([^;]+)/);
  return match?.[1] ? decodeURIComponent(match[1]) : `sess_${crypto.randomUUID()}`;
}
 
Bun.serve({ 
  port: PORT,
  hostname: '0.0.0.0',
  async fetch(request) {
    const url = new URL(request.url);
 
    if (url.pathname === '/api/translate' && request.method === 'POST') {
      const body: unknown = await request.json();
      const parsed = requestSchema.safeParse(body);
 
      if (!parsed.success) {
        return Response.json({ error: 'text and toLanguage are required' }, { status: 400 });
      }
 
      const { text, toLanguage } = parsed.data;
      const model = parsed.data.model ?? DEFAULT_MODEL;
      const sessionId = getSessionId(request);
      const result = await gateway.completeText({ 
        model,
        messages: [{ role: 'user', content: `Translate to ${toLanguage}:\n\n${text}` }],
      });
 
      if (!result.hasText) {
        return Response.json({ error: 'model returned no text' }, { status: 502 });
      }
 
      const translation = result.text;
 
      // Append to per-session history, keep last 5 entries
      const previous = await kv.get<HistoryState>(HISTORY_NAMESPACE, sessionId);
      const history = [
        ...(previous.exists ? previous.data.history : []),
        {
          model,
          sessionId,
          text,
          timestamp: new Date().toISOString(),
          toLanguage,
          translation,
        },
      ].slice(-5);
      const translationCount = (previous.exists ? previous.data.translationCount : 0) + 1;
 
      await kv.set(HISTORY_NAMESPACE, sessionId, { history, translationCount }, { ttl: SESSION_TTL }); 
 
      return Response.json(
        { sessionId, translation, history, model, translationCount },
        {
          headers: {
            'Set-Cookie': [
              `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}`,
              'HttpOnly',
              'Path=/',
              'SameSite=Lax',
              `Max-Age=${SESSION_TTL}`,
              ...(NODE_ENV === 'production' ? ['Secure'] : []),
            ].join('; '),
          },
        },
      );
    }
 
    // Static fallback for deployed builds. Vite output is co-located with server.js
    if (NODE_ENV === 'production') {
      const pathname = url.pathname === '/' ? '/index.html' : url.pathname;
      const file = Bun.file(import.meta.dir + pathname);
      // Fall back to index.html so the SPA router handles the path
      const body = (await file.exists()) ? file : Bun.file(import.meta.dir + '/index.html');
      return new Response(body);
    }
 
    // Development: proxy everything else to the Vite dev server
    const viteRes = await fetch(`http://127.0.0.1:5173${url.pathname}${url.search}`, { 
      method: request.method,
      headers: request.headers,
      body: request.method !== 'GET' && request.method !== 'HEAD' ? request.body : undefined,
    });
    return new Response(viteRes.body, {
      status: viteRes.status,
      headers: viteRes.headers,
    });
  },
});

The local script below runs this file with Bun so Bun.serve, Bun.file, and HMR work as shown. Use the model id exactly as it appears in the AI Gateway model catalog. Use a provider SDK directly only when the server route depends on provider-specific APIs.

Call the Server from React

tsxsrc/App.tsx
async function translate(text: string, toLanguage: string): Promise<string> {
  const response = await fetch('/api/translate', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ text, toLanguage }),
  });
 
  if (!response.ok) {
    throw new Error(`Translation failed: ${response.status}`);
  }
 
  const data = await response.json();
  return data.translation;
}

Run Locally

A Vite + React app needs two processes during development: Vite for the SPA and server.ts for the API. The default dev script from create-vite only runs Vite, so configure package.json to run both.

The recommended pattern uses bun run --parallel to run both scripts simultaneously without a third-party dependency:

jsonpackage.json
{
  "scripts": {
    "dev": "agentuity dev --script dev:start",
    "dev:start": "bun run --parallel dev:vite dev:server",
    "dev:vite": "vite --host 127.0.0.1",
    "dev:server": "bun --hot server.ts",
    "build": "tsc -b && vite build && bun build server.ts --target=bun --outfile=dist/server.js",
    "start": "NODE_ENV=production bun dist/server.js",
    "deploy": "agentuity deploy"
  }
}
npm run dev

agentuity dev runs the wrapper dev:start script, which uses Bun's parallel runner to launch both Vite and server.ts in the same process group. The CLI supplies AGENTUITY_SDK_KEY and PORT=3000 when the linked project key is available. server.ts reads PORT and listens on 3000; Vite uses port 5173 internally.

Smoke-test the API route on port 3000:

curl http://localhost:3000/api/translate \
  -H "content-type: application/json" \
  -d '{"text":"Hello","toLanguage":"Spanish"}'

Deployed Environment Variables

Configure the variables your deployed app needs. Direct AIGatewayClient calls use AGENTUITY_SDK_KEY; the model ID in this example is normal app configuration or request input.

VariableUsed by
AGENTUITY_SDK_KEYAgentuity service clients and @agentuity/aigateway in server.ts
DATABASE_URLdatabase routes in server.ts
AWS_*object storage routes in server.ts; see Object Storage for exact variables

Validate Before Deploy

Run the framework build when you want Vite/server-only feedback, then run the Agentuity build validator before deploying. agentuity build runs the build script during packaging and writes .agentuity/launch.json.

npm run build
npx agentuity build
cat .agentuity/launch.json

Vite + React apps are detected as the generic vite adapter. Inspect .agentuity/launch.json to confirm the start command points at dist/server.js, not at vite preview or a static _serve.js helper. If your start command has an env prefix such as NODE_ENV=production bun dist/server.js, trust processes[0].command over the runtime.name hint.

Deploy only after the framework build, Agentuity build, and a local packaged validation of / and /api/translate pass:

npx agentuity deploy --confirm

Common Gotchas

SymptomFix
AGENTUITY_SDK_KEY appears in browser bundleMove any Agentuity client code into server.ts
/api/* routes work locally but 404 after deployCheck .agentuity/launch.json: the start command must run dist/server.js, not vite preview
/api/* routes return SPA HTML after packaginglaunch.json is starting a static helper such as _serve.js; it is not running server.ts
vite preview used as the deployed servervite preview is for local build verification only; it has no /api/* handler. See Vite backend integration
Missing dist/server.js after buildAdd bun build server.ts --target=bun --outfile=dist/server.js to the build script
Model call returns an auth errorVerify AGENTUITY_SDK_KEY is available to server.ts. See AI Gateway

Framework Docs

Next Steps