Get your API key
How to generate PDFs from Notion pages and databases

How to generate PDFs from Notion pages and databases

Turn Notion pages and database rows into PDFs: a script via the Notion API, a no-code n8n flow, or a template render, each producing a styled PDF.

12 min read

Generating a PDF from a Notion page or database row takes three steps: read the content with the Notion API (@notionhq/client), turn it into HTML, then render that HTML to PDF. Notion has no PDF endpoint of its own, so the rendering happens elsewhere. The fastest path for most teams is a short Node script plus a hosted render call to PDF4.dev, because it skips both the manual Export menu and any headless browser you would otherwise host yourself.

This guide covers a scripted export with the Notion API, a no-code n8n flow, and a per-row database export, with the exact property-to-variable mapping that trips people up.

Which method should you use to turn Notion into a PDF?

The right method depends on whether you write code and how often the export runs. The table below compares the three realistic options against setup effort, control over styling, and whether it can run unattended.

MethodSetup effortStyling controlRuns unattendedBest for
Notion built-in "Export to PDF"NoneNone (Notion's layout)No, manual clickOne-off personal export
Node script + Notion API + PDF4.devMedium (write a script)Full (your HTML/CSS)Yes (cron, webhook)Branded invoices, wikis, reports
n8n flow (Notion trigger + PDF4.dev)Low (visual nodes)Full (HTML template)Yes (trigger-based)No-code teams, recurring jobs

The built-in menu (··· then Export, format PDF) is fine for grabbing a single page by hand. It cannot be scheduled, it ignores your brand, and it has no API. Both other rows produce a styled, repeatable PDF. Pick the Node script if you want the export inside an existing backend, pick n8n if you would rather wire boxes together.

Notion's public API exposes pages, databases, blocks, and users. It does not expose a "render this page to PDF" call. Every programmatic path reads structured JSON and converts it yourself.

How do you set up a Notion integration and token?

Create an internal integration to get a token, then share each page or database with it. Without the share step, the API returns nothing. This is the single most common reason a Notion export "works in Postman but returns an empty list".

  1. Go to notion.so/my-integrations and create a new internal integration.
  2. Copy the integration secret. Internal tokens start with ntn_.
  3. Open the target page or database in Notion, click the ··· menu, choose Connections, and add your integration.
  4. Grab the database id from its URL: the 32-character hex string before the ?v= view parameter.

The integration inherits only the permissions you grant. If you share a single database, the token can read that database and its child pages, nothing else in the workspace. Store the secret in an environment variable, never in client-side code, since it grants read access to everything shared with it.

A Notion database id and a page id are both 32-char hex but refer to different objects. Calling databases.query with a page id returns a 404. Copy the id from the database's own URL, not from a row inside it.

How do you export a single Notion page to PDF with Node.js?

Read the page's properties and child blocks, convert the blocks to HTML, then POST that HTML to a render endpoint. The script below uses @notionhq/client for the read and PDF4.dev for the render, so there is no browser to install. Install the client first with npm install @notionhq/client.

// notion-page-to-pdf.js
import { Client } from "@notionhq/client"
 
const notion = new Client({ auth: process.env.NOTION_TOKEN })
 
// Fetch every block of a page, following pagination.
async function getBlocks(blockId) {
  const blocks = []
  let cursor
 
  do {
    const res = await notion.blocks.children.list({
      block_id: blockId,
      start_cursor: cursor,
      page_size: 100,
    })
    blocks.push(...res.results)
    cursor = res.has_more ? res.next_cursor : undefined
  } while (cursor)
 
  return blocks
}
 
// Map a rich_text array to inline HTML (bold, italic, links).
function richText(items = []) {
  return items
    .map((t) => {
      let text = escapeHtml(t.plain_text)
      const a = t.annotations
      if (a.bold) text = `<strong>${text}</strong>`
      if (a.italic) text = `<em>${text}</em>`
      if (a.code) text = `<code>${text}</code>`
      if (t.href) text = `<a href="${t.href}">${text}</a>`
      return text
    })
    .join("")
}
 
function escapeHtml(s) {
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
 
// Convert a subset of Notion block types to HTML.
function blockToHtml(block) {
  const type = block.type
  const value = block[type]
  switch (type) {
    case "heading_1":
      return `<h1>${richText(value.rich_text)}</h1>`
    case "heading_2":
      return `<h2>${richText(value.rich_text)}</h2>`
    case "heading_3":
      return `<h3>${richText(value.rich_text)}</h3>`
    case "paragraph":
      return `<p>${richText(value.rich_text)}</p>`
    case "bulleted_list_item":
      return `<li>${richText(value.rich_text)}</li>`
    case "numbered_list_item":
      return `<li>${richText(value.rich_text)}</li>`
    case "image": {
      const src = value.type === "external" ? value.external.url : value.file.url
      return `<img src="${src}" style="max-width:100%" />`
    }
    case "divider":
      return `<hr />`
    default:
      return ""
  }
}
 
async function pageToHtml(pageId) {
  const page = await notion.pages.retrieve({ page_id: pageId })
  const titleProp = Object.values(page.properties).find((p) => p.type === "title")
  const title = richText(titleProp?.title)
  const blocks = await getBlocks(pageId)
  const body = blocks.map(blockToHtml).join("\n")
 
  return `<!doctype html><html><head><meta charset="utf-8"><style>
    body { font-family: Inter, Arial, sans-serif; color: #111827; padding: 40px; }
    h1 { font-size: 28px; } h2 { font-size: 22px; } h3 { font-size: 18px; }
    p, li { font-size: 14px; line-height: 1.6; }
    code { background: #f3f4f6; padding: 2px 4px; border-radius: 4px; }
  </style></head><body><h1>${title}</h1>${body}</body></html>`
}
 
async function main() {
  const html = await pageToHtml(process.env.NOTION_PAGE_ID)
 
  const res = await fetch("https://pdf4.dev/api/v1/render", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.PDF4_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ html, data: {}, delivery: "url" }),
  })
 
  const { url } = await res.json()
  console.log("PDF ready:", url)
}
 
