Engineering9 min read

Why your react-pdf table splits across pages (and the fix)

react-pdf tears table rows in half at page breaks because it can't measure layout. Here's the wrap={false} fix, a repeating header, and a full invoice that paginates cleanly.

You built an invoice with @react-pdf/renderer. It looked perfect with three line items. Then a real customer with twenty-two line items downloaded theirs, and row eleven is sliced clean in half by the page break - the top of the row on page one, the bottom on page two.

This is the single most-reported pain with react-pdf - which is otherwise the right tool for data-driven documents (see the full guide to generating PDFs in React). This is just its sharpest edge, and it has a precise cause and a precise fix.

The short answer

react-pdf splits a table row across a page break because it has no layout-measurement API - it can't know a row's rendered height before it lays the page out, so it fills to the bottom edge and cuts whatever is there. The fix is wrap={false} on each row's <View>, which forces the whole row to jump to the next page instead of tearing. Add a fixed header so the column titles repeat on every page.

Why does react-pdf split tables across pages?

react-pdf is not a browser. There's no DOM, no reflow, and no way to ask "how tall will this row be once the text wraps?" It lays elements out top to bottom, and when it reaches the bottom margin mid-element, the default behavior is to break inside that element and continue on the next page.

For a paragraph, breaking mid-element is fine. For a table row - a horizontal flexDirection: "row" View with cells in it - it's a disaster: the row's cells get severed at an arbitrary vertical pixel. This is a long-standing, well-documented limitation (#2698 is the same story with itineraries), not a bug you can patch around with styling.

So the strategy isn't to make react-pdf measure better. It's to tell it which elements must never be broken.

The fix: wrap={false} on every row

Every react-pdf element accepts a wrap prop. Set it to false and the element becomes atomic - if it doesn't fit in the remaining space, react-pdf moves the entire element to the next page rather than splitting it.

{items.map((item, i) => (
  <View key={i} style={styles.row} wrap={false}>
    <Text style={styles.cId}>{item.description}</Text>
    <Text style={styles.cQty}>{item.qty}</Text>
    <Text style={styles.cPrice}>{money(item.price)}</Text>
    <Text style={styles.cAmt}>{money(item.qty * item.price)}</Text>
  </View>
))}

That one prop fixes the torn-row problem completely. The row either fits or it doesn't - it never tears.

How do I repeat the table header on page 2?

By default the header is rendered once, at the top of page one. When your rows flow onto page two, the columns there have no titles. The fix is the fixed prop, which re-renders an element at the same position on every page:

<View style={styles.head} fixed>
  <Text style={styles.cId}>Description</Text>
  <Text style={styles.cQty}>Qty</Text>
  <Text style={styles.cPrice}>Price</Text>
  <Text style={styles.cAmt}>Amount</Text>
</View>

fixed is the same prop you use for running headers, footers, and page numbers. Page numbers use the render callback, which receives pageNumber and totalPages:

<Text
  fixed
  render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`}
/>

How do I keep a block (totals, signature) together?

Same tool, bigger scope. Wrap any group that must stay on one page in a wrap={false} View. A totals block that gets separated from its last line item looks broken; this prevents it:

<View style={styles.totals} wrap={false}>
  <View style={styles.totalRow}><Text>Subtotal</Text><Text>{money(subtotal)}</Text></View>
  <View style={styles.totalRow}><Text>Tax</Text><Text>{money(tax)}</Text></View>
  <View style={styles.totalRowStrong}><Text>Total</Text><Text>{money(total)}</Text></View>
</View>

For section starts, the inverse prop break forces a new page before an element - useful for report sections that should each begin fresh.

A complete invoice that paginates correctly

Here's the whole thing: dynamic line items that never tear, a header that repeats, and a totals block that stays intact. It's plain @react-pdf/renderer - no headless browser, no Puppeteer, nothing to deploy but your own code.

import { Document, Page, View, Text, StyleSheet } from "@react-pdf/renderer";
 
const styles = StyleSheet.create({
  page: { padding: 40, fontSize: 10, fontFamily: "Helvetica", color: "#111" },
  h1: { fontSize: 20, fontFamily: "Helvetica-Bold", marginBottom: 2 },
  muted: { color: "#666" },
  head: { flexDirection: "row", backgroundColor: "#f4f4f5", paddingVertical: 6, paddingHorizontal: 4 },
  row: { flexDirection: "row", borderBottomWidth: 0.5, borderBottomColor: "#e5e5e5", paddingVertical: 6, paddingHorizontal: 4 },
  cId: { flex: 3 },
  cQty: { flex: 1, textAlign: "right" },
  cPrice: { flex: 1, textAlign: "right" },
  cAmt: { flex: 1, textAlign: "right" },
  bold: { fontFamily: "Helvetica-Bold" },
  totals: { marginTop: 12, alignSelf: "flex-end", width: 200 },
  totalRow: { flexDirection: "row", justifyContent: "space-between", paddingVertical: 3 },
  totalStrong: { flexDirection: "row", justifyContent: "space-between", borderTopWidth: 0.5, borderTopColor: "#111", marginTop: 4, paddingTop: 4 },
  footer: { position: "absolute", bottom: 24, left: 40, right: 40, flexDirection: "row", justifyContent: "space-between", color: "#999", fontSize: 8 },
});
 
type Item = { description: string; qty: number; price: number };
type InvoiceData = { number: string; date: string; billedTo: string; items: Item[]; taxRate?: number };
 
const money = (n: number) => `$${n.toFixed(2)}`;
 
export function Invoice({ data }: { data: InvoiceData }) {
  const subtotal = data.items.reduce((s, i) => s + i.qty * i.price, 0);
  const tax = subtotal * (data.taxRate ?? 0);
  const total = subtotal + tax;
 
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        <View style={{ marginBottom: 20 }}>
          <Text style={styles.h1}>Invoice {data.number}</Text>
          <Text style={styles.muted}>Issued {data.date}</Text>
          <Text style={{ marginTop: 8 }}>Billed to: {data.billedTo}</Text>
        </View>
 
        {/* fixed → the header repeats on every page the table flows onto */}
        <View style={styles.head} fixed>
          <Text style={[styles.cId, styles.bold]}>Description</Text>
          <Text style={[styles.cQty, styles.bold]}>Qty</Text>
          <Text style={[styles.cPrice, styles.bold]}>Price</Text>
          <Text style={[styles.cAmt, styles.bold]}>Amount</Text>
        </View>
 
        {/* wrap={false} → each row jumps whole to the next page, never tears */}
        {data.items.map((item, i) => (
          <View key={i} style={styles.row} wrap={false}>
            <Text style={styles.cId}>{item.description}</Text>
            <Text style={styles.cQty}>{item.qty}</Text>
            <Text style={styles.cPrice}>{money(item.price)}</Text>
            <Text style={styles.cAmt}>{money(item.qty * item.price)}</Text>
          </View>
        ))}
 
        {/* totals stay together */}
        <View style={styles.totals} wrap={false}>
          <View style={styles.totalRow}><Text>Subtotal</Text><Text>{money(subtotal)}</Text></View>
          {data.taxRate ? (
            <View style={styles.totalRow}>
              <Text>Tax ({(data.taxRate * 100).toFixed(0)}%)</Text><Text>{money(tax)}</Text>
            </View>
          ) : null}
          <View style={styles.totalStrong}>
            <Text style={styles.bold}>Total</Text><Text style={styles.bold}>{money(total)}</Text>
          </View>
        </View>
 
        <View style={styles.footer} fixed>
          <Text>Invoice {data.number}</Text>
          <Text render={({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`} />
        </View>
      </Page>
    </Document>
  );
}

