A zone app is a normal Next.js App Router project with three platform-specific config tweaks: basePath, assetPrefix, and serverActions.allowedOrigins.
1. Create the project
npx create-next-app@latest my-agent-zone \
--ts --app --eslint --tailwind --src-dir --import-alias "@/*"
cd my-agent-zone
npm install @cxpa/sdk
@cxpa/sdk/zone ships everything needed to verify the JWT, read the session, and call platform endpoints. jose is pulled in transitively — do not install it directly.
Do not add an auth library. A zone never authenticates users itself — identity arrives in the platform-signed JWT, and adding a second auth system (Supabase Auth, NextAuth, Clerk) creates a competing source of identity that the platform will not honour.
A database client is a different matter and is expected if the zone owns domain data — a list to render, records to write. Install whatever driver that database needs, use it server-side only, and keep it strictly separate from identity, which still comes from the session. See Zone data and tenancy.
2. Folder layout
The source tree is flat. basePath (configured below) is what makes the platform serve app/page.tsx at /{orgSlug}/{agentSlug}/app.
There is no app/api/ directory, and adding one will not work. A client-side fetch('/api/…') resolves against the platform's origin — the one in the browser's address bar — so the request never reaches the zone. Every client-to-server call goes through a server action instead.
my-agent-zone/
├── src/
│ ├── app/
│ │ ├── page.tsx landing surface (e.g. input form)
│ │ ├── runs/[runId]/page.tsx optional run detail
│ │ ├── layout.tsx
│ │ ├── icon.png browser tab icon (see §4)
│ │ └── globals.css
│ ├── components/
│ ├── lib/
│ │ └── actions.ts 'use server' — triggerRun / pollRun / ...
│ └── middleware.ts
├── next.config.ts
├── .env.example
└── package.json
3. Configure next.config.ts
The zone is mounted under /{orgSlug}/{agentSlug}/app/* on the platform. basePath and assetPrefix ensure links and static assets resolve correctly behind the proxy.
import type { NextConfig } from 'next'
const orgSlug = process.env.ZONE_ORG_SLUG
const agentSlug = process.env.ZONE_AGENT_SLUG
if (!orgSlug || !agentSlug) {
throw new Error(
'ZONE_ORG_SLUG and ZONE_AGENT_SLUG must be set — they compose the zone basePath and assetPrefix.',
)
}
const nextConfig: NextConfig = {
basePath: `/${orgSlug}/${agentSlug}/app`,
assetPrefix: `/assets/${orgSlug}/${agentSlug}`,
experimental: {
serverActions: {
allowedOrigins: [process.env.PLATFORM_ORIGIN!],
},
},
}
export default nextConfig
Three points:
basePathis built from the slugs. Next.js prepends it to every route, asset URL,<Link>href, androuter.push()call — so the source tree stays flat. The slugs are read at build time; throwing on missing values is intentional, because a build with the wrong basePath silently 404s in production.assetPrefixis a path, not a URL, and is scoped by(orgSlug, agentSlug)to avoid collisions between agents that share a slug across orgs. A cross-originassetPrefixbreaks hydration silently.serverActions.allowedOriginsmust include the platform's origin. Server actions run on the zone but the browser POSTs them from the platform's host; without the allow-list Next.js rejects the request as cross-origin.
A single zone deployment binds to exactly one (orgSlug, agentSlug) pair. Build separate deployments for separate agents.
4. Favicon and app icons
Put icons in src/app/ using Next.js file conventions — icon.png (or icon.svg), and apple-icon.png when you have a large enough master:
src/app/
├── icon.png 32x32 or larger — the browser tab icon
├── apple-icon.png 180x180 — optional
└── layout.tsx
Next prefixes these with the zone's basePath automatically, so the emitted tag is <link rel="icon" href="/{orgSlug}/{agentSlug}/app/icon.png?abc123">. That path is on the platform origin and the platform proxies it back to the zone, so it resolves correctly.
Do not hand-write metadata.icons with root-relative URLs. This is the one form that breaks silently:
// ✗ Broken behind a basePath
export const metadata: Metadata = {
icons: { icon: '/favicon.png' },
}
Next emits metadata.icons URLs verbatim — it applies neither basePath nor metadataBase to them. A root-relative URL therefore resolves against the platform's origin rather than the zone, so the icon either 404s or silently picks up the platform's own mark. Worse, a hand-written block overrides the file convention, so adding one disables src/app/icon.png.
If a zone genuinely needs metadata.icons — several sizes, media queries — every url must carry the basePath:
const basePath = `/${process.env.ZONE_ORG_SLUG}/${process.env.ZONE_AGENT_SLUG}/app`
export const metadata: Metadata = {
icons: { icon: [{ url: `${basePath}/icon.png`, sizes: '32x32', type: 'image/png' }] },
}
Two more things worth knowing:
- Files in the zone's
public/produce no link tag on their own. They are served at{basePath}/<file>, but nothing points a browser at them. Thesrc/app/conventions are the reliable route. - Prefer
icon.pngoverfavicon.icoinside a zone. Next's internal favicon check matches the literal path/favicon.ico, so under a basePath a zone'sapp/favicon.icois not hoisted to first-icon position and is emitted as an ordinary icon link. Harmless, buticon.pnghas no such quirk. Note thatcreate-next-appscaffoldssrc/app/favicon.icofor you — replace that file rather than adding metadata alongside it.
A zone that ships no icon at all is fine: the browser falls back to probing /favicon.ico on the platform origin, and inherits the CXP mark.
5. Environment variables
The zone needs the following at runtime:
| Variable | Example | Purpose |
|---|---|---|
ZONE_ORG_SLUG | acme | Org slug the zone is bound to. Read at build time. |
ZONE_AGENT_SLUG | intake-bot | Agent slug the zone is bound to. Read at build time. |
ZONE_JWT_SECRET | (provided by platform admin) | HS256 secret used to verify x-zone-token. |
AUDIENCE_ID | (provided by platform admin) | Audience claim the platform mints with. |
PLATFORM_API_BASE_URL | https://platform.example.com | Base URL for outbound platform calls. |
PLATFORM_ORIGIN | https://platform.example.com | Used by Server Actions allow-list. |
PLATFORM_API_BASE_URL and PLATFORM_ORIGIN are typically the same value. Strip trailing slashes from both — a stray / produces //assets/... in the proxy destination and triggers a 308 loop on Vercel.
6. Next steps
Continue with Auth to wire JWT verification.