main()

The richText helper matters more than it looks. Notion stores inline formatting in the annotations object of each rich_text item (bold, italic, code, color) and links in the href field, so flattening with plain_text alone drops every bold word and hyperlink. Map each annotation to its HTML tag once and every page reads correctly.

How do you generate a PDF for each row in a Notion database?

Query the database, loop over the rows, map each row's properties to template variables, and render once per row. A database row is a page whose properties object is keyed by column name, so the mapping is a lookup by column name plus the property type. This is the pattern for invoices, certificates, or member cards stored as a Notion table.

The property shapes you read most often:

Notion property typeWhere the value livesExample access
titlearray of rich_textprops.Name.title[0].plain_text
rich_textarray of rich_textprops.Notes.rich_text[0]?.plain_text
numberraw numberprops.Amount.number
selectsingle option objectprops.Status.select?.name
datestart and end stringsprops.Due.date?.start
checkboxbooleanprops.Paid.checkbox
email / url / phone_numberraw stringprops.Email.email
// notion-database-to-pdfs.js
import { Client } from "@notionhq/client"
 
const notion = new Client({ auth: process.env.NOTION_TOKEN })
const DATABASE_ID = process.env.NOTION_DATABASE_ID
 
// Read all rows, following the start_cursor pagination.
async function queryAll(databaseId) {
  const rows = []
  let cursor
 
  do {
    const res = await notion.databases.query({
      database_id: databaseId,
      start_cursor: cursor,
      page_size: 100,
    })
    rows.push(...res.results)
    cursor = res.has_more ? res.next_cursor : undefined
  } while (cursor)
 
  return rows
}
 
// Flatten a row's properties into a plain object for the template.
function mapRow(page) {
  const p = page.properties
  return {
    invoice_number: p.Name?.title?.[0]?.plain_text ?? "",
    client: p.Client?.rich_text?.[0]?.plain_text ?? "",
    amount: p.Amount?.number ?? 0,
    status: p.Status?.select?.name ?? "Draft",
    due_date: p.Due?.date?.start ?? "",
    paid: p.Paid?.checkbox ?? false,
  }
}
 
