Adding Surf to a Next.js App
Use Next.js Route Handlers to expose typed Surf commands. Works with the App Router, edge runtime, and deploys instantly to Vercel โ no extra infrastructure needed.
Overview
Surf lives entirely in your API layer. For a Next.js app this means a single Route Handler that mounts your Surf router. Agents discover your commands via /.well-known/surf.json and call them over plain HTTP โ no WebSocket server, no long-running process, no extra Vercel project.
What you add
app/api/surf/[...surf]/route.tsapp/.well-known/surf.json/route.tscomponents/SurfSetup.tsxWhat you get
Installation
Install the core package into your existing Next.js project:
npm install @surfjs/core @surfjs/next# orpnpm add @surfjs/core @surfjs/next@surfjs/core defines your commands and @surfjs/next provides App Router route handlers with full edge-runtime support. Both ship ESM + CJS with zero peer dependencies.
Route Handler
Create a catch-all Route Handler. The [...surf] segment lets Surf handle all sub-paths for commands, pipelines, and streaming.
import { createSurf } from '@surfjs/core'import { createSurfRouteHandler } from '@surfjs/next'ย // 1. Define your commandsconst surf = await createSurf({ name: 'My Next.js App', commands: { search: { description: 'Search articles or products', params: { q: { type: 'string', required: true, description: 'Search query' }, limit: { type: 'number', default: 10 }, }, run: async ({ q, limit }) => { // query your DB, CMS, or search index const results = await db.search(q, { limit }) return { results, total: results.length } }, },ย page: { description: 'Get structured content for any page', params: { slug: { type: 'string', required: true }, }, run: async ({ slug }) => { const page = await cms.getPage(slug) if (!page) throw new Error('Page not found') return { title: page.title, content: page.body, meta: page.meta } }, }, },})ย // 2. Export route handlers โ handles manifest, execute, pipeline, sessionsexport const { GET, POST } = createSurfRouteHandler(surf)The createSurfRouteHandler from @surfjs/next returns GET and POST handlers that cover all Surf routes: manifest, execute, pipeline, sessions, and SSE streaming โ all within a single catch-all route.
Pipeline Endpoint
The @surfjs/next adapter automatically exposes a pipeline endpoint at POST /api/surf/pipeline. Agents can execute multiple commands in a single round-trip:
import { SurfClient } from '@surfjs/client'ย const client = await SurfClient.discover('https://yourapp.vercel.app', { basePath: '/api/surf/execute', // Override for Next.js route})ย // Execute multiple commands in one requestconst response = await client.pipeline([ { command: 'search', params: { q: 'laptop' }, as: 'results' }, { command: 'page', params: { slug: '$prev.results[0].slug' } },])The pipeline endpoint supports $prev references between steps, sessions, and continueOnError for resilient workflows.
Browser-Side Commands
Surf isn't just a server protocol โ commands can also execute entirely in the browser with zero server round-trips. Install @surfjs/react, which bundles the @surfjs/web framework-agnostic runtime:
npm install @surfjs/react# orpnpm add @surfjs/reactUse the useSurfCommands() hook in a client component to register local handlers. When window.surf.execute() is called, the @surfjs/web runtime checks for a local handler first โ falling back to the server only if none is found.
'use client'import { useSurfCommands } from '@surfjs/react'ย export function SurfSetup() { useSurfCommands({ 'ui.toggleTheme': { mode: 'local', run: () => { document.documentElement.classList.toggle('dark') return { ok: true } }, }, 'ui.scrollToTop': { mode: 'local', run: () => { window.scrollTo({ top: 0, behavior: 'smooth' }) return { ok: true } }, }, }) return null}Mount this component in your root layout. Add SurfBadge to signal to agents and users that this site supports Surf:
import { SurfBadge } from '@surfjs/react'import { SurfSetup } from '@/components/SurfSetup'ย export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html> <body> {children} <SurfSetup /> <SurfBadge /> </body> </html> )}Full Stack Example
The cleanest pattern is to declare all commands on the server with execution hints that describe where each command runs. Server commands go via the API; browser commands are intercepted locally by useSurfCommands().
import { createSurf } from '@surfjs/core'import { createSurfRouteHandler } from '@surfjs/next'ย const surf = await createSurf({ name: 'My App', commands: { 'data.search': { description: 'Search the database', params: { query: { type: 'string', required: true } }, hints: { execution: 'server' }, run: async ({ query }) => db.search(query), }, 'ui.toggleTheme': { description: 'Toggle dark/light theme', hints: { execution: 'browser' }, // No run() needed โ handled client-side by useSurfCommands() }, },})ย export const { GET, POST } = createSurfRouteHandler(surf, { basePath: '/api/surf' })The server manifest advertises all commands โ including browser-only ones โ so agents see the complete capability surface. The client component wires up the local handlers:
'use client'import { useSurfCommands } from '@surfjs/react'ย export function SurfSetup() { useSurfCommands({ 'ui.toggleTheme': { mode: 'local', run: () => { document.documentElement.classList.toggle('dark') return { ok: true } }, }, }) return null}Execution routing
execution: 'server' โ always hits the API. execution: 'browser' โ intercepted locally, no network call. execution: 'any' (the default) โ uses a local handler if registered, falls back to the server if not. This lets you progressively enhance server commands with client-side overrides.
surf.json Manifest
The @surfjs/next adapter automatically serves the manifest at /api/surf/.well-known/surf.json via the catch-all route. To also serve it at the conventional root path, add a proxy route:
import { NextResponse } from 'next/server'ย export async function GET(request: Request) { const baseUrl = new URL(request.url).origin const manifest = await fetch( `${baseUrl}/api/surf/.well-known/surf.json` ).then(r => r.json()) return NextResponse.json(manifest, { headers: { 'Cache-Control': 'public, max-age=300' }, })}ย export const dynamic = 'force-dynamic'Now any AI agent can discover your commands with a single curl:
curl https://yourapp.vercel.app/.well-known/surf.jsonMiddleware & Auth
Surf has a first-class middleware system. Use it to add authentication, logging, or per-tenant rate limiting before commands run:
import { createSurf } from '@surfjs/core'import { createSurfRouteHandler } from '@surfjs/next'import { verifyToken } from '@/lib/auth'ย const surf = await createSurf({ name: 'My Next.js App',ย // Auth verifier โ called for commands with auth: 'required' authVerifier: async (token) => { const user = await verifyToken(token) if (!user) return { valid: false, reason: 'Invalid token' } return { valid: true, claims: { userId: user.id, tenantId: user.tenantId } } },ย // Per-command rate limiting rateLimit: { windowMs: 60_000, maxRequests: 60, // 60 requests / min per IP },ย commands: { search: { description: 'Search (auth required)', auth: 'required', params: { q: { type: 'string', required: true } }, run: async ({ q }, ctx) => { return db.search(q, { tenantId: ctx.claims!.tenantId }) }, }, },})ย export const { GET, POST } = createSurfRouteHandler(surf)Edge Runtime
Surf is edge-compatible by default โ no Node.js-only APIs. To opt into the edge runtime and eliminate cold starts on Vercel, add one line to your route file:
// Add this export at the top of your route fileexport const runtime = 'edge'ย // The rest of your Surf setup is unchanged...import { createSurf } from '@surfjs/core'import { createSurfRouteHandler } from '@surfjs/next'ย const surf = await createSurf({ /* ... */ })export const { GET, POST } = createSurfRouteHandler(surf)Commands served from the edge have sub-10ms latency globally โ well below Surf's 47ms average. The constraint: your handlers must use edge-compatible APIs (fetch, Web Crypto, KV stores โ not fs or native modules).
Deploy to Vercel
No extra config needed. Push to your repo and Vercel auto-deploys. Make sure to set your base URL env var:
# Set via Vercel CLIvercel env add NEXT_PUBLIC_BASE_URL production# โ https://yourapp.vercel.appย # Or in the Vercel dashboard under Project โ Settings โ Environment VariablesAfter deploy, verify your manifest is live:
curl https://yourapp.vercel.app/.well-known/surf.json | jq .commandsPreview deployments
Vercel creates a unique URL per branch โ useful for testing Surf command changes before merging. Set NEXT_PUBLIC_BASE_URL to the preview URL in your preview environment, or use VERCEL_URL in a server component to derive it at runtime.
Summary
Adding Surf to a Next.js app comes down to four files:
app/api/surf/[...slug]/route.tsโ Your commands + @surfjs/next route handlerapp/.well-known/surf.json/route.tsโ Manifest proxy for agent discoverycomponents/SurfSetup.tsxโ useSurfCommands() for browser-side handlers + SurfBadge.env.localโ NEXT_PUBLIC_BASE_URL=https://yourapp.vercel.appFrom there, every AI agent that discovers your surf.json can call your commands directly โ no screenshots, no brittle selectors, no vision models.