Vercel
Guides

Accessing Previews

Learn how to display v0 chat previews from the Platform API v2

Use chats.getPreview to display the live preview for a chat in your own product.

Preview access is designed to go through your backend. The API returns a preview URL and a short-lived preview token. Browsers cannot attach that token as a custom header when loading an iframe, so your iframe should point at your own proxy route, not directly at the preview URL.

Your proxy is responsible for:

  • calling chats.getPreview
  • caching the returned preview while it is valid
  • forwarding iframe requests to the preview URL with the preview token
  • refreshing the cached preview when the sandbox restarts or the token expires

Proxy flow

  1. Your frontend renders an iframe that points to your backend.
  2. Your backend gets or refreshes the preview for the chat.
  3. Your backend forwards the iframe request to preview.url with x-v0-preview-token.
  4. If v0 returns x-v0-preview-refresh: 1, clear your cached preview, call chats.getPreview again, and retry the original request once.
  5. If preview is null, return your own loading response and try again shortly.
<iframe src="/api/v0-preview/chat_abc123/" />

Example proxy

The example below uses an in-memory cache for clarity. Use Redis or another shared cache in production.

import { v0 } from 'v0'

type Preview = {
  url: string
  token: string
  expiresAt: string
}

const previewCache = new Map<string, Preview>()

function previewLoadingResponse() {
  return new Response(
    `<!doctype html>
      <html>
        <head>
          <meta http-equiv="refresh" content="3" />
        </head>
        <body>Preview is starting...</body>
      </html>`,
    {
      status: 202,
      headers: {
        'content-type': 'text/html; charset=utf-8',
        'cache-control': 'no-store',
      },
    },
  )
}

async function getPreview(chatId: string, options: { refresh?: boolean } = {}) {
  const cached = previewCache.get(chatId)
  const now = Date.now()

  if (
    !options.refresh &&
    cached &&
    new Date(cached.expiresAt).getTime() - now > 60_000
  ) {
    return cached
  }

  const { preview } = await v0.chats.getPreview({ chatId })

  if (preview) {
    previewCache.set(chatId, preview)
  } else {
    previewCache.delete(chatId)
  }

  return preview
}

function shouldRefreshPreview(response: Response) {
  return response.headers.get('x-v0-preview-refresh') === '1'
}

async function proxyPreviewRequest(
  request: Request,
  chatId: string,
  path: string[],
) {
  const preview = await getPreview(chatId)

  if (!preview) {
    return previewLoadingResponse()
  }

  const incomingUrl = new URL(request.url)
  const baseHeaders = new Headers(request.headers)
  baseHeaders.delete('host')

  const hasBody = request.method !== 'GET' && request.method !== 'HEAD'
  const body = hasBody ? await request.arrayBuffer() : undefined

  async function fetchPreview(currentPreview: Preview) {
    const upstreamUrl = new URL(`/${path.join('/')}`, currentPreview.url)
    upstreamUrl.search = incomingUrl.search

    const headers = new Headers(baseHeaders)
    headers.set('x-v0-preview-token', currentPreview.token)

    return fetch(upstreamUrl, {
      method: request.method,
      headers,
      body,
      redirect: 'manual',
    })
  }

  let response = await fetchPreview(preview)

  if (shouldRefreshPreview(response)) {
    previewCache.delete(chatId)
    const refreshedPreview = await getPreview(chatId, { refresh: true })

    if (!refreshedPreview) {
      return previewLoadingResponse()
    }

    response = await fetchPreview(refreshedPreview)
  }

  return new Response(response.body, {
    status: response.status,
    headers: response.headers,
  })
}

Your proxy route must handle every path under the iframe URL, including assets, navigations, and in-app requests.

Refresh behavior

Preview details are temporary:

  • token is valid until expiresAt
  • url can become stale if the underlying sandbox restarts

Use expiresAt to refresh before the token expires. Separately, if the preview response includes x-v0-preview-refresh: 1, treat it as a proxy signal. Do not render that response in the iframe. Clear the cached preview, call chats.getPreview, and retry the original request once with the new url and token.

If chats.getPreview returns preview: null, the sandbox is not ready yet. Return your own loading page and retry with backoff or a timed refresh.

Do not send your v0 API key to the preview URL or expose it to the browser. The API key is only for calling the Platform API; preview access uses the short-lived x-v0-preview-token header.

On this page