next

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Browser Automation in Next.js Serverless Functions

在Next.js无服务器函数中实现浏览器自动化

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.
使用
@sparticuz/chromium
+
puppeteer-core
直接在Next.js Server Actions和API路由中运行无头Chrome。无需外部服务器——Chrome直接在同一无服务器函数中运行。

Dependencies

依赖

bash
pnpm add @sparticuz/chromium puppeteer-core
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
.
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 },
  });
}
在Vercel上,
@sparticuz/chromium
会自动打包兼容的Chromium二进制文件。在本地环境中,启动器会回退到系统Chrome安装路径或
CHROMIUM_PATH
配置的路径。

Server Actions

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();
  }
}
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();
  }
}
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

API路由

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();
  }
}
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

环境变量

VariableRequiredDescription
CHROMIUM_PATH
Local dev onlyPath 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
.
变量名是否必填描述
CHROMIUM_PATH
仅本地开发需要Chrome/Chromium二进制文件的路径。在Vercel上无需配置。
在Vercel上,
@sparticuz/chromium
会自动检测打包的二进制文件。在本地环境中,如果Chrome不在标准路径下,需设置
CHROMIUM_PATH

Vercel Configuration

Vercel配置

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;
@sparticuz/chromium
的二进制文件体积较大(约50MB)。如果需要,可增加无服务器函数的内存和超时时间:
ts
// next.config.ts
const nextConfig = {
  serverExternalPackages: ["@sparticuz/chromium"],
};
export default nextConfig;
如果项目位于 monorepo 的子目录中,需设置
outputFileTracingRoot
以确保Chromium二进制文件被包含在部署包中:
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).
  • Vercel无服务器函数的压缩包大小限制为50MB。
    @sparticuz/chromium
    符合该限制,但留给其他大型依赖的空间有限。
  • Hobby计划的函数执行超时时间为10秒,Pro计划为300秒。复杂页面加载可能需要Pro计划。
  • 每次调用都会启动一个全新的浏览器,请求之间没有会话持久化。
  • 对于需要持久会话、更长超时时间或完整Chrome(无大小限制)的工作流,建议使用Vercel Sandbox模式(详见
    vercel-sandbox
    技能)。

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.
可查看agent-browser仓库中的
examples/demo/
目录,获取同时包含无服务器和Sandbox模式的可运行应用,以及一键部署到Vercel的按钮。