ScreenshotNeo

BlogHow-to

How to Convert HTML to PDF in React Native

Generate PDFs from HTML in React Native with Expo Print or react-native-html-to-pdf, then save, share, and troubleshoot iOS and Android output.

By the ScreenshotNeo team1 October 20268 min read

Direct answer: In an Expo project, use expo-print and call Print.printToFileAsync({ html }). It renders your HTML and returns a PDF URI in the app cache. In a bare React Native app, use react-native-html-to-pdf and call generatePDF. Copy the resulting file to persistent storage before relying on it, then pass its URI to your sharing flow.

This guide covers both approaches, complete JavaScript examples, page layout, images and fonts, saving and sharing on iOS and Android, failure diagnosis, and an API alternative when the HTML already lives at a public URL.

1. Choose an implementation path

Situation Use Trade-offs
Expo managed workflow expo-print Official Expo module; minimal native setup; output is initially cached.
Bare React Native with native configuration react-native-html-to-pdf More control over dimensions, padding, directories and fonts; verify package compatibility with your React Native version.
HTML hosted at a URL ScreenshotNeo PDF capture No browser setup in the app; the service renders the URL and returns a PDF.

2. Expo: generate a PDF with expo-print

Install the module with your Expo project’s package manager:

npx expo install expo-print expo-file-system expo-sharing

Then create a complete HTML document and pass it to Print.printToFileAsync. A complete document with a viewport declaration gives WebView rendering more predictable results.

import * as Print from 'expo-print';

const invoiceHtml = `<!DOCTYPE html>
<html>
  <head>
    <meta name="viewport" content="width=device-width" />
    <style>
      @page { size: A4; margin: 18mm 14mm; }
      * { box-sizing: border-box; }
      body { font-family: Arial, sans-serif; color: #202124; margin: 0; }
      h1 { font-size: 26px; margin: 0 0 12px; }
      p { font-size: 14px; line-height: 1.5; }
      .total { margin-top: 28px; font-size: 18px; font-weight: 700; }
    </style>
  </head>
  <body>
    <h1>Invoice</h1>
    <p>Generated from HTML in React Native.</p>
    <p class="total">Total: $120.00</p>
  </body>
</html>`;

export async function createInvoicePdf() {
  const { uri } = await Print.printToFileAsync({ html: invoiceHtml });
  console.log('Temporary PDF:', uri);
  return uri;
}

See the Expo Print documentation and ScreenshotNeo documentation when choosing between local rendering and URL-based capture.

Open the native print interface instead

If the user should choose a printer or system destination rather than receive a file directly, call Print.printAsync:

import * as Print from 'expo-print';

await Print.printAsync({
  html: '<!DOCTYPE html><html><body><h1>Invoice</h1></body></html>',
});

Copy the PDF out of the cache and share it

printToFileAsync returns a cache URI. Cache files are temporary, so copy the file to the app’s document location when it must survive cache cleanup. Expo’s FileSystem documentation covers document storage and file operations, while Sharing provides the platform share sheet.

import * as Print from 'expo-print';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';

export async function createAndShareInvoice() {
  const html = `<!DOCTYPE html>
    <html><head>
      <meta name="viewport" content="width=device-width" />
      <style>@page { size: A4; margin: 16mm; }</style>
    </head><body>
      <h1>Invoice</h1><p>Generated from HTML.</p>
    </body></html>`;

  const { uri: cacheUri } = await Print.printToFileAsync({ html });
  const persistentUri = `${FileSystem.documentDirectory}invoice-${Date.now()}.pdf`;

  await FileSystem.copyAsync({ from: cacheUri, to: persistentUri });

  if (await Sharing.isAvailableAsync()) {
    await Sharing.shareAsync(persistentUri, {
      mimeType: 'application/pdf',
      dialogTitle: 'Share invoice',
    });
  }

  return persistentUri;
}

3. Bare React Native: use react-native-html-to-pdf

Install the package and follow its native setup instructions:

npm install react-native-html-to-pdf

The package README documents fileName, base64, directory, height and width. iOS also supports padding and background-color controls; Android supports custom font paths. The npm registry lists version 1.3.0, but check compatibility with your app’s React Native version before adopting it.

import { generatePDF } from 'react-native-html-to-pdf';

export async function createPdf() {
  const html = `<!DOCTYPE html>
    <html>
      <head>
        <meta name="viewport" content="width=device-width" />
        <style>
          @page { size: A4; margin: 16mm; }
          body { font-family: Arial, sans-serif; }
        </style>
      </head>
      <body>
        <h1>Invoice</h1>
        <p>Generated from HTML.</p>
      </body>
    </html>`;

  const result = await generatePDF({
    html,
    fileName: 'invoice',
    directory: 'Documents',
    base64: false,
    width: 595,
    height: 842,
  });

  const filePath = result.filePath ?? result.uri;
  console.log(filePath);
  return filePath;
}

On iOS, Documents is the accepted custom directory described by the package documentation. Keep dimensions, padding, background color and font paths explicit when layout consistency matters.

4. Build HTML that prints consistently

Use a complete document

