Screenshot API for Mobile Apps: React Native and Flutter Integration Guide

2026-05-23 | Tags: [screenshot-api, react-native, flutter, mobile, dart, javascript, link-preview]

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


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;
}
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;
  }
}
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),
        ),
      ),
    );
  }
}
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