Add Export to PNG and PDF to Your SaaS

Ship a Download as PNG or PDF button in your app: render the logged-in view server side with a short-lived token, capture one element, and stream the file.

By Simon Chiu · Updated

"Can I download this as an image?" is one of the most common feature requests for dashboards, reports and editors. Users want to paste a chart into a slide deck, attach a report to a ticket, or keep a record of what a page showed. The feature looks small. Building it means running Chrome against a logged-in page of your own app, and that is where most implementations go wrong.

This page shows the server-side design we recommend: your backend asks RenderScreenshot to render a dedicated export view, passes a short-lived token, captures one element, and streams the file back. It is part of our use cases.

Browser-side or server-side export?

You can export in the user's browser with a DOM-to-canvas library, or on a server with a real browser. The choice decides what can go wrong.

In the browser (html-to-image, html2canvas) On the server (headless Chrome or an API)
CSS fidelity Partial; in our 19 CSS probes, the best library missed one feature and html2canvas missed seven Same as Chrome
Cross-origin images and fonts Break the canvas without CORS headers Load normally
PDF with selectable text No, an image in a PDF at best Yes
Works from an email, a schedule or an API No, needs an open tab Yes
Cost Free, uses the user's device One render per export

If your page uses modern CSS, third-party images or charts, render on the server. If it is a simple card with your own assets, a browser library can be enough.

How the server-side export works

sequenceDiagram
  participant U as User's browser
  participant A as Your app server
  participant R as RenderScreenshot
  U->>A: GET /dashboards/42/export.png (session cookie)
  A->>A: Check access, mint 2-minute export token
  A->>R: POST /v1/screenshot (url of export view, token cookie)
  R->>A: GET /dashboards/42/export_view (token cookie)
  A-->>R: HTML of the export view
  R-->>A: PNG bytes (+ X-Cache-URL)
  A-->>U: Content-Disposition: attachment

Four design rules come from this flow:

  1. Render a dedicated export view, not the page the user sees. Drop navigation, tooltips and "loading" states, fix the width, and set a marker such as data-export-ready on the root element once data and charts have drawn.
  2. Never forward the user's session. Mint a token that can read only this export view, for a minute or two. Send it as a cookie or header. RenderScreenshot sends custom headers only to the target origin, never to third-party scripts or images on the page.
  3. Only export your own URLs. Build the target URL on the server from an ID, never from a URL the client sends. Otherwise your export endpoint becomes a proxy that renders any page on the internet on your account.
  4. Check access before you render. The token proves the renderer may read the view; your controller must first prove the user may.

Minimal request

This is what your server sends for a PNG of one dashboard panel at twice the pixel density:

curl -X POST https://api.renderscreenshot.com/v1/screenshot \
  -H "Authorization: Bearer $RS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/dashboards/42/export_view",
    "viewport": { "width": 1440, "height": 900, "scale": 2 },
    "network": {
      "cookies": [
        { "name": "export_token", "value": "SHORT_LIVED_TOKEN", "domain": "app.example.com",
          "secure": true, "http_only": true }
      ]
    },
    "wait": { "for_selector": "[data-export-ready]", "timeout": 30 },
    "capture": { "mode": "element", "selector": "#dashboard", "selector_required": true },
    "output": { "format": "png" }
  }' \
  -o dashboard.png

selector_required: true makes the request fail instead of returning a full-viewport image when the export view did not render its root element. The authenticated pages guide covers headers and basic auth, and the element screenshots guide covers cropping.

A Rails controller

The same request with the Ruby SDK, streamed back as a download. ExportToken stands in for your own token code:

