Written By Hanzala Saleem
Updated At June 18, 2026 | 7 min read
To take a website screenshot in Next.js, send the target URL to a screenshot API from a server-side route and render the returned image. This avoids running a headless browser inside your own deployment, which fails on serverless hosts like Vercel because Chromium cannot launch in that runtime.
This guide builds a working Next.js app step by step. You enter a website URL, click a button, and the app displays a screenshot of that page. The app uses the App Router (the default in current Next.js) and calls ScreenshotAPI from a route handler, so your API key never reaches the browser.
We also cover the two questions every developer hits next: how to run this on Vercel without a server crash, and when a screenshot API is the right call versus self-hosting Puppeteer.
The fastest way is to call a screenshot API from a Next.js route handler. You send a target URL to the API, it renders the page in a managed Chromium browser, and it returns an image URL or the image bytes. Your app displays the result. This takes one HTTP request and works on any host, including serverless platforms where you cannot launch a browser yourself. Self-hosted Puppeteer is the alternative, but it requires a Node.js runtime that can run Chromium and adds memory and maintenance overhead.
By the end of this tutorial, you will have built a Next.js app that:
Create a new Next.js project. The current version uses the App Router by default, which is what this guide targets.
npx create-next-app@latest website-screenshot
cd website-screenshotAccept the defaults when prompted. The key choice is the App Router (the app/ directory), which we rely on for the server-side route handler in Step 4.
Next.js ships with the global fetch function on both the server and the client, so you do not need an extra HTTP library for this app. The examples below use fetch. If you prefer Axios for interceptors or a shared client config, install it:
npm install axiosThe rest of this guide uses fetch to keep the dependency count at zero.
Open app/page.js and replace its contents with a client component that holds the URL input and the capture button. The 'use client' directive is required because this component uses React state.
'use client';
import { useState } from 'react';
export default function Home() {
const [url, setUrl] = useState('');
const [screenshotUrl, setScreenshotUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleCaptureScreenshot = async () => {
// API call added in Step 4
};
return (
<main style={{ maxWidth: 640, margin: '40px auto', padding: 16 }}>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="Enter website URL (https://example.com)"
style={{ width: '100%', padding: 8 }}
/>
<button onClick={handleCaptureScreenshot} disabled={loading} style={{ marginTop: 12 }}>
{loading ? 'Capturing...' : 'Capture Screenshot'}
</button>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
</main>
);
}The component tracks four pieces of state: the input URL, the returned screenshot URL, a loading flag for the button label, and an error message. We wire up the capture logic next.
Register at ScreenshotAPI and copy your API key from the dashboard. Store it as an environment variable, never in client code. Create a .env.local file in your project root:
SCREENSHOT_API_TOKEN=your_api_key_herePutting the token in a route handler keeps it on the server. If you call the API directly from the browser, anyone can read your key from the network tab and spend your quota.
Create the route handler at app/api/screenshot/route.js:
export async function POST(request) {
const { url } = await request.json();
if (!url) {
return Response.json({ error: 'A url is required.' }, { status: 400 });
}
const token = process.env.SCREENSHOT_API_TOKEN;
const endpoint =
`https://shot.screenshotapi.net/v3/screenshot` +
`?token=${token}&url=${encodeURIComponent(url)}&output=json&file_type=png`;
try {
const response = await fetch(endpoint);
const data = await response.json();
return Response.json({ screenshot: data.screenshot });
} catch (err) {
return Response.json({ error: 'Failed to capture the screenshot.' }, { status: 500 });
}
}Two details matter here. First, encodeURIComponent(url) escapes the target URL so query parameters in the user's input do not break the request. Second, output=json tells ScreenshotAPI to return a JSON object whose screenshot field holds the hosted image URL, which is what the front end renders in the next step.
Fill in handleCaptureScreenshot to POST the URL to your route handler and store the returned image URL in state. Then render an img element when a screenshot exists.
'use client';
import { useState } from 'react';
export default function Home() {
const [url, setUrl] = useState('');
const [screenshotUrl, setScreenshotUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleCaptureScreenshot = async () => {
setLoading(true);
setError('');
setScreenshotUrl('');
try {
const res = await fetch('/api/screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
setScreenshotUrl(data.screenshot);
} catch (err) {
setError(err.message || 'Something went wrong.');
} finally {
setLoading(false);
}
};
return (
<main style={{ maxWidth: 640, margin: '40px auto', padding: 16 }}>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="Enter website URL (https://example.com)"
style={{ width: '100%', padding: 8 }}
/>
<button onClick={handleCaptureScreenshot} disabled={loading} style={{ marginTop: 12 }}>
{loading ? 'Capturing...' : 'Capture Screenshot'}
</button>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
{screenshotUrl && (
<img src={screenshotUrl} alt={`Screenshot of ${url}`} style={{ marginTop: 16, width: '100%' }} />
)}
</main>
);
}Run npm run dev, open http://localhost:3000, paste a URL, and click the button. The screenshot appears below the form. The loading flag swaps the button label while the capture is in flight, and the error state surfaces any failed request instead of failing silently.
This app deploys to Vercel with no extra configuration because the heavy work happens inside ScreenshotAPI, not your function. The route handler only forwards a URL and returns JSON, so it stays well under serverless memory and execution limits.
This is the main reason to use a screenshot API in a Next.js project. Self-hosted Puppeteer launches a Chromium binary, which a Vercel or Cloudflare Workers function cannot run without bundling a special build, and even then, you fight cold starts and the 50 MB function size cap. Set SCREENSHOT_API_TOKEN in your Vercel project environment variables, deploy, and the app behaves the same as it does locally.
Both approaches capture screenshots. They differ in where the browser runs and who maintains it.
| Factor | Screenshot API | Self-hosted Puppeteer |
|---|---|---|
| Runs on serverless (Vercel, Workers) | Yes, no config | No, needs custom Chromium build or a VPS |
| Setup time | One API call | Install Chromium, configure launch flags |
| Cold start and memory | Handled by the API | Large bundle, slow cold starts, high memory |
| Ad and cookie banner blocking | Built in | You write and maintain it |
| Maintenance | None | Patch Chromium, fix zombie processes |
| Cost model | Per capture | Server time plus engineering time |
| Best fit | Serverless apps, scale, low maintenance | Full control on a long-running Node server |
Use self-hosted Puppeteer when you run a long-lived Node.js server, need full control of the browser, and capture low volumes. Use a screenshot API when you deploy serverless, want ad and banner blocking out of the box, or do not want to babysit a browser in production.
You now have a Next.js app that captures and displays website screenshots, with the API call running server-side and the app deployable to Vercel without browser launch errors. From here you can add batch capture, expose output options like full-page or PDF, or persist results to your own storage.
Ready to build it for real? Create a free ScreenshotAPI key and drop it into the route handler from Step 4.
Vercel functions run in a serverless environment that cannot launch a standard Chromium binary, so puppeteer.launch() throws at runtime. You either bundle a serverless-specific Chromium build and accept slow cold starts, or move the capture to a screenshot API that runs the browser for you. The API approach is why the app in this guide deploys to Vercel unchanged.
Call the API from a server-side route handler, as this guide does in Step 4, and read the key from an environment variable. The browser only talks to your own /api/screenshot route, so the token never appears in client code or the network tab.
Yes. ScreenshotAPI accepts authentication parameters and cookies, so you can capture pages that require a session. Pass the credentials as request parameters from your route handler rather than the browser.
Yes. ScreenshotAPI supports PNG, JPG, WebP, and PDF output, custom viewport sizes, full-page capture, and a render delay for animation-heavy pages. Add the parameters to the endpoint URL in your route handler. See the API documentation for the full list.
Trigger your route handler from a Vercel cron job or a scheduled serverless function, or use the scheduling features of the API directly. For large batches, send URLs through a queue so you do not block a single request.