You're building a Vue app and need to capture screenshots of URLs. The use cases are everywhere:
- A link preview card that shows a thumbnail before the user clicks
- A bookmark manager with visual snapshots of saved pages
- A content moderation tool that screenshots submitted URLs for review
- An OG image generator for a Nuxt-powered blog
- A monitoring dashboard that visually diffs pages over time
The naive path is to add Puppeteer or Playwright to your Node backend. It works in development, then breaks your Docker build, crashes under load, and consumes 300MB of RAM doing a job that takes one HTTP call.
This guide shows you the clean approach: a hosted screenshot API paired with Vue composables and Nuxt server routes. No browser management. No infrastructure changes.
The Core API Call
The PageBolt screenshot endpoint accepts a URL and returns a PNG binary. Here's the raw call in Node:
const response = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: 'https://example.com',
width: 1280,
height: 800,
blockBanners: true,
}),
})
const imageBuffer = await response.arrayBuffer()
// → PNG bytes, ready to save or serve
Everything below is built on this foundation.
Option 1: Nuxt Server Route (Recommended)
For Nuxt 3 apps, keep the API key server-side by creating a server route that proxies the request:
// server/api/screenshot.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event)
if (!body.url) {
throw createError({ statusCode: 400, message: 'url is required' })
}
const response = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: body.url,
width: body.width ?? 1280,
height: body.height ?? 800,
fullPage: body.fullPage ?? false,
blockBanners: true,
blockAds: true,
}),
})
if (!response.ok) {
throw createError({ statusCode: 502, message: 'Screenshot failed' })
}
const imageBuffer = await response.arrayBuffer()
setHeader(event, 'Content-Type', 'image/png')
setHeader(event, 'Cache-Control', 'public, max-age=3600')
return new Uint8Array(imageBuffer)
})
Your API key never touches the client. The route also adds a 1-hour cache header — important if multiple users screenshot the same URL.
Option 2: A Reusable Vue Composable
Wrap the server route in a composable for clean, reactive state in your components:
// composables/useScreenshot.ts
import { ref } from 'vue'
interface ScreenshotOptions {
width?: number
height?: number
fullPage?: boolean
}
export function useScreenshot() {
const imageUrl = ref<string | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function capture(url: string, options: ScreenshotOptions = {}) {
loading.value = true
error.value = null
imageUrl.value = null
try {
const response = await $fetch('/api/screenshot', {
method: 'POST',
body: { url, ...options },
responseType: 'blob',
})
// Revoke previous object URL to avoid memory leaks
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = URL.createObjectURL(response as Blob)
} catch (e: any) {
error.value = e?.message ?? 'Screenshot failed'
} finally {
loading.value = false
}
}
function reset() {
if (imageUrl.value) URL.revokeObjectURL(imageUrl.value)
imageUrl.value = null
error.value = null
}
return { imageUrl, loading, error, capture, reset }
}
A Screenshot Capture Component
Now a full component that uses the composable:
<!-- components/UrlScreenshot.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useScreenshot } from '~/composables/useScreenshot'
const { imageUrl, loading, error, capture, reset } = useScreenshot()
const urlInput = ref('')
async function handleCapture() {
if (!urlInput.value.trim()) return
await capture(urlInput.value, { fullPage: false, width: 1280, height: 800 })
}
function handleDownload() {
if (!imageUrl.value) return
const a = document.createElement('a')
a.href = imageUrl.value
a.download = 'screenshot.png'
a.click()
}
</script>
<template>
<div class="screenshot-widget">
<div class="input-row">
<input
v-model="urlInput"
type="url"
placeholder="https://example.com"
@keyup.enter="handleCapture"
/>
<button :disabled="loading" @click="handleCapture">
{{ loading ? 'Capturing…' : 'Capture' }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="imageUrl" class="preview">
<img :src="imageUrl" alt="Page screenshot" />
<div class="actions">
<button @click="handleDownload">Download PNG</button>
<button @click="reset">Clear</button>
</div>
</div>
</div>
</template>
Option 3: Vue 3 + Express (Without Nuxt)
If you're running a separate Express backend, here's the equivalent server route and a plain Vue 3 client call:
// Express route — server.js
import express from 'express'
const router = express.Router()
router.post('/api/screenshot', async (req, res) => {
const { url, width = 1280, height = 800, fullPage = false } = req.body
if (!url) return res.status(400).json({ error: 'url required' })
try {
const upstream = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, width, height, fullPage, blockBanners: true }),
})
if (!upstream.ok) return res.status(502).json({ error: 'Screenshot failed' })
res.setHeader('Content-Type', 'image/png')
res.setHeader('Cache-Control', 'public, max-age=3600')
upstream.body.pipe(res)
} catch (err) {
res.status(500).json({ error: 'Internal error' })
}
})
export default router
// Vue 3 component (Options API)
export default {
data() {
return { url: '', imageUrl: null, loading: false, error: null }
},
methods: {
async capture() {
this.loading = true
this.error = null
try {
const res = await fetch('/api/screenshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: this.url }),
})
if (!res.ok) throw new Error('Capture failed')
const blob = await res.blob()
if (this.imageUrl) URL.revokeObjectURL(this.imageUrl)
this.imageUrl = URL.createObjectURL(blob)
} catch (e) {
this.error = e.message
} finally {
this.loading = false
}
},
},
}
Generating OG Images at Build Time (Nuxt)
For static sites, capture OG images during the Nuxt build with a simple script:
// scripts/generate-og-images.ts
import fs from 'fs'
import path from 'path'
const pages = [
{ slug: 'about', url: 'http://localhost:3000/about' },
{ slug: 'pricing', url: 'http://localhost:3000/pricing' },
{ slug: 'blog', url: 'http://localhost:3000/blog' },
]
async function generateOgImages() {
for (const page of pages) {
const response = await fetch('https://pagebolt.dev/api/v1/screenshot', {
method: 'POST',
headers: {
'x-api-key': process.env.PAGEBOLT_API_KEY!,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: page.url,
width: 1200,
height: 630,
blockBanners: true,
}),
})
if (!response.ok) {
console.error(`Failed to capture ${page.slug}`)
continue
}
const buffer = await response.arrayBuffer()
const outPath = path.join('public', 'og', `${page.slug}.png`)
fs.mkdirSync(path.dirname(outPath), { recursive: true })
fs.writeFileSync(outPath, Buffer.from(buffer))
console.log(`✓ ${outPath}`)
}
}
generateOgImages()
Run it with npx tsx scripts/generate-og-images.ts before your Nuxt build.
Caching Screenshots with useAsyncData
For pages that load a screenshot on mount, use useAsyncData to avoid re-fetching on navigation:
<script setup lang="ts">
const route = useRoute()
const targetUrl = computed(() => route.query.url as string)
const { data: screenshotBlob, pending, error } = await useAsyncData(
`screenshot-${targetUrl.value}`,
() => $fetch('/api/screenshot', {
method: 'POST',
body: { url: targetUrl.value },
responseType: 'blob',
}),
{ watch: [targetUrl] }
)
const imageUrl = computed(() =>
screenshotBlob.value ? URL.createObjectURL(screenshotBlob.value as Blob) : null
)
</script>
<template>
<div>
<div v-if="pending">Capturing…</div>
<div v-else-if="error">{{ error.message }}</div>
<img v-else-if="imageUrl" :src="imageUrl" alt="Page preview" />
</div>
</template>
What About html2canvas?
html2canvas is a popular client-side library for capturing DOM snapshots. It has real limitations worth knowing:
| Factor | html2canvas | PageBolt API |
|---|---|---|
| Captures external URLs | No — same-origin only | Yes — any URL |
| Renders CSS accurately | Partial (many known gaps) | Full browser render |
| Captures iframes | No | Yes |
| Handles JS-rendered content | Only what's in the DOM now | Full JS execution |
| Works on mobile (iOS Safari) | Buggy | Server-side, no client needed |
| Bundle size | ~300KB gzipped | 0KB (HTTP call) |
| API key exposure risk | N/A | Route through server |
html2canvas makes sense when you need to capture your own component's current state (e.g. a chart export). For capturing external URLs or getting pixel-perfect renders, use a server-side API.
Getting Started
Get a free API key at pagebolt.dev/dashboard — 100 requests/month, no credit card required.
For a Nuxt 3 project, add your key to .env:
PAGEBOLT_API_KEY=your_key_here
Then drop the server route from Option 1 into server/api/screenshot.post.ts and the composable into composables/useScreenshot.ts. You're done.
Add screenshots to your Vue app today
PageBolt handles the browser so you don't have to. One API key, no infrastructure, works with any Vue 3 or Nuxt 3 project.
Get your free API key