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
+ Your command definitions

What you get

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

Installation

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

terminal
npm install surfjs
# or
pnpm add surfjs

That's the only dependency. Surf has no peer dependencies and ships ESM + CJS.

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'
import { NextRequest } from 'next/server'
ย 
// 1. Define your commands
const surf = createSurf({
name: 'My Next.js App',
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
commands: {
search: {
description: 'Search articles or products',
parameters: {
q: { type: 'string', required: true, description: 'Search query' },
limit: { type: 'number', default: 10 },
},
async handler({ 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',
parameters: {
slug: { type: 'string', required: true },
},
async handler({ 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 the handler โ€” Next.js handles routing
const handler = surf.toNextHandler()
export { handler as GET, handler as POST }

Commands are now reachable at POST /api/surf/search and POST /api/surf/page. Agents execute them by name โ€” no URL mapping required.

surf.json Manifest

Surf auto-generates the manifest at /api/surf/.well-known/surf.json, but convention is to expose it at the root. Add a tiny proxy route:

app/.well-known/surf.json/route.ts
import { NextResponse } from 'next/server'
ย 
export async function GET() {
const manifest = await fetch(
`${process.env.NEXT_PUBLIC_BASE_URL}/api/surf/.well-known/surf.json`
).then(r => r.json())
return NextResponse.json(manifest)
}
ย 
// Ensure this route is never cached โ€” agents always need the live schema
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'
import { verifyToken } from '@/lib/auth'
ย 
const surf = createSurf({
name: 'My Next.js App',
baseUrl: process.env.NEXT_PUBLIC_BASE_URL!,
ย 
// Global auth middleware โ€” runs before every command
middleware: [
async (ctx, next) => {
const token = ctx.request.headers.get('Authorization')?.replace('Bearer ', '')
if (!token) throw new Error('Unauthorized')
ย 
const user = await verifyToken(token)
ctx.state.user = user // available in all handlers via ctx.state
return next()
},
],
ย 
// Per-command rate limiting
rateLimit: {
window: '1m',
max: 60, // 60 requests / min per IP
},
ย 
commands: {
search: {
description: 'Search (auth required)',
parameters: { q: { type: 'string', required: true } },
async handler({ q }, ctx) {
const { user } = ctx.state
return db.search(q, { tenantId: user.tenantId })
},
},
},
})
ย 
const handler = surf.toNextHandler()
export { handler as GET, handler as POST }

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'
ย 
const surf = createSurf({ /* ... */ })
const handler = surf.toNextHandler()
export { handler as GET, handler as POST }

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 three files:

app/api/surf/[...surf]/route.tsโ€” Your commands + Surf router
app/.well-known/surf.json/route.tsโ€” Manifest proxy for agent discovery
.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.