Render it server-side on Vercel without a browser - it fits the serverless function size limit because there's no Chromium to bundle:

// app/api/invoice/route.tsx  (.tsx because of JSX)
import { renderToBuffer } from "@react-pdf/renderer";
import { Invoice } from "@/components/Invoice";
 
export const runtime = "nodejs"; // react-pdf needs Node, not edge
 
export async function POST(req: Request) {
  const data = await req.json();
  const pdf = await renderToBuffer(<Invoice data={data} />);
  return new Response(pdf, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": `attachment; filename="invoice-${data.number}.pdf"`,
    },
  });
}

This is the whole reason teams move off headless-browser PDFs in the first place - no 50 MB Chromium layer, no cold-start tax, no font drift. If you're weighing that trade-off, we did the math on HTML-to-PDF invoices at volume.

When wrap={false} isn't enough: a single huge row

wrap={false} works as long as a row is shorter than a page. If one cell holds a paragraph taller than the printable area, the row can't fit anywhere and react-pdf will warn and render it broken anyway - there's an open edge case here.

When a row genuinely can be page-tall (long free text, stacked images), don't fight it per-row. Pre-chunk your data into page-sized groups before rendering, and let each chunk start cleanly:

function chunk<T>(arr: T[], size: number): T[][] {
  const out: T[][] = [];
  for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
  return out;
}
 
// render one <View break={i > 0}> per chunk, rows inside still wrap={false}
{chunk(items, 18).map((group, i) => (
  <View key={i} break={i > 0}>
    {group.map((item, j) => (
      <View key={j} style={styles.row} wrap={false}>{/* cells */}</View>
    ))}
  </View>
))}

You tune the chunk size to your row height. It's not elegant, but it's deterministic - which is the whole game with PDF layout.

The takeaway

You can't make react-pdf measure layout, so you tell it what not to break:

Goal Prop Where
Row never tears wrap={false} each row <View>
Header repeats every page fixed header <View>
Footer / page numbers repeat fixed + render footer <Text>
Totals/signature stay together wrap={false} the group <View>
Section starts on a new page break the section <View>
Oversized rows pre-chunk + break data layer

Once these are in place, the same invoice renders correctly whether it's one line item or two hundred.

The deeper point: this is your code. You own the component, it renders in your own infrastructure, and your customer data never leaves your app. That's the model behind PDFx Builder - design or AI-generate the document, then export exactly this kind of clean, owned react-pdf TSX (page breaks already handled) instead of hand-fighting layout every time you need a PDF. If you want the building blocks without the builder, the react-pdf component library is free and open source.

Either way: wrap={false} on the row, fixed on the header. That's the fix.

Build one of these yourself.

Start free at pdfxbuilder.com. Three templates, no credit card.

Open PDFx Builder