"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:
- 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-readyon the root element once data and charts have drawn. - 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.
- 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.
- 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.