Using React Router with Agentuity

Connect a React Router framework-mode app to Agentuity services and deploy it from the CLI

Add Agentuity service clients to a React Router framework-mode app (the full-stack SSR mode, not a plain SPA). Keep your route modules, loaders, actions, and routes.ts as-is. Agentuity connects through the CLI and its service packages wherever server code needs storage, AI Gateway routing, or deploy metadata.

Create the Framework App

npx create-react-router@latest my-react-router-app
cd my-react-router-app

Then add Agentuity:

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

Use the package manager that create-react-router configured. 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 project import writes the local metadata needed for deploys. The --validate-only flag lets you review before linking.

Agentuity does not scaffold React Router from the create flow today. The build detector still recognizes existing React Router framework-mode apps from react-router.config.* or the React Router Vite plugin.

The examples below use these files:

FilePurpose
app/routes/home.tsxUI component that calls the resource route
app/routes/api.translate.tsResource route that exports loader and action only
app/routes.tsRoute map registering both modules

Import an Existing App

Use the same Agentuity commands in an app that already exists:

npm install -D @agentuity/cli
npx agentuity project import --validate-only  # review what the CLI finds
npx agentuity project import                  # write local project metadata

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

What Agentuity adds

AreaReact Router keepsAgentuity adds
routingroutes.ts, loaders, actions, and resource routesservice clients imported in server route code
local devreact-router devagentuity dev supplies service credentials
buildReact Router build outputagentuity build runs the build and packages launch metadata
deploythe React Router server entryagentuity deploy ships the packaged app

Add a Resource Route

A resource route is any route module that exports loader or action but no default component. React Router sends GET requests to loader and POST, PUT, PATCH, DELETE to action.

The route below uses AIGatewayClient for the model call. The project SDK key authenticates the request, and the model ID is app configuration or request input.

Install the packages used in this example:

npm install @agentuity/aigateway @agentuity/keyvalue zod

Create the route file:

typescriptapp/routes/api.translate.ts
import { AIGatewayClient } from '@agentuity/aigateway';
import { KeyValueClient } from '@agentuity/keyvalue';
import { z } from 'zod';
import type { Route } from './+types/api.translate';
 
const DEFAULT_MODEL = 'openai/gpt-5.4-mini';
const HISTORY_NAMESPACE = 'translation-history';
const HISTORY_LIMIT = 5;
const SESSION_COOKIE = 'agentuity_session';
const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; // 30 days
 
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({
  model: z.string().optional(),
  text: z.string(),
  toLanguage: z.string(),
});
 
const kv = new KeyValueClient();
const gateway = new AIGatewayClient();
 
function createSessionId(): string {
  return `sess_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`;
}
 
function getSessionId(request: Request): string {
  const cookie = request.headers.get('cookie') ?? '';
  const match = cookie.match(new RegExp(`(?:^|; )${SESSION_COOKIE}=([^;]+)`));
  return match?.[1] ? decodeURIComponent(match[1]) : createSessionId();
}
 
function sessionCookie(sessionId: string): string {
  const parts = [
    `${SESSION_COOKIE}=${encodeURIComponent(sessionId)}`,
    'HttpOnly',
    'Path=/',
    'SameSite=Lax',
    `Max-Age=${SESSION_TTL_SECONDS}`,
  ];
  if (process.env.NODE_ENV === 'production') parts.push('Secure');
  return parts.join('; ');
}
 
async function readHistory(sessionId: string): Promise<HistoryState> {
  const result = await kv.get<HistoryState>(HISTORY_NAMESPACE, sessionId);
  return result.exists ? result.data : { history: [], translationCount: 0 };
}
 
// loader handles GET and returns session history
export async function loader({ request }: Route.LoaderArgs) { 
  const sessionId = getSessionId(request);
  const state = await readHistory(sessionId);
  return Response.json(
    { sessionId, history: state.history, translationCount: state.translationCount },
    { headers: { 'Set-Cookie': sessionCookie(sessionId) } }
  );
}
 
