Every Flutter link preview package on pub.dev does the same thing: fetch the page HTML, parse the og:image tag, display whatever it finds. When the target URL is a single-page app with no OG tags, you get a blank card. When it's a JavaScript-heavy dashboard, you get nothing at all. A server-side flutter screenshot API skips all of that by rendering the actual page in a headless browser and returning the image. Converting a Flutter URL to image takes one HTTP call. This guide walks through the ScreenshotRun Flutter integration, from a basic capture to saving screenshots on disk, building a reusable preview widget, converting URLs to PDF.
Capture a website screenshot from Dart
The shortest path is a single HTTP GET. The API returns binary image data, which Dart handles as Uint8List (the same type that Image.memory() expects).
curl "https://api.screenshotrun.com/v1/screenshots/capture?\
url=https://flutter.dev&\
format=png&\
width=1280&\
full_page=false" \
-H "Authorization: Bearer YOUR_API_KEY" \
--output flutter-dev.png
In Dart, the equivalent using the http package. This is all you need for a basic dart screenshot API call:
import 'dart:typed_data';
import 'package:http/http.dart' as http;
Future<Uint8List> captureScreenshot(String targetUrl) async {
final uri = Uri.https('api.screenshotrun.com', '/v1/screenshots/capture', {
'url': targetUrl,
'format': 'png',
'width': '1280',
});
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer YOUR_API_KEY'},
).timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
throw Exception('Capture failed: ${response.statusCode}');
}
return response.bodyBytes;
}
response.bodyBytes gives you the raw PNG as a Uint8List. No Base64 decoding, no file conversion, just the bytes. Display it immediately with Image.memory(response.bodyBytes) in any widget tree.
Why not use WebView or RepaintBoundary?
Flutter developers usually try two local approaches before looking at an API. Both have real limitations that show up fast in production.
InAppWebView.takeScreenshot() loads the URL in an embedded WebView and grabs the visible viewport. Sounds straightforward enough. But the screenshot only captures what's on screen (no full-page option), returns black images when the page contains video or WebGL canvas, behaves differently on iOS versus Android, and on Flutter Web most controller methods simply don't exist. An open GitHub issue thread going back years documents blank captures on iOS specifically.
RepaintBoundary.toImage() works only on Flutter's own widget tree. It can't render external URLs; it converts your Dart widgets to pixels. On Flutter Web with the HTML renderer, it fails entirely. Developers end up adding a JavaScript html2canvas call as a workaround, which defeats the purpose of writing Dart in the first place.
A REST API call sidesteps both problems. You get identical results on all six Flutter platforms (Android, iOS, Web, macOS, Windows, Linux) because the rendering happens server-side in a full Chromium instance. The Dart code is the same everywhere: one HTTP call, one Uint8List back.
Display and save the screenshot
Once you have the bytes, showing them takes one line:
Image.memory(screenshotBytes, fit: BoxFit.contain)
Saving to the device filesystem needs path_provider to find the right directory. iOS sandboxes app storage differently from Android, but getApplicationDocumentsDirectory() abstracts that away:
import 'dart:io';
import 'dart:typed_data';
import 'package:path_provider/path_provider.dart';
Future<File> saveScreenshot(Uint8List bytes, String filename) async {
final dir = await getApplicationDocumentsDirectory();
final file = File('${dir.path}/$filename');
return file.writeAsBytes(bytes);
}
If the user should be able to find the image in their photo gallery (not buried in app storage), add the gal package and call Gal.putImageBytes(bytes) after saving. On iOS this triggers the Photos Library permission prompt automatically.
Build a Flutter link preview widget
Chat apps, bookmarking tools, news aggregators, content curation feeds: any Flutter app that displays URLs can benefit from visual previews. OG-tag-based packages frequently fail on SPAs, JavaScript-rendered dashboards, pages behind auth walls, pages that just don't have meta tags at all. A flutter link preview API that renders the actual page fills that gap.
class LinkPreview extends StatelessWidget {
final String url;
const LinkPreview({required this.url, super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List>(
future: captureScreenshot(url),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
if (snapshot.hasError) {
return const Icon(Icons.broken_image, size: 48);
}
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.memory(snapshot.data!, fit: BoxFit.cover, height: 200),
);
},
);
}
}
This widget calls the API once, then displays the result. For a production app you'd want to cache the bytes: store them locally with path_provider, or use the API's cache_ttl parameter to let the server return cached results on repeat requests. Does your preview need to be pixel-perfect? Probably not. The resize_width=400 parameter cuts bandwidth for thumbnail-sized previews, and smaller images load noticeably faster over cellular connections.
One thing to watch: FutureBuilder calls the future on every rebuild if you put the API call directly in build(). In production, store the future in a StatefulWidget's initState or use a state management solution like Riverpod to avoid duplicate requests.
Using Dio instead of the http package
If your project already uses Dio (most production Flutter apps do, for the interceptors and retry logic), the integration looks slightly different. You need to set responseType: ResponseType.bytes explicitly. Skip that, and Dio tries to decode the binary response as a string, which corrupts the image.
import 'package:dio/dio.dart';
final dio = Dio(BaseOptions(
baseUrl: 'https://api.screenshotrun.com',
headers: {'Authorization': 'Bearer YOUR_API_KEY'},
));
Future<Uint8List> captureWithDio(String targetUrl) async {
final response = await dio.get(
'/v1/screenshots/capture',
queryParameters: {
'url': targetUrl,
'format': 'webp',
'width': '1280',
'block_cookies': 'true',
},
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data);
}
Same Uint8List result, same Image.memory() display. Dio gives you automatic retries with dio_smart_retry, which is handy when capturing pages that occasionally time out due to heavy client-side hydration.
Convert a URL to PDF from Flutter
Business apps sometimes need URL-to-PDF conversion for compliance reports, receipt archiving, invoice snapshots, or dashboard exports. Packages like webcontent_converter use three different rendering engines under the hood (WebView on Android, WKWebView on iOS, Chromium on desktop), produce inconsistent output across platforms. Flutter Web has no support whatsoever.
The API uses Chrome's print pipeline on the server, so the result is identical regardless of where your Flutter app runs:
Future<Uint8List> capturePdf(String targetUrl) async {
final uri = Uri.https('api.screenshotrun.com', '/v1/screenshots/capture', {
'url': targetUrl,
'format': 'pdf',
'pdf_page_format': 'A4',
'pdf_margin_top': '20',
'pdf_margin_bottom': '20',
});
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer YOUR_API_KEY'},
).timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
throw Exception('PDF capture failed: ${response.statusCode}');
}
return response.bodyBytes;
}
// Usage:
final pdfBytes = await capturePdf('https://example.com/invoice/1234');
final file = await saveScreenshot(pdfBytes, 'invoice-1234.pdf');
Pass format=pdf along with page format and margin parameters. The response is raw PDF bytes, save them the same way you'd save a screenshot, then open with a PDF viewer package or share via the system share sheet. How does the output compare to what you see in the browser? PDF rendering uses Chrome's @media print pipeline, so pages styled with print-specific CSS will look different from their on-screen version.
Flutter Web, CORS, and API key security
On Flutter Web, HTTP requests from the browser are subject to CORS restrictions. The ScreenshotRun API includes the necessary CORS headers, so direct calls from dart:html or the http package work without a proxy. But if you're hitting a self-hosted endpoint that doesn't return Access-Control-Allow-Origin, you'll get a silent failure: the request fires, the browser blocks the response, Dart sees a generic XMLHttpRequest error. Worth checking if your captures work on mobile but break on web.
There's a second concern specific to Flutter Web. Your API key sits in the JavaScript bundle, visible in the browser's network tab to anyone who opens DevTools. For mobile builds, use String.fromEnvironment('API_KEY') with --dart-define to keep the key out of source code. For web builds in production, route API calls through your own backend so the key never reaches the client.
Flutter screenshot API parameters
These are the parameters you'll use most often in a Flutter integration. The full parameter reference covers everything; below is what matters for mobile and cross-platform apps:
| Parameter | What it does | Flutter context |
|---|---|---|
format | Output: png, jpeg, webp, avif, tiff, pdf | WebP is smaller, better for mobile bandwidth |
width | Viewport width in pixels | Match your app's layout width or use 1280 for desktop-like captures |
full_page | Scroll and stitch the entire page | Combine with delay for lazy-loaded content |
resize_width | Shrink the output image | 400px for thumbnails saves bandwidth on cellular |
dark_mode | Capture in dark mode | Match your app's ThemeMode for visual consistency |
device | Emulate desktop, mobile, tablet, or custom viewport | Use mobile when your app targets phones |
block_cookies | Remove GDPR consent banners | Cleaner thumbnails without overlay clutter |
delay | Wait N ms after page load | 2000ms is a good default for SPAs and lazy-loaded images |
cache_ttl | Cache the result server-side (seconds) | 86400 (24h) for link previews that don't need real-time freshness |
stealth | Bypass bot detection | Some sites block headless browsers; stealth makes Chrome look like a regular visitor |
response_type | Return Base64 string instead of binary | Useful when you want to embed the image in JSON payloads |
Platform behavior differences
Because the screenshot happens server-side, the image comes back identical on every platform. But the code around it has a few platform-specific edges worth knowing about.
On Android, getApplicationDocumentsDirectory() points to internal app storage. Users can't browse to it from the Files app unless you write to getExternalStorageDirectory() instead. On iOS, there's no external storage concept. Everything lives in the app sandbox, and the only way to make screenshots visible in Photos is through Gal.putImageBytes().
Flutter Web doesn't have filesystem access at all. To let users download a screenshot, convert the bytes to a Blob and trigger a browser download with AnchorElement from dart:html:
import 'dart:html' as html;
import 'dart:typed_data';
void downloadScreenshot(Uint8List bytes, String filename) {
final blob = html.Blob([bytes], 'image/png');
final url = html.Url.createObjectUrlFromBlob(blob);
html.AnchorElement(href: url)
..setAttribute('download', filename)
..click();
html.Url.revokeObjectUrl(url);
}
Desktop platforms (macOS, Windows, Linux) have normal filesystem access, so saving works the same as on Android.
What about image format support? Image.memory() handles PNG, JPEG, WebP on all platforms out of the box. AVIF works on iOS 16+ and Android 12+, but older devices and Flutter Web with the HTML renderer may not decode it. When in doubt, stick with WebP for the best balance of file size and compatibility across your user base.
Get started with the flutter screenshot API
Add the http package to your pubspec.yaml, grab an API key from the dashboard, run the Dart snippet from the first section. The free tier includes 200 captures per month, enough to build and test your flutter capture website screenshot integration before scaling up. Every parameter on this page works on the free tier. No restrictions.
If you run into edge cases (blank captures on certain URLs, timeouts on heavy SPAs), the wait_for_selector parameter lets you target a specific DOM element instead of relying on a fixed delay. For converting entire pages to images for archiving, check the serverless screenshots guide. For bulk captures (generating thumbnails for a bookmark directory, say), the API handles concurrency on its end, so you can fire multiple Futures in parallel without worrying about browser instance management. See all integration guides for other languages.
FAQ#
Yes. The screenshot is rendered server-side in a headless Chromium instance, so the API returns identical results on Android, iOS, Flutter Web, macOS, Windows, and Linux. Your Dart code is the same everywhere: one HTTP call, one Uint8List response.
InAppWebView captures only the visible viewport and fails on pages with video, WebGL canvas, or CanvasKit content. It also behaves differently on iOS versus Android. A server-side screenshot API avoids these issues entirely because it runs a full browser on the server, not an embedded WebView on the device.
On Flutter Web, your API key is visible in the browser JavaScript bundle. Route API calls through your own backend server so the key stays server-side. For mobile builds, use String.fromEnvironment with the --dart-define flag to inject the key at compile time instead of hardcoding it in source.
Yes. Pass format=pdf to the API along with pdf_page_format and pdf_margin parameters. The server renders the PDF using Chrome print pipeline and returns raw PDF bytes. The result is identical on every Flutter platform, unlike webcontent_converter which uses three different rendering engines.
Two approaches work well together. Server-side: add cache_ttl=86400 to your request and the API returns cached results for 24 hours. Client-side: save the Uint8List bytes to local storage with path_provider and check for a cached file before making a new API call.
Image.memory decodes PNG, JPEG, and WebP on all platforms out of the box. AVIF works on iOS 16 and later plus Android 12 and later, but older devices and Flutter Web with the HTML renderer may not support it. WebP gives the best balance of file size and cross-platform compatibility.