Tutorial Mar 25, 2026

Screenshot API with Vue.js: Capture Any URL from Your Frontend or Backend

A reusable composable, a Nuxt server route, and a full-page capture component — no Puppeteer, no server-side browser required.

You're building a Vue app and need to capture screenshots of URLs. The use cases are everywhere:

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:

Factorhtml2canvasPageBolt API
Captures external URLsNo — same-origin onlyYes — any URL
Renders CSS accuratelyPartial (many known gaps)Full browser render
Captures iframesNoYes
Handles JS-rendered contentOnly what's in the DOM nowFull JS execution
Works on mobile (iOS Safari)BuggyServer-side, no client needed
Bundle size~300KB gzipped0KB (HTTP call)
API key exposure riskN/ARoute 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