# app/controllers/dashboard_exports_controller.rb
class DashboardExportsController < ApplicationController
  RS = RenderScreenshot.client(ENV.fetch('RS_API_KEY'))

  def show
    dashboard = Current.account.dashboards.find(params[:dashboard_id]) # access check first
    token = ExportToken.issue(dashboard: dashboard, user: Current.user, expires_in: 2.minutes)

    options = RenderScreenshot::TakeOptions
      .url(export_view_dashboard_url(dashboard)) # built on the server, never from params
      .width(1440)
      .height(900)
      .wait_for_selector('[data-export-ready]')
      .cookies([{ name: 'export_token', value: token, domain: request.host }])

    options = if params[:format] == 'pdf'
      options.format('pdf').pdf_paper_size('a4').pdf_print_background
    else
      options.scale(2).element('#dashboard').format('png')
    end

    send_data RS.take(options),
              filename: "#{dashboard.name.parameterize}.#{params[:format] == 'pdf' ? 'pdf' : 'png'}",
              type: params[:format] == 'pdf' ? 'application/pdf' : 'image/png',
              disposition: 'attachment'
  rescue RenderScreenshot::Error => e
    Rails.logger.warn("dashboard export failed: #{e.message}")
    redirect_to dashboard_path(dashboard), alert: 'Export failed. Please try again.'
  end
end

In the export view, verify the export_token cookie, render the dashboard without layout chrome, and add data-export-ready to #dashboard once the data has loaded. For charts, turn animation off first; the chart to image tutorial shows why an animated chart exports half drawn.

Settings for exports, and why

Setting Value Reason
viewport.width Fixed, such as 1440 Every export has the same layout, whatever the user's window
viewport.scale 2 for PNG Sharp text when pasted into slides and retina screens
wait.for_selector Your ready marker Data and charts are drawn; a fixed delay is either slow or wrong
capture.mode element for panels Crops to the content without manual clip maths
output.format png, or pdf for reports PNG for pasting, PDF for printing and selectable text
browser.timezone, browser.locale The user's Dates and numbers match what the user saw
Cache Leave on default Exports are on demand; cache.refresh is not needed unless you reuse URLs

Each response also includes X-Cache-URL, a public CDN link to the same file. Do not hand that link to users for private dashboards: anyone with it can open the image until the cache expires. Stream the bytes, as the controller does.

Shareable exports with signed URLs

For content that is public anyway, such as a public status page or a shared read-only report, you can skip the controller. A signed URL lets an <img> tag or a link call the API directly. Signed URLs work only for GET (and HEAD) requests, carry no cookies or headers, and expire after at most 30 days. That makes them a good fit for public pages and a bad fit for anything behind login.

What it costs

Every successful export is one credit; failures, including a timeout on your ready selector, cost nothing. A product with 2,000 exports a month fits the Starter plan: $10 a month for 2,500 credits, which is $0.0050 per export at that volume. The Free plan's 150 monthly credits are enough to build and test the feature. The rate limit is per account and shared by all keys, so a burst of exports from many users counts against one limit; see rate limits before you add a "download all" button.

Proof

We have no customer metrics for this use case to show yet, so this page shows a design and code instead of a testimonial. The request above uses only documented parameters, and the render HTML to PNG test results explain the CSS fidelity column in the first table.

To build the feature, create a free account, point the minimal request at your own export view, and replace SHORT_LIVED_TOKEN with a token from your app.

Frequently asked questions

Should I forward the user's session cookie to the renderer?
No. Mint a short-lived, read-only token that can open only the export view, and send that as a cookie or header. If it leaks from a log, it opens one page for a few minutes instead of the user's whole account.
Can I put the export link in an email or a public page?
Only for content that is safe to show without login. A signed URL authenticates GET requests to the screenshot API, carries no cookies or headers, and expires after at most 30 days. For private views, call the API from your server.
PNG or PDF for exports?
PNG for a single panel or chart that people paste into slides and chats. PDF for multi-page reports that people print or attach, because text stays selectable and pages break cleanly.
Does a failed export cost a credit?
No. Only successful renders use a credit. A render that times out waiting for your ready selector costs nothing, so a strict ready check is free.

Related in this topic

Try RenderScreenshot free

150 free credits every month, no credit card required.