// action handles POST / PUT / PATCH / DELETE
export async function action({ request }: Route.ActionArgs) { 
  const sessionId = getSessionId(request);
 
  if (request.method === 'DELETE') {
    await kv.delete(HISTORY_NAMESPACE, sessionId);
    return Response.json(
      { sessionId, history: [], translationCount: 0 },
      { headers: { 'Set-Cookie': sessionCookie(sessionId) } }
    );
  }
 
  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 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;
 
  const previous = await readHistory(sessionId);
  const history = [
    ...previous.history,
    { model, sessionId, text, timestamp: new Date().toISOString(), toLanguage, translation },
  ].slice(-HISTORY_LIMIT);
  const next: HistoryState = { history, translationCount: previous.translationCount + 1 };
 
  await kv.set(HISTORY_NAMESPACE, sessionId, next, { ttl: SESSION_TTL_SECONDS }); 
 
  return Response.json(
    { sessionId, history, translationCount: next.translationCount, translation, model, toLanguage },
    { headers: { 'Set-Cookie': sessionCookie(sessionId) } }
  );
}
// Omitting the default export makes this a resource route

Route.LoaderArgs and Route.ActionArgs come from React Router's typegen. Run react-router typegen (or a package script that runs it) before a direct TypeScript check or agentuity build; the import type line stays unresolved until the generated .react-router/types output exists.

Use the model id exactly as it appears in the AI Gateway model catalog. Use a provider SDK directly only when the resource route depends on provider-specific APIs.

Register the route in app/routes.ts:

typescriptapp/routes.ts
import { index, route } from '@react-router/dev/routes';
import type { RouteConfig } from '@react-router/dev/routes';
 
export default [
  index('routes/home.tsx'),
  route('api/translate', 'routes/api.translate.ts'),
] satisfies RouteConfig;

Call from a Component

Use fetch from client code. Do not use React Router's <Link> for resource routes because client-side navigation won't reach a module with no component to render.

typescriptapp/routes/home.tsx (excerpt)
async function translate(text: string, toLanguage: string, model: string) {
  const response = await fetch('/api/translate', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text, toLanguage, model }),
  });
 
  if (!response.ok) {
    const err = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
    throw new Error(err.error ?? `HTTP ${response.status}`);
  }
 
  return response.json();
}
 
// GET history on mount
const history = await fetch('/api/translate').then((r) => r.json());
 
// DELETE to clear
await fetch('/api/translate', { method: 'DELETE' });

Plain anchor tags and <Link reloadDocument> also work for non-JSON resource routes (downloads, feeds, etc.) because they issue a full browser request.

Run Locally

agentuity dev supplies AGENTUITY_SDK_KEY and a PORT value. Use a wrapper script that delegates to the framework dev command:

jsonpackage.json
{
  "scripts": {
    "dev": "agentuity dev --script dev:start",
    "dev:start": "react-router dev --port 3000",
    "build": "react-router build",
    "deploy": "agentuity deploy"
  }
}

Then run:

npm run dev

The --port 3000 flag is required because Vite (which React Router uses under the hood) ignores the PORT env var.

Smoke-test the resource route before touching the UI:

# GET, returns session history
curl http://localhost:3000/api/translate
 
# POST, translate text
curl http://localhost:3000/api/translate \
  -H 'Content-Type: application/json' \
  -d '{"text":"Hello","toLanguage":"Spanish"}'
 
# DELETE, clear history
curl -X DELETE http://localhost:3000/api/translate

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
DATABASE_URLdatabase resource routes
AWS_*object storage resource routes; see Object Storage for exact variables

Validate Before Deploy

Run React Router typegen and the framework build when you want framework-only feedback, then run the Agentuity packaging check. agentuity build runs the framework build during packaging and writes .agentuity/launch.json.

npx react-router typegen
npm run build
npx agentuity build

React Router outputs to build/ (client assets in build/client/, server in build/server/). Agentuity detects framework-mode apps from react-router.config.* or the React Router Vite plugin. Inspect the generated metadata to confirm the server entry and static paths match what was built:

cat .agentuity/launch.json

Deploy only after the packaging check and a local packaged validation of / and the resource route pass:

npx agentuity deploy --confirm

Common Gotchas

SymptomWhat to check
Route returns 404 in dev or deployedapp/routes.ts must include route('api/translate', 'routes/api.translate.ts'); React Router does not auto-discover routes
Cannot find module './+types/api.translate'Run npx react-router typegen or your package typecheck script before direct TypeScript checks
KV or AI Gateway calls return auth errorsStart the app through the agentuity dev --script dev:start wrapper so credentials are available
Model call returns an auth errorVerify AGENTUITY_SDK_KEY is available to the resource route. See AI Gateway
Deploy starts but only static assets are servedInspect .agentuity/launch.json: processes[0].command must run the React Router server entry (build/server/index.js)
Client-side <Link> to the resource route renders nothing or errorsResource routes have no component; navigate to them with fetch, plain <a>, or <Link reloadDocument>

Framework Docs

Next Steps