next.js example (#694)
* next.js guide
* better
* shadcn
* fixes
* fix: correct screenshot test assertion to check path instead of base64
The daemon returns { path: savePath } for screenshot commands, not base64.
* fix: cross-platform Chrome detection and gitignore hardening
- Replace hardcoded macOS Chrome path with findLocalChrome() that
searches common paths on macOS, Linux, and WSL, with a clear error
message when no Chrome is found.
- Add .env and .env*.local to .gitignore to prevent accidental
secret commits.
* fix: correct Vercel deploy button repo URL to vercel-labs/agent-browser
* clean up
* demo
* next page
---------
Co-authored-by: ctate <366502+ctate@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
---
|
||||
name: next
|
||||
description: Run headless Chrome in Next.js serverless functions using @sparticuz/chromium + puppeteer-core. Use when the user needs browser automation from a Next.js app, wants to take screenshots or snapshots from server actions or API routes, or is building a Next.js app that needs headless Chrome. Triggers include "screenshot from Next.js", "headless Chrome in serverless", "browser automation in Next.js", "puppeteer on Vercel", or any task requiring Chrome in a Next.js server context.
|
||||
---
|
||||
|
||||
# Browser Automation in Next.js Serverless Functions
|
||||
|
||||
Run headless Chrome directly inside Next.js server actions and API routes using `@sparticuz/chromium` + `puppeteer-core`. No external server needed -- Chrome runs in the same serverless function.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
pnpm add @sparticuz/chromium puppeteer-core
|
||||
```
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```ts
|
||||
import puppeteer from "puppeteer-core";
|
||||
import chromium from "@sparticuz/chromium";
|
||||
import fs from "node:fs";
|
||||
|
||||
const CHROME_PATHS = [
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/usr/bin/google-chrome",
|
||||
"/usr/bin/google-chrome-stable",
|
||||
"/usr/bin/chromium",
|
||||
"/usr/bin/chromium-browser",
|
||||
];
|
||||
|
||||
function findLocalChrome(): string {
|
||||
for (const p of CHROME_PATHS) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
throw new Error(
|
||||
`Chrome not found. Set CHROMIUM_PATH to your Chrome/Chromium binary.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
const isLambda =
|
||||
!!process.env.VERCEL || !!process.env.AWS_LAMBDA_FUNCTION_NAME;
|
||||
|
||||
const executablePath = isLambda
|
||||
? await chromium.executablePath()
|
||||
: process.env.CHROMIUM_PATH || findLocalChrome();
|
||||
|
||||
const args = isLambda
|
||||
? chromium.args
|
||||
: ["--no-sandbox", "--disable-setuid-sandbox"];
|
||||
|
||||
return puppeteer.launch({
|
||||
args,
|
||||
executablePath,
|
||||
headless: true,
|
||||
defaultViewport: { width: 1280, height: 720 },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
On Vercel, `@sparticuz/chromium` bundles a compatible Chromium binary automatically. Locally, the launcher falls back to the system Chrome installation or `CHROMIUM_PATH`.
|
||||
|
||||
## Server Actions
|
||||
|
||||
### Screenshot
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
|
||||
export async function takeScreenshot(url: string) {
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
|
||||
const title = await page.title();
|
||||
const screenshot = await page.screenshot({
|
||||
fullPage: true,
|
||||
encoding: "base64",
|
||||
});
|
||||
return { ok: true, title, screenshot: screenshot as string };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility Snapshot
|
||||
|
||||
```ts
|
||||
"use server";
|
||||
|
||||
export async function takeSnapshot(url: string) {
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
|
||||
const title = await page.title();
|
||||
const snapshot = await page.accessibility.snapshot();
|
||||
return { ok: true, title, snapshot: JSON.stringify(snapshot, null, 2) };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Routes
|
||||
|
||||
```ts
|
||||
// app/api/browse/route.ts
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { url, action } = await req.json();
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Provide a 'url'" }, { status: 400 });
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.goto(url, { waitUntil: "networkidle2", timeout: 30_000 });
|
||||
|
||||
if (action === "screenshot") {
|
||||
const screenshot = await page.screenshot({ encoding: "base64" });
|
||||
return NextResponse.json({ screenshot });
|
||||
}
|
||||
|
||||
if (action === "snapshot") {
|
||||
const snapshot = await page.accessibility.snapshot();
|
||||
return NextResponse.json({ snapshot });
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: "action must be 'screenshot' or 'snapshot'" },
|
||||
{ status: 400 },
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `CHROMIUM_PATH` | Local dev only | Path to Chrome/Chromium binary. Not needed on Vercel. |
|
||||
|
||||
On Vercel, `@sparticuz/chromium` auto-detects the bundled binary. Locally, if Chrome is not in a standard location, set `CHROMIUM_PATH`.
|
||||
|
||||
## Vercel Configuration
|
||||
|
||||
The `@sparticuz/chromium` binary is large (~50MB). Increase the serverless function's memory and timeout if needed:
|
||||
|
||||
```ts
|
||||
// next.config.ts
|
||||
const nextConfig = {
|
||||
serverExternalPackages: ["@sparticuz/chromium"],
|
||||
};
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
If the project lives in a monorepo subdirectory, set `outputFileTracingRoot` so the Chromium binary is included in the deployment:
|
||||
|
||||
```ts
|
||||
import path from "node:path";
|
||||
|
||||
const nextConfig = {
|
||||
outputFileTracingRoot: path.join(import.meta.dirname, "../../"),
|
||||
serverExternalPackages: ["@sparticuz/chromium"],
|
||||
};
|
||||
export default nextConfig;
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Vercel serverless functions have a 50MB compressed size limit. `@sparticuz/chromium` fits within this but leaves limited room for other large dependencies.
|
||||
- Function execution timeout is 10s on Hobby, 300s on Pro. Complex page loads may need the Pro plan.
|
||||
- Each invocation launches a fresh browser. There is no session persistence between requests.
|
||||
- For workflows that need persistent sessions, longer timeouts, or full Chrome (no size limits), use the Vercel Sandbox pattern instead (see the `vercel-sandbox` skill).
|
||||
|
||||
## Example
|
||||
|
||||
See `examples/demo/` in the agent-browser repo for a working app with both serverless and sandbox patterns, and a deploy-to-Vercel button.
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: vercel-sandbox
|
||||
description: Run agent-browser + Chrome inside Vercel Sandbox microVMs for browser automation from any Vercel-deployed app. Use when the user needs browser automation in a Vercel app (Next.js, SvelteKit, Nuxt, Remix, Astro, etc.), wants to run headless Chrome without binary size limits, needs persistent browser sessions across commands, or wants ephemeral isolated browser environments. Triggers include "Vercel Sandbox browser", "microVM Chrome", "agent-browser in sandbox", "browser automation on Vercel", or any task requiring Chrome in a Vercel Sandbox.
|
||||
---
|
||||
|
||||
# Browser Automation with Vercel Sandbox
|
||||
|
||||
Run agent-browser + headless Chrome inside ephemeral Vercel Sandbox microVMs. A Linux VM spins up on demand, executes browser commands, and shuts down. Works with any Vercel-deployed framework (Next.js, SvelteKit, Nuxt, Remix, Astro, etc.).
|
||||
|
||||
## When to Use Sandbox vs Serverless
|
||||
|
||||
| | Vercel Sandbox | Serverless (`@sparticuz/chromium`) |
|
||||
|---|---|---|
|
||||
| Binary size limit | None | 50MB compressed |
|
||||
| Session persistence | Yes, within a sandbox lifetime | No, fresh browser per request |
|
||||
| Multi-step workflows | Yes, run sequences of commands | Single request only |
|
||||
| Startup time | ~30s cold, sub-second with snapshot | ~2-3s |
|
||||
| Framework support | Any (Next.js, SvelteKit, Nuxt, etc.) | Next.js (or any Node.js serverless) |
|
||||
|
||||
Use Sandbox when you need full Chrome, multi-step workflows, or longer execution times. Use serverless when you need fast single-request screenshots/snapshots.
|
||||
|
||||
## Dependencies
|
||||
|
||||
```bash
|
||||
pnpm add @vercel/sandbox
|
||||
```
|
||||
|
||||
The sandbox VM installs agent-browser and Chrome on first run. Use snapshots (below) to skip this step.
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```ts
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
async function withBrowser<T>(
|
||||
fn: (sandbox: InstanceType<typeof Sandbox>) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const snapshotId = process.env.AGENT_BROWSER_SNAPSHOT_ID;
|
||||
|
||||
const sandbox = snapshotId
|
||||
? await Sandbox.create({
|
||||
source: { type: "snapshot", snapshotId },
|
||||
timeout: 120_000,
|
||||
})
|
||||
: await Sandbox.create({ runtime: "node24", timeout: 120_000 });
|
||||
|
||||
if (!snapshotId) {
|
||||
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
||||
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn(sandbox);
|
||||
} finally {
|
||||
await sandbox.stop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Screenshot
|
||||
|
||||
```ts
|
||||
export async function screenshotUrl(url: string) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const titleResult = await sandbox.runCommand("agent-browser", [
|
||||
"get", "title", "--json",
|
||||
]);
|
||||
const title = JSON.parse(await titleResult.stdout())?.data?.title || url;
|
||||
|
||||
const ssResult = await sandbox.runCommand("agent-browser", [
|
||||
"screenshot", "--json",
|
||||
]);
|
||||
const screenshot = JSON.parse(await ssResult.stdout())?.data?.base64 || "";
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
|
||||
return { title, screenshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Accessibility Snapshot
|
||||
|
||||
```ts
|
||||
export async function snapshotUrl(url: string) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const titleResult = await sandbox.runCommand("agent-browser", [
|
||||
"get", "title", "--json",
|
||||
]);
|
||||
const title = JSON.parse(await titleResult.stdout())?.data?.title || url;
|
||||
|
||||
const snapResult = await sandbox.runCommand("agent-browser", [
|
||||
"snapshot", "-i", "-c",
|
||||
]);
|
||||
const snapshot = await snapResult.stdout();
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
|
||||
return { title, snapshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Multi-Step Workflows
|
||||
|
||||
The sandbox persists between commands, so you can run full automation sequences:
|
||||
|
||||
```ts
|
||||
export async function fillAndSubmitForm(url: string, data: Record<string, string>) {
|
||||
return withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", url]);
|
||||
|
||||
const snapResult = await sandbox.runCommand("agent-browser", [
|
||||
"snapshot", "-i",
|
||||
]);
|
||||
const snapshot = await snapResult.stdout();
|
||||
// Parse snapshot to find element refs...
|
||||
|
||||
for (const [ref, value] of Object.entries(data)) {
|
||||
await sandbox.runCommand("agent-browser", ["fill", ref, value]);
|
||||
}
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["click", "@e5"]);
|
||||
await sandbox.runCommand("agent-browser", ["wait", "--load", "networkidle"]);
|
||||
|
||||
const ssResult = await sandbox.runCommand("agent-browser", [
|
||||
"screenshot", "--json",
|
||||
]);
|
||||
const screenshot = JSON.parse(await ssResult.stdout())?.data?.base64 || "";
|
||||
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
|
||||
return { screenshot };
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Snapshots (Fast Startup)
|
||||
|
||||
Without a snapshot, the first sandbox run installs agent-browser + Chromium (~30s). Create a snapshot to make startup sub-second:
|
||||
|
||||
```ts
|
||||
import { Sandbox } from "@vercel/sandbox";
|
||||
|
||||
async function createSnapshot(): Promise<string> {
|
||||
const sandbox = await Sandbox.create({
|
||||
runtime: "node24",
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
await sandbox.runCommand("npm", ["install", "-g", "agent-browser"]);
|
||||
await sandbox.runCommand("npx", ["agent-browser", "install"]);
|
||||
|
||||
const snapshot = await sandbox.snapshot();
|
||||
return snapshot.snapshotId;
|
||||
}
|
||||
```
|
||||
|
||||
Run this once, then set the environment variable:
|
||||
|
||||
```bash
|
||||
AGENT_BROWSER_SNAPSHOT_ID=snap_xxxxxxxxxxxx
|
||||
```
|
||||
|
||||
A helper script is available in the demo app:
|
||||
|
||||
```bash
|
||||
npx tsx examples/demo/scripts/create-snapshot.ts
|
||||
```
|
||||
|
||||
## Scheduled Workflows (Cron)
|
||||
|
||||
Combine with Vercel Cron Jobs for recurring browser tasks:
|
||||
|
||||
```ts
|
||||
// app/api/cron/route.ts (or equivalent in your framework)
|
||||
export async function GET() {
|
||||
const result = await withBrowser(async (sandbox) => {
|
||||
await sandbox.runCommand("agent-browser", ["open", "https://example.com/pricing"]);
|
||||
const snap = await sandbox.runCommand("agent-browser", ["snapshot", "-i", "-c"]);
|
||||
await sandbox.runCommand("agent-browser", ["close"]);
|
||||
return await snap.stdout();
|
||||
});
|
||||
|
||||
// Process results, send alerts, store data...
|
||||
return Response.json({ ok: true, snapshot: result });
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
// vercel.json
|
||||
{ "crons": [{ "path": "/api/cron", "schedule": "0 9 * * *" }] }
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `AGENT_BROWSER_SNAPSHOT_ID` | No (but recommended) | Pre-built snapshot ID for sub-second startup |
|
||||
|
||||
The Vercel Sandbox SDK handles OIDC authentication automatically when deployed on Vercel. For local development, run `vercel link` and `vercel env pull` to get the required tokens.
|
||||
|
||||
## Framework Examples
|
||||
|
||||
The pattern works identically across frameworks. The only difference is where you put the server-side code:
|
||||
|
||||
| Framework | Server code location |
|
||||
|---|---|
|
||||
| Next.js | Server actions, API routes, route handlers |
|
||||
| SvelteKit | `+page.server.ts`, `+server.ts` |
|
||||
| Nuxt | `server/api/`, `server/routes/` |
|
||||
| Remix | `loader`, `action` functions |
|
||||
| Astro | `.astro` frontmatter, API routes |
|
||||
|
||||
## Example
|
||||
|
||||
See `examples/demo/` in the agent-browser repo for a working app with the Vercel Sandbox pattern, including a snapshot creation script and demo UI.
|
||||
Reference in New Issue
Block a user