async function renderInvoice(data) {
  const res = await fetch("https://pdf4.dev/api/v1/render", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.PDF4_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      template_id: "notion-invoice",
      data,
      delivery: "url",
    }),
  })
  return (await res.json()).url
}
 
// Stay under ~3 requests/second.
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
 
async function main() {
  const rows = await queryAll(DATABASE_ID)
  for (const row of rows) {
    const data = mapRow(row)
    const url = await renderInvoice(data)
    console.log(`${data.invoice_number} -> ${url}`)
    await sleep(400)
  }
}
 
main()

Store the HTML once as a template in PDF4.dev and pass only template_id plus data, so the layout lives in one place and each row sends a small JSON payload. The Handlebars helpers (formatCurrency, formatDate, {{#if}}) run server-side at render time, so the Notion script never has to format numbers or dates itself.

The sleep(400) between calls keeps you under Notion's rate limit of roughly 3 requests per second. For a database of 500 rows, batch the renders or run them through a queue so one slow page never stalls the whole export.

How do you build a no-code Notion to PDF flow in n8n?

Wire a Notion trigger to an HTTP request node that calls PDF4.dev, with a Set node in between to map properties to template variables. n8n ships a Notion node and an official PDF4.dev community node (n8n-nodes-pdf4), so the whole flow is visual with no script to maintain.

A working layout:

  1. Notion Trigger (or a scheduled Notion node with the "Get Many" database rows operation) fires when a row is added or updated.
  2. Set node maps each Notion property to a flat field, the same flattening the mapRow function does in code.
  3. PDF4.dev node (operation "Render From Template"), or an HTTP Request node calling POST https://pdf4.dev/api/v1/render with your Bearer key, sends the mapped data.
  4. Google Drive / Gmail / S3 node stores or emails the returned PDF URL.

This flow exports a PDF on every database change without a server. For a deeper walk-through of the trigger-then-render pattern across tools, see automating PDFs with Zapier and Make. The same shape (read rows, map fields, render, deliver) also applies to Airtable and Google Sheets as the data source.

How do you render the HTML to PDF without hosting a browser?

Send your HTML to a hosted endpoint and get a PDF back, instead of running headless Chromium yourself. PDF4.dev renders HTML with Playwright-driven Chromium server-side, so the Notion script stays small and there is no browser binary, no serverless cold-start, and no font installation to manage.

curl -X POST https://pdf4.dev/api/v1/render \
  -H "Authorization: Bearer p4_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Hello from Notion</h1>",
    "data": {},
    "delivery": "url"
  }'

The delivery: "url" option returns a signed link valid for 24 hours instead of a base64 blob, which keeps the response small when you export hundreds of rows. Set delivery to "base64" if you want the bytes inline, or omit it to receive the raw PDF body. To experiment without writing code first, paste any HTML into the free HTML to PDFTry it free tool and confirm your CSS prints the way you expect.

Which option should you choose?

The decision comes down to whether you write code and how often the export runs. Match your scenario to the row below.

ScenarioRecommended method
Grab one page by hand, no styling neededNotion's built-in Export to PDF menu
Branded invoice or certificate per database rowNode script + PDF4.dev template
Recurring export, no-code teamn8n flow with the PDF4.dev node
Export embedded in an existing backendNode/TypeScript script calling the render API
Long wiki page with images and formattingNode script with a block-to-HTML mapper

For most product and ops teams, the Node script that reads the Notion API and renders through PDF4.dev hits the best balance: full control over the layout, no browser to host, and a loop that scales from one page to a whole database. Start with a single page, get the property mapping right, then wrap the same render call in a loop or an n8n trigger when you need it to run on its own.

Keep your Notion integration token and your PDF4.dev API key in environment variables, never in the template HTML or client-side code. Both grant read or render access and should never reach a browser bundle.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.