Include <!DOCTYPE html>, <head> and <body>. Put print rules in a style block so the renderer receives the same structure on both platforms.

Control paper size and margins

@page {
  size: A4;
  margin: 18mm 14mm;
}

@media print {
  .screen-only { display: none; }
}

Android margins can vary with the WebView engine. Define them with CSS @page instead of relying on defaults.

Avoid content splitting in the wrong place

.invoice-row {
  break-inside: avoid;
  page-break-inside: avoid;
}

.page-break {
  break-before: page;
  page-break-before: always;
}

Long tables and very large images can still paginate differently across WebView versions. Keep row content compact and test representative data.

Inline images on iOS

On iOS, the HTML source cannot reliably reference local asset URLs through WKWebView. Convert required local images to base64 and embed them:

<img src="data:image/png;base64,AAABAA..." alt="Company mark" />

Remote images also depend on network availability at render time. For invoices and receipts, embedding the image data makes the input self-contained.

Fonts

Use a font available to the renderer, or follow the native package’s documented custom-font path support. If a font falls back, check its path, format and platform configuration before changing layout measurements.

5. Save, open and share the result

  1. Generate the file.
  2. Check that the returned URI or file path exists.
  3. Copy a cache result to a document directory if it must persist.
  4. Pass the persistent URI to the platform sharing module or your own file viewer.

Do not treat an Expo cache URI as permanent storage. If a user expects to find the PDF later, keep a document-directory copy and maintain your own list of generated files.

6. Dynamic data and safe HTML

Escape user-controlled values before interpolating them into HTML. A value containing < or quotes can corrupt the document, and inserting untrusted markup can change what is rendered.

function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

const customer = escapeHtml('A < B');
const html = `<!DOCTYPE html><html><body><p>${customer}</p></body></html>`;

7. Performance and reliability checklist

  • Generate only the HTML needed for the document; very large DOM trees take longer to layout.
  • Resize oversized images before embedding them. A PDF does not benefit from shipping camera-resolution pixels into a small logo.
  • Keep the HTML self-contained when offline generation is required.
  • Handle generation errors and show a retry action instead of assuming a URI was returned.
  • Test both platforms with long text, multiple pages, missing images, non-Latin characters and slow network conditions.
  • Persist files that users need after the current session.

8. Troubleshooting

Symptom Likely cause Fix
PDF is blank Incomplete HTML, unsupported markup or a render failure. Use a full document, simplify the markup and log the generated URI and exception.
Local images missing on iOS WKWebView cannot resolve the local asset URL. Inline the image as base64.
Margins differ on Android WebView print defaults vary. Add explicit CSS @page margins.
File disappears later The URI points to temporary cache storage. Copy it to the app document directory.
Custom font is ignored Incorrect path or unsupported native configuration. Verify the package’s platform-specific font setup and use a known available fallback.
Share sheet does not open Sharing is unavailable on the device or the URI is invalid. Check Sharing.isAvailableAsync(), confirm the file exists and provide a save option.
Content is clipped Fixed dimensions or large unbreakable elements. Use responsive widths, avoid oversized images and add page-break rules.
Native build fails after installing the bare package Package and React Native versions are incompatible or native setup is incomplete. Check the package README and npm metadata, then align versions and rebuild the native app.

9. Or skip the browser setup

If your HTML is available at a public URL, ScreenshotNeo can return a PDF from one GET request. The page is rendered remotely, so your React Native app only downloads the resulting file. See the ScreenshotNeo API documentation for request options.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://example.com/invoice -o invoice.pdf
import requests

r = requests.get(
    "https://api.screenshotneo.com/v1/shot",
    params={"access_key": "YOUR_API_KEY", "url": "https://example.com/invoice"},
    timeout=90,
)
r.raise_for_status()
open("invoice.pdf", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://example.com/invoice' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo request failed: ${res.status}`);
const pdf = Buffer.from(await res.arrayBuffer());

ScreenshotNeo removes cookie and consent banners, newsletter popups and chat widgets before capture. Bot checks, blank pages, failed loads, timeouts and cache hits are not billed, and each response identifies the result with X-Page-Verdict and X-Billed headers. Its MCP server lets Claude, Cursor and other MCP clients call take_screenshot, get_page_info and capture_pdf. The free plan includes 1,000 screenshots per month with no card; paid plans start at $5 for 3,000.

Create a free ScreenshotNeo account and start with 1,000 screenshots a month at no charge.

10. FAQ

Does Expo Print create a PDF on both iOS and Android?

Yes. Expo’s Print module supports both platforms. Rendering details such as margins and local asset handling still require platform-specific testing.

Can I print without saving a file?

Yes. Use Print.printAsync({ html }) to open the native print interface.

Where does an Expo-generated PDF live?

printToFileAsync returns a URI in the app cache. Copy it to a document location when it must persist.

Which library gives more native layout controls?

react-native-html-to-pdf exposes options for dimensions, directories and other platform-specific controls. Confirm compatibility with your React Native version first.

Can ScreenshotNeo convert an in-memory HTML string directly?

The provided API call captures a URL. Host the HTML at a URL that the service can reach, then request the PDF from that URL.