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.
| Method | Setup effort | Styling control | Runs unattended | Best for |
|---|---|---|---|---|
| Notion built-in "Export to PDF" | None | None (Notion's layout) | No, manual click | One-off personal export |
| Node script + Notion API + PDF4.dev | Medium (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".
- Go to notion.so/my-integrations and create a new internal integration.
- Copy the integration secret. Internal tokens start with
ntn_. - Open the target page or database in Notion, click the
···menu, choose Connections, and add your integration. - 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, "&").replace(/</g, "<").replace(/>/g, ">")
}
// 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 type | Where the value lives | Example access |
|---|---|---|
title | array of rich_text | props.Name.title[0].plain_text |
rich_text | array of rich_text | props.Notes.rich_text[0]?.plain_text |
number | raw number | props.Amount.number |
select | single option object | props.Status.select?.name |
date | start and end strings | props.Due.date?.start |
checkbox | boolean | props.Paid.checkbox |
email / url / phone_number | raw string | props.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:
- Notion Trigger (or a scheduled Notion node with the "Get Many" database rows operation) fires when a row is added or updated.
- Set node maps each Notion property to a flat field, the same flattening the
mapRowfunction does in code. - PDF4.dev node (operation "Render From Template"), or an HTTP Request node calling
POST https://pdf4.dev/api/v1/renderwith your Bearer key, sends the mapped data. - 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.
| Scenario | Recommended method |
|---|---|
| Grab one page by hand, no styling needed | Notion's built-in Export to PDF menu |
| Branded invoice or certificate per database row | Node script + PDF4.dev template |
| Recurring export, no-code team | n8n flow with the PDF4.dev node |
| Export embedded in an existing backend | Node/TypeScript script calling the render API |
| Long wiki page with images and formatting | Node 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:
Start generating PDFs
Build PDF templates with a visual editor. Render them via API from any language in ~300ms.



