Screenshot API for Mobile Apps: React Native and Flutter Integration Guide
Mobile apps frequently need to capture web content: a link preview before sharing, a thumbnail of a user's website, a screenshot of a web-based report, a receipt from a web checkout. You can't run a headless browser on a phone — so a screenshot API is the right tool.
This guide covers both React Native (JavaScript/TypeScript) and Flutter (Dart) integration patterns, focusing on the use cases mobile developers actually encounter.
Common Mobile Use Cases
- Link preview: User pastes a URL → show a thumbnail before sharing
- Website thumbnails: Display previews of websites in a list or card UI
- Share cards: Capture a styled web template as an image to share natively
- Report screenshots: Capture a web-based dashboard and save to camera roll
- Content moderation: Screenshot submitted URLs for human review queue
React Native
Basic fetch wrapper
React Native's fetch API works for image downloads, but you need to handle binary data correctly. The simplest pattern stores the image in the app's cache directory using expo-file-system (Expo) or react-native-fs (bare React Native).
import * as FileSystem from "expo-file-system";
const SCREENSHOT_API_KEY = "your_api_key_here";
const BASE_URL = "https://hermesforge.dev/api/screenshot";
interface ScreenshotOptions {
url: string;
width?: number;
height?: number;
delay?: number;
format?: "png" | "webp" | "jpeg";
fullPage?: boolean;
}
export async function captureScreenshot(options: ScreenshotOptions): Promise<string> {
const params = new URLSearchParams({
url: options.url,
width: String(options.width ?? 800),
height: String(options.height ?? 600),
format: options.format ?? "webp",
delay: String(options.delay ?? 0),
full_page: String(options.fullPage ?? false),
});
const apiUrl = `${BASE_URL}?${params.toString()}`;
// Build a cache filename from URL hash
const urlHash = btoa(options.url).replace(/[^a-zA-Z0-9]/g, "").slice(0, 16);
const ext = options.format ?? "webp";
const localPath = `${FileSystem.cacheDirectory}screenshot_${urlHash}.${ext}`;
// Check cache first
const existing = await FileSystem.getInfoAsync(localPath);
if (existing.exists) {
return localPath;
}
const downloadResult = await FileSystem.downloadAsync(apiUrl, localPath, {
headers: { "X-API-Key": SCREENSHOT_API_KEY },
});
if (downloadResult.status !== 200) {
throw new Error(`Screenshot API returned ${downloadResult.status}`);
}
return downloadResult.uri;
}
Link preview card component
import React, { useState, useEffect } from "react";
import { View, Image, Text, ActivityIndicator, StyleSheet, TouchableOpacity } from "react-native";
import * as Linking from "expo-linking";
import { captureScreenshot } from "./screenshotApi";
interface LinkPreviewProps {
url: string;
onPress?: () => void;
}
export function LinkPreview({ url, onPress }: LinkPreviewProps) {
const [imageUri, setImageUri] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
const uri = await captureScreenshot({
url,
width: 600,
height: 400,
format: "webp",
delay: 1000,
});
if (!cancelled) {
setImageUri(uri);
}
} catch (e) {
if (!cancelled) {
setError("Preview unavailable");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
load();
return () => { cancelled = true; };
}, [url]);
const domain = (() => {
try {
return new URL(url).hostname;
} catch {
return url;
}
})();
return (
<TouchableOpacity style={styles.card} onPress={onPress ?? (() => Linking.openURL(url))}>
{loading && (
<View style={styles.placeholder}>
<ActivityIndicator size="small" color="#6366f1" />
</View>
)}
{!loading && imageUri && (
<Image source={{ uri: imageUri }} style={styles.image} resizeMode="cover" />
)}
{!loading && error && (
<View style={styles.placeholder}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}
<View style={styles.footer}>
<Text style={styles.domain} numberOfLines={1}>{domain}</Text>
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
borderRadius: 12,
overflow: "hidden",
backgroundColor: "#f8fafc",
borderWidth: 1,
borderColor: "#e2e8f0",
},
image: {
width: "100%",
aspectRatio: 3 / 2,
},
placeholder: {
width: "100%",
aspectRatio: 3 / 2,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f1f5f9",
},
footer: {
paddingHorizontal: 12,
paddingVertical: 8,
},
domain: {
fontSize: 12,
color: "#64748b",
},
errorText: {
fontSize: 12,
color: "#94a3b8",
},
});
Save screenshot to camera roll
import * as MediaLibrary from "expo-media-library";
import * as FileSystem from "expo-file-system";
export async function saveScreenshotToCameraRoll(url: string): Promise<void> {
// Request permission
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== "granted") {
throw new Error("Camera roll permission denied");
}
const localPath = await captureScreenshot({
url,
width: 1280,
height: 800,
format: "png",
delay: 1500,
fullPage: false,
});
await MediaLibrary.saveToLibraryAsync(localPath);
}
Share screenshot natively
import * as Sharing from "expo-sharing";
export async function shareWebsiteScreenshot(url: string): Promise<void> {
const localPath = await captureScreenshot({
url,
width: 1200,
height: 630,
format: "png",
delay: 1000,
});
const isAvailable = await Sharing.isAvailableAsync();
if (!isAvailable) {
throw new Error("Sharing not available on this device");
}
await Sharing.shareAsync(localPath, {
mimeType: "image/png",
dialogTitle: "Share screenshot",
});
}
Flutter (Dart)
HTTP client wrapper
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:crypto/crypto.dart';
import 'dart:convert';
const _apiKey = 'your_api_key_here';
const _baseUrl = 'https://hermesforge.dev/api/screenshot';
class ScreenshotOptions {
final String url;
final int width;
final int height;
final int delay;
final String format;
final bool fullPage;
const ScreenshotOptions({
required this.url,
this.width = 800,
this.height = 600,
this.delay = 0,
this.format = 'webp',
this.fullPage = false,
});
}
class ScreenshotApiClient {
final http.Client _client;
ScreenshotApiClient({http.Client? client}) : _client = client ?? http.Client();
Future<File> capture(ScreenshotOptions options) async {
final cacheDir = await getTemporaryDirectory();
final hash = md5.convert(utf8.encode(options.url)).toString().substring(0, 12);
final cacheFile = File('${cacheDir.path}/screenshot_$hash.${options.format}');
if (await cacheFile.exists()) {
return cacheFile;
}
final uri = Uri.parse(_baseUrl).replace(queryParameters: {
'url': options.url,
'width': options.width.toString(),
'height': options.height.toString(),
'delay': options.delay.toString(),
'format': options.format,
'full_page': options.fullPage.toString(),
});
final response = await _client.get(uri, headers: {'X-API-Key': _apiKey});
if (response.statusCode != 200) {
throw HttpException(
'Screenshot API error: ${response.statusCode}',
uri: uri,
);
}
await cacheFile.writeAsBytes(response.bodyBytes);
return cacheFile;
}
}
Link preview widget
import 'package:flutter/material.dart';
import 'screenshot_api_client.dart';
import 'dart:io';
class LinkPreviewCard extends StatefulWidget {
final String url;
final VoidCallback? onTap;
const LinkPreviewCard({super.key, required this.url, this.onTap});
@override
State<LinkPreviewCard> createState() => _LinkPreviewCardState();
}
class _LinkPreviewCardState extends State<LinkPreviewCard> {
final _client = ScreenshotApiClient();
File? _imageFile;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadPreview();
}
Future<void> _loadPreview() async {
try {
final file = await _client.capture(ScreenshotOptions(
url: widget.url,
width: 600,
height: 400,
delay: 1000,
format: 'webp',
));
if (mounted) setState(() { _imageFile = file; _loading = false; });
} catch (e) {
if (mounted) setState(() { _error = 'Preview unavailable'; _loading = false; });
}
}
String get _domain {
try {
return Uri.parse(widget.url).host;
} catch (_) {
return widget.url;
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: widget.onTap,
child: Card(
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: 3 / 2,
child: _buildPreview(),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(
_domain,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey.shade600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget _buildPreview() {
if (_loading) {
return const ColoredBox(
color: Color(0xFFF1F5F9),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (_imageFile != null) {
return Image.file(_imageFile!, fit: BoxFit.cover);
}
return ColoredBox(
color: const Color(0xFFF1F5F9),
child: Center(
child: Text(
_error ?? 'No preview',
style: const TextStyle(color: Colors.grey),
),
),
);
}
}
Save to gallery (Flutter)
import 'package:gal/gal.dart'; // or image_gallery_saver
Future<void> saveToGallery(String url) async {
final client = ScreenshotApiClient();
final file = await client.capture(ScreenshotOptions(
url: url,
width: 1280,
height: 800,
format: 'png',
delay: 1500,
));
await Gal.putImage(file.path);
}
Backend Pattern: Mobile Backend Captures Screenshots
Rather than calling the screenshot API directly from the device, you can have your backend generate screenshots and return URLs to the mobile client. This keeps your API key off the device, adds server-side caching, and lets you pre-generate thumbnails when content is created.
# FastAPI backend endpoint for mobile clients
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, HttpUrl
import httpx
import hashlib
import os
from pathlib import Path
app = FastAPI()
SCREENSHOT_API_KEY = os.environ["SCREENSHOT_API_KEY"]
STATIC_DIR = Path("static/screenshots")
STATIC_DIR.mkdir(parents=True, exist_ok=True)
class ThumbnailRequest(BaseModel):
url: HttpUrl
@app.post("/api/thumbnail")
async def generate_thumbnail(req: ThumbnailRequest):
url_str = str(req.url)
cache_key = hashlib.sha256(url_str.encode()).hexdigest()[:16]
cached_path = STATIC_DIR / f"{cache_key}.webp"
if not cached_path.exists():
async with httpx.AsyncClient() as client:
response = await client.get(
"https://hermesforge.dev/api/screenshot",
params={"url": url_str, "width": 600, "height": 400, "format": "webp", "delay": 1000},
headers={"X-API-Key": SCREENSHOT_API_KEY},
timeout=30,
)
if response.status_code != 200:
raise HTTPException(status_code=502, detail="Screenshot generation failed")
cached_path.write_bytes(response.content)
# Return a URL the mobile client can load directly
return {"thumbnail_url": f"/static/screenshots/{cache_key}.webp"}
The mobile client then just loads thumbnail_url as a regular <Image> — no API key needed on device, no binary download logic required.
Cache Management
On-device caches should be periodically cleaned to avoid filling storage:
// React Native: clear screenshot cache older than 7 days
import * as FileSystem from "expo-file-system";
export async function cleanScreenshotCache(maxAgeDays = 7): Promise<void> {
const cacheDir = FileSystem.cacheDirectory;
if (!cacheDir) return;
const files = await FileSystem.readDirectoryAsync(cacheDir);
const screenshotFiles = files.filter(f => f.startsWith("screenshot_"));
const cutoff = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
for (const filename of screenshotFiles) {
const path = `${cacheDir}${filename}`;
const info = await FileSystem.getInfoAsync(path, { md5: false });
if (info.exists && info.modificationTime * 1000 < cutoff) {
await FileSystem.deleteAsync(path, { idempotent: true });
}
}
}
Key Takeaways
- API key security: Never embed API keys in mobile app bundles — ship them server-side or use a backend proxy pattern
- Caching is essential: Mobile connections are slow and metered; cache screenshots by URL hash to avoid repeated API calls
- Format matters:
webpfor thumbnails (smallest file size on Android/modern iOS),pngfor saving to camera roll (universal compatibility) - Delay for JS content: Use
delay: 1000or higher for pages with lazy-loaded images or JavaScript-rendered content - Backend proxy: For production apps, route through your backend — add auth, rate limiting, and centralized caching in one place
- Aspect ratio:
600×400(3:2) works well for link previews;1200×630(1.91:1) for Open Graph / share cards