Skip to content
Home/Guides/Next.js & Vercel
Guide ยท 10 min read

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.ts
+ app/.well-known/surf.json/route.ts
+ components/SurfSetup.tsx
+ Your command definitions

What you get

โœ“ Auto-discovery via surf.json
โœ“ Full TypeScript inference
โœ“ Built-in rate limiting & auth
โœ“ Browser-side command execution
โœ“ Edge-compatible, zero cold starts

Installation

Install the core package into your existing Next.js project:

terminal
npm install @surfjs/core @surfjs/next
# or
pnpm 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.

app/api/surf/[...surf]/route.ts
import { createSurf } from '@surfjs/core'
import { createSurfRouteHandler } from '@surfjs/next'
ย 
// 1. Define your commands
const 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, sessions
export 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:

agent.ts
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 request
const 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:

terminal
npm install @surfjs/react
# or
pnpm add @surfjs/react

Use 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.

components/SurfSetup.tsx
'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:

app/layout.tsx
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().

app/api/surf/[[...slug]]/route.ts
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:

components/SurfSetup.tsx
'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:

app/.well-known/surf.json/route.ts
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:

terminal
curl https://yourapp.vercel.app/.well-known/surf.json

Middleware & Auth

Surf has a first-class middleware system. Use it to add authentication, logging, or per-tenant rate limiting before commands run:

app/api/surf/[...surf]/route.ts
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:

app/api/surf/[...surf]/route.ts
// Add this export at the top of your route file
export 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:

terminal
# Set via Vercel CLI
vercel env add NEXT_PUBLIC_BASE_URL production
# โ†’ https://yourapp.vercel.app
ย 
# Or in the Vercel dashboard under Project โ†’ Settings โ†’ Environment Variables

After deploy, verify your manifest is live:

terminal
curl https://yourapp.vercel.app/.well-known/surf.json | jq .commands

Preview 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 handler
app/.well-known/surf.json/route.tsโ€” Manifest proxy for agent discovery
components/SurfSetup.tsxโ€” useSurfCommands() for browser-side handlers + SurfBadge
.env.localโ€” NEXT_PUBLIC_BASE_URL=https://yourapp.vercel.app

From there, every AI agent that discovers your surf.json can call your commands directly โ€” no screenshots, no brittle selectors, no vision models.