In many industries, PDF generation has become a core requirement, whether it's for invoices, reports, or certificates, which are very important.
In this article, you will learn how to use Next.js, Puppeteer, and Strapi to build a PDF generation engine. The engine seamlessly integrates content from the Strapi backend, server-side rendering using Next.js, and web to PDF using Puppeteer.
By the end of this article, you will be able to easily build the frontend pages below, populate them with data from Strapi, and serve them to your users as PDFs.
Open up your terminal and create a pdf-engine folder to store your project files.
mkdir pdf-engineNavigate into the pdf-engine directory.
cd pdf-engineCreate your Strapi app in a folder named backend.
npx create-strapi-app@latest backend --quickstartThe --quickstart flag sets up your Strapi app with an SQLite database and automatically starts your server on port 1337.
If the server is not already running in your terminal, cd into the backend folder and run npm develop to launch it.
Visit http://localhost:1337/admin in your browser and register in the Strapi Admin Registration Form.
Now that your Strapi application is set up and running, fill the form with your personal information to get authenticated to the Strapi Admin Panel.
From your admin panel, click on Content-Type Builder > Create new collection type tab to create a collection and its field types for your application. In this case, you are creating the following collections: About, Article, Graph, topfeature, topstat, and report. These collections will help us create types for the Strapi report PDF.
Here is a table showing all collection types, their fields, and relations.
| Collection Type | Field | Type | Relation | Notes |
|---|---|---|---|---|
| About | Description | Long Text | – | |
| Funded | Boolean | – | ||
| Size | Short Text | – | ||
| Returns | Enum | – | Options: daily, weekly, monthly, quarterly | |
| Report | Relation | One to One with Report | Each About entry belongs to one Report | |
| Article | Picture | Media | – | |
| Title | Text | – | ||
| Report | Relation | Many to One with Report | Each Report can have many Articles | |
| Graph | Year | Number | – | |
| Ratio | Number | – | ||
| Report | Relation | Many to One with Report | Each Report can have many Graphs | |
| Topfeature | Icon | Media | – | |
| Title | Short Text | – | ||
| Description | Long Text | – | ||
| Report | Relation | Many to One with Report | Each Report can have many Topfeatures | |
| Topstat | Title | Text | – | |
| Description | Long Text | – | ||
| Report | Relation | Many to One with Report | Each Report can have many Topstats | |
| Report | Title | Short Text | – | |
| Articles | Relation | One to Many with Article | A Report can have many Articles | |
| Graphs | Relation | One to Many with Graph | A Report can have many Graphs | |
| Topfeatures | Relation | One to Many with Topfeature | A Report can have many Topfeatures | |
| Topstats | Relation | One to Many with Topstat | A Report can have many Topstats | |
| About | Relation | One to One with About | A Report has one About section |
For more on this, see Understanding and Using Relations in Strapi to learn more.
Now, let's allow API access to your blog to allow you to access the blog posts via your API on the frontend. To do that, click on Settings > Roles > Public.
Then expand the Report and click on find and findOne.
Scroll downwards and do the same for the media library Permission tab to enable us to upload images to our Strapi backend.
Strapi supports frontend frameworks including React, Next.js, Gatsby, Svelte, Vue.js etc. but for this application you will be making use of Next.js.
In a new terminal session, change the directory to pdf-engine and run the following command:
npx create-next-app@latestOn installation, you'll see some prompts. Name your project frontend and select Typescript, app router with Tailwind, and other config of your choice.
Add the following dependencies to your Next.js frontend app, you will use them later:
cd frontend
npm install zod puppeteer recharts @asteasolutions/zod-to-openapiThe directory structure looks like this:
1.
2├── README.md
3├── app
4│ ├── api
5│ │ ├── docs
6│ │ │ └── route.ts
7│ │ └── pdf
8│ │ └── route.ts
9│ ├── favicon.ico
10│ ├── globals.css
11│ ├── layout.tsx
12│ └── page.tsx
13├── component
14│ ├── annual-returns.tsx
15│ ├── article-count-ratio.tsx
16│ ├── details-card.tsx
17│ ├── last-page.tsx
18│ ├── page.tsx
19│ └── portfolio-about.tsx
20├── eslint.config.mjs
21├── lib
22│ ├── http.ts
23│ ├── openapi.ts
24│ ├── schemas
25│ │ └── pdf.ts
26│ └── util.ts
27├── modules
28│ ├── about-page.tsx
29│ ├── front-page.tsx
30│ └── return-details-page.tsx
31├── next-env.d.ts
32├── next.config.ts
33├── package-lock.json
34├── package.json
35├── postcss.config.mjs
36├── public
37│ ├── file.svg
38│ ├── globe.svg
39│ ├── next.svg
40│ ├── vercel.svg
41│ └── window.svg
42└── tsconfig.jsonOpen the app/globals.css file in your Next.js project and replace its contents with the following code:
1. Import Tailwind
1@import 'tailwindcss'Brings in Tailwind CSS so you can use its utility classes along with your custom print styles.
2. Set Print Page
1@page {
2 size: A4;
3 margin: 0;
4}The size: A4; sets the print page size to standard A4 dimensions (210mm × 297mm). And the margin: 0; removes default printer margins so your content fills the page edge-to-edge (printer permitting).
3. HTML & Body reset
1html,
2body {
3 width: 210mm;
4 height: 297mm;
5 margin: 0;
6 padding: 0;
7 counter-reset: page;
8}The width/height forces the document’s physical dimensions to A4 size for accurate print scaling. The margin:0; padding: 0; Removes browser default spacing. And counter-reset: page; Initializes a CSS counter named page at zero, so you can number pages.
4. Page number counter
1.pageNumber::after {
2 counter-increment: page;
3 content: counter(page);
4}The style above targets elements with .pageNumber and appends the current page number using CSS counters. The counter-increment: page; increases the page counter each time the element appears. And content: counter(page); inserts the counter’s value into the document.
5. Page break handling
1.print-page {
2 break-after: page;
3 page-break-after: always;
4}
5
6.print-page:last-of-type {
7 page-break-after: auto;
8}The .print-page forces a hard page break after this element when printing. And the .print-page:last-of-type Removes the page break for the very last page, so you don’t get an empty page at the end.
6. @media print rules
1@media print {
2 html,
3 body {
4 width: 210mm;
5 height: 297mm;
6 }
7
8 * {
9 -webkit-print-color-adjust: exact !important;
10 print-color-adjust: exact !important;
11 }
12
13 .pageNumber::after {
14 counter-increment: page;
15 content: counter(page);
16 }
17}The html and body elements enforce the A4 size specifically for print mode. The -webkit-print-color-adjust: exact / print-color-adjust: exact: ensures colors are printed exactly as defined, preventing browsers from lightening or ignoring background colors. And .pageNumber::after Repeats the counter rule inside @media print to ensure page numbering still works during print rendering.
Create a .env file in the root of your frontend directory and add the following environment variables:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337/This section explains how the application’s core components work together to manage data, display dynamic content, and generate PDFs.
The application integrates with Strapi CMS running on localhost:1337 to fetch dynamic content:
1// Path: ./lib/http.ts
2
3export const getAllReports = async () => {
4 const res = await fetch(`http://localhost:1337/api/reports?populate=*`);
5 return res.json();
6};
7
8export const getSingleReport = async (id: number | string) => {
9 const res = await fetch(
10 `http://localhost:1337/api/reports/${id}?populate[0]=articles.picture&populate[1]=topfeature.icon&populate[2]=graphs&populate[3]=topstats&populate[4]=about`
11 );
12 return res.json();
13};
14
15//lib/util.ts
16export const getImageUrl = (url: string) => {
17 return `${process.env.NEXT_PUBLIC_STRAPI_URL}${url}`;
18};This design allows content editors to manage reports, articles, and statistics through Strapi's intuitive interface, while the Next.js application consumes this content via REST API calls.
This is the main page (app/page.tsx) where you will put all the pages together, with each wrapped in the page component.
Each page section is a modular component that receives specific data from Strapi, enabling flexible content layouts and easy maintenance.
1// Path: ./app/page.tsx
2
3import { LastPage } from '@/component/last-page';
4import { Page } from '@/component/page';
5import { getSingleReport } from '@/lib/http';
6import { AboutPage } from '@/modules/about-page';
7import { FrontPage } from '@/modules/front-page';
8import { ReturnDetailsPage } from '@/modules/return-details-page';
9
10export default async function Home({
11 searchParams,
12}: {
13 searchParams: { documentId: string };
14}) {
15 const singleReport = await getSingleReport(searchParams?.documentId);
16
17 return (
18 <>
19 <Page noPadding>
20 <FrontPage
21 title={singleReport?.data?.title}
22 about={singleReport?.data?.about}
23 />
24 </Page>
25 <Page>
26 <AboutPage
27 about={singleReport?.data?.about}
28 articles={singleReport?.data?.articles}
29 />
30 </Page>
31 <Page>
32 <ReturnDetailsPage
33 topStats={singleReport?.data?.topstats}
34 graph={singleReport?.data?.graphs}
35 topFeatures={singleReport?.data?.topfeature}
36 />
37 </Page>
38 <Page noPadding>
39 <LastPage />
40 </Page>
41 </>
42 );
43}The heart of the application lies in the PDF generation API (app/api/pdf/route.ts), which leverages Puppeteer for high-quality document creation. See code in the "Next.js + Puppeteer: Generate and Download PDFs via API Routes" section of "PDF Generation Implementation" heading.
route query parameter to generate PDFs from any frontend routepage.emulateMediaType('print') for optimal PDF outputBrowser Configuration:
The application uses optimized Puppeteer arguments for production environments:
This section shows how the UI is brought together from small, reusable components and how they come to be a printable report. Skim the visuals, then check the code to see props and structure.
What’s below:
In the code block below, we gave our app its default layout and set Geist as the font; this layout here affects other pages in the Next app.
1// Path: ./app/layout.tsx
2
3import type { Metadata } from 'next';
4import { Geist, Geist_Mono } from 'next/font/google';
5import './globals.css';
6
7const geistSans = Geist({
8 variable: '--font-geist-sans',
9 subsets: ['latin'],
10});
11
12const geistMono = Geist_Mono({
13 variable: '--font-geist-mono',
14 subsets: ['latin'],
15});
16
17export const metadata: Metadata = {
18 title: 'Create Next App',
19 description: 'Generated by create next app',
20};
21
22export default function RootLayout({
23 children,
24}: Readonly<{
25 children: React.ReactNode;
26}>) {
27 return (
28 <html lang='en'>
29 <body
30 className={`${geistSans.variable} ${geistMono.variable} antialiased`}
31 >
32 {children}
33 </body>
34 </html>
35 );
36}Explanation
{ data: { year: string; ratio: number }[] }.Code walkthrough
ResponsiveContainer: Keeps the chart responsive on screen and stable in print.BarChart/Bar: Plots ratio; rounded corners (radius) look better when printed.XAxis/YAxis: Minimal axis labels; unit='%' adds a percent suffix.Tooltip: Helpful in the browser; ignored by Puppeteer when generating PDF.1// Path: ./component/article-count-ratio
2
3'use client';
4
5import {
6 BarChart,
7 Bar,
8 XAxis,
9 YAxis,
10 Tooltip,
11 ResponsiveContainer,
12} from 'recharts';
13
14export type IArticleRatioData = {
15 year: string;
16 ratio: number;
17};
18
19export default function ArticleCountRatioChart({
20 data,
21}: {
22 data: IArticleRatioData[];
23}) {
24 return (
25 <div className='w-full h-[400px] bg-white p-10 rounded-2xl shadow'>
26 <h2 className='text-xl font-bold mb-4'>Article Count Ratio by Year</h2>
27 <ResponsiveContainer width='100%' height='100%'>
28 <BarChart data={data}>
29 <XAxis dataKey='year' />
30 <YAxis unit='%' />
31 <Tooltip />
32 <Bar dataKey='ratio' fill='#4845fe' radius={[8, 8, 0, 0]} />
33 </BarChart>
34 </ResponsiveContainer>
35 </div>
36 );
37}Explanation
Code walkthrough
data to the chart without transforming it.1// Path: ./component/annual-returns
2
3import ArticleCountRatioChart, {
4 IArticleRatioData,
5} from '@/component/article-count-ratio';
6import React from 'react';
7
8export const AnnualReturns = ({ data }: { data: IArticleRatioData[] }) => {
9 return (
10 <div className='w-full'>
11 <div className='flex items-center mb-10'>
12 <img
13 src='https://strapi.io/assets/strapi-logo-dark.svg'
14 height={42}
15 alt=''
16 />
17 </div>
18
19 <ArticleCountRatioChart data={data} />
20 </div>
21 );
22};Explanation
Code walkthrough
formatDate(...): Shared function for human-readable dates.1// Path: ./component/details-card
2
3import React from 'react';
4
5export const DetailsCard = ({
6 title,
7 img,
8 createdAt,
9 updatedAt,
10}: {
11 title: string;
12 img?: string;
13 updatedAt: Date;
14 createdAt: Date;
15}) => {
16 const formatDate = (date: Date) => {
17 return new Intl.DateTimeFormat('en-US', {
18 month: 'long',
19 day: 'numeric',
20 year: 'numeric',
21 }).format(new Date(date));
22 };
23
24 return (
25 <div className='flex gap-2 my-2 bg-white border border-gray-200 rounded-md overflow-hidden'>
26 <div className='w-[200px] h-[100px] flex-shrink-0'>
27 {img && (
28 <img src={img} alt='img-fd' className='w-full h-full object-cover' />
29 )}
30 </div>
31 <div className='p-3 text-[#032b69] flex flex-col justify-center'>
32 <h3 className='text-lg font-semibold'>{title}</h3>
33 <p className='text-[10pt] text-gray-500'>
34 {formatDate(createdAt)} · Updated {formatDate(updatedAt)}
35 </p>
36 </div>
37 </div>
38 );
39};Explanation
Code walkthrough
backgroundImage keeps the hero visual in print mode.1// Path: ./component/last-page
2
3import Link from 'next/link';
4import React from 'react';
5
6export const LastPage = () => {
7 return (
8 <div className='h-full w-full bg-[#f6f6ff] relative overflow-hidden'>
9 <div className='p-8'>
10 <img
11 src='https://strapi.io/assets/strapi-logo-dark.svg'
12 height={42}
13 alt=''
14 />
15 <div
16 style={{
17 backgroundImage:
18 'url(https://strapi.io/assets/use-case/strapi5_hero.svg)',
19
20 backgroundColor: '#181826',
21 }}
22 className='text-white my-8 p-4 rounded-md'
23 >
24 <h4 className='text-[25pt] font-bold leading-normal'>
25 Build modern websites with the most customizable Headless CMS
26 </h4>
27 <p className='text-sm leading-5 mt-3 w-[90%]'>
28 Open-source Headless CMS for developers that makes API creation
29 easy, and supports your favorite frameworks. Customize and host your
30 projects in the cloud or on your own servers.
31 </p>
32 <Link
33 href='https://strapi.io/'
34 className='cursor-pointer'
35 target='_blank'
36 >
37 <button className='bg-[#4845fe] text-white p-2 px-4 mt-4 rounded-md font-[500]'>
38 Get started
39 </button>
40 </Link>
41 </div>
42 <div>
43 {[
44 {
45 title: 'Improved Productivity',
46 description:
47 'An intuitive interface simplifies content creation, so your marketing team can work more efficiently, freeing up your time for more complex development tasks.',
48 },
49 {
50 title: 'Simplify Content Editing and Layouts',
51 description:
52 'Dynamic zones allow marketers to create adaptable and creative designs. This means less back-and-forth with developers for frontend changes.',
53 },
54 {
55 title: 'Internationalization and Media Management',
56 description:
57 'Publish content in multiple languages with I18N and organize media files using the Media Library.',
58 },
59 ].map((item) => (
60 <div
61 key={item?.title}
62 className='bg-[#fff] border border-[#e9e9ff] mb-3 p-6 rounded-md'
63 >
64 <div className='flex gap-2'>
65 <img
66 src='https://delicate-dawn-ac25646e6d.media.strapiapp.com/Check_5f2ef36f5a.svg'
67 alt=''
68 />
69 <h4 className='text-[#292875] text-[15pt] font-bold'>
70 {item?.title}
71 </h4>
72 </div>
73 <p className='text-[10pt] text-[#292875]'>{item?.description}</p>
74 </div>
75 ))}
76 </div>
77 </div>
78
79 <img
80 src='https://delicate-dawn-ac25646e6d.media.strapiapp.com/Customization_6abc7697f5.png'
81 alt=''
82 className='absolute'
83 style={{ bottom: -370 }}
84 />
85 </div>
86 );
87};Explanation
Page.noPadding for full-bleed layouts when needed.Code walkthrough
w-[210mm] h-[297mm] aligns with the print CSS rules..pageNumber leverages counters set in global CSS.1// Path: ./component/page
2
3import React, { ReactNode } from 'react';
4
5export const Page = ({
6 children,
7 noPadding,
8}: {
9 children: ReactNode;
10 noPadding?: boolean;
11}) => {
12 return (
13 <div
14 className={
15 'print-page bg-[#f6f6ff] relative w-[210mm] h-[297mm] box-border ' +
16 (noPadding ? 'p-0' : 'p-8')
17 }
18 >
19 <div className='h-full w-full'>{children}</div>
20
21 <div className='pageNumber absolute bottom-4 right-8 text-xs text-gray-500 print:block'>
22 Page <span className='inline-block'></span>
23 </div>
24 </div>
25 );
26};Explanation
about object; values formatted for compact print.Code walkthrough
Info sub-component: Centralizes label/value alignment to avoid duplication.1// Path: ./component/portfolio-about
2
3import React, { FC } from 'react';
4
5export const PortfolioAbout: FC<{
6 about: {
7 description: string;
8 funded: boolean;
9 returns: string;
10 size: string;
11 };
12}> = async ({ about }) => {
13 return (
14 <div className='mt-10 p-6 bg-white rounded-lg'>
15 <h2 className='text-2xl font-bold text-blue-900 mb-4'>About</h2>
16 <p className='text-gray-500 mb-6 leading-relaxed'>{about?.description}</p>
17
18 <div className='space-y-4'>
19 <Info label='Funded' value={about?.funded ? 'Yes' : 'No'} />
20 <Info label='Fund Size' value={`₦ ${about?.size}`} />
21 <Info label='Returns Payment' value='Quarterly' />
22 </div>
23 </div>
24 );
25};
26
27const Info = ({ label, value }: { label: string; value: string }) => (
28 <div className='flex justify-between text-gray-500'>
29 <span>{label}</span>
30 <span className='text-blue-900 font-medium truncate max-w-[150px] text-right'>
31 {value}
32 </span>
33 </div>
34);You’ll assemble these into full pages via the modules folder.
Explanation
Code walkthrough
Link: Easy to replace with your own destination.1// Path: ./modules/front-page.tsx
2
3import Link from 'next/link';
4import React from 'react';
5
6interface IFrontPageProps {
7 title: string;
8 about: {
9 description: string;
10 funded: boolean;
11 returns: string;
12 size: string;
13 };
14}
15
16export const FrontPage = ({ title, about }: IFrontPageProps) => {
17 return (
18 <div
19 style={{
20 backgroundImage:
21 'url(https://strapi.io/assets/use-case/strapi5_hero.svg)',
22 }}
23 className='bg-[#181826] text-white w-full h-full relative overflow-hidden'
24 >
25 <div className='p-8'>
26 <h4 className='text-[48pt] pt-[100px] font-bold leading-16'>{title}</h4>
27 <p className='text-sm leading-5 mt-3 w-[80%]'>{about?.description}</p>
28 <Link
29 href='https://strapi.io/'
30 className='cursor-pointer'
31 target='_blank'
32 >
33 <button className='bg-[#4845fe] text-white p-2 px-4 mt-4 rounded-md font-[500]'>
34 Get started
35 </button>
36 </Link>
37 </div>
38 <img
39 src='https://delicate-dawn-ac25646e6d.media.strapiapp.com/Content_Management_cfd037fcc2.png'
40 alt='cowry-test'
41 style={{
42 // scale: 2.5,
43 position: 'absolute',
44 bottom: -150,
45 right: 0,
46 }}
47 />
48 </div>
49 );
50};Explanation
DetailsCard.Code walkthrough
getImageUrl(...): Resolves Strapi media URLs to absolute paths.articles: Each item becomes a stable-width DetailsCard row.1// Path: ./modules/about-page.tsx
2
3import { DetailsCard } from '@/component/details-card';
4import { PortfolioAbout } from '@/component/portfolio-about';
5import { getImageUrl } from '@/lib/util';
6import React from 'react';
7
8export const AboutPage = ({
9 articles,
10 about,
11}: {
12 articles: Array<{
13 title: string;
14 createdAt: Date;
15 updatedAt: Date;
16 }>;
17 about: {
18 description: string;
19 funded: boolean;
20 returns: string;
21 size: string;
22 };
23}) => {
24 return (
25 <div>
26 <div className='flex items-center'>
27 <img
28 src='https://strapi.io/assets/strapi-logo-dark.svg'
29 height={28}
30 alt=''
31 />
32 </div>
33 <PortfolioAbout about={about} />
34 <h2 className='text-2xl font-bold text-blue-900 mt-8 my-4'>
35 Top articles last year
36 </h2>
37 <div className='grid grid-cols-1'>
38 {articles?.map((details: any, item) => (
39 <DetailsCard
40 img={getImageUrl(details?.picture?.url as string)}
41 {...details}
42 key={item}
43 />
44 ))}
45 </div>
46 </div>
47 );
48};Explanation
Code walkthrough
AnnualReturns to set context with a quick visual.getImageUrl.1// Path: ./modules/return-details-page
2
3import { AnnualReturns } from '@/component/annual-returns';
4import { IArticleRatioData } from '@/component/article-count-ratio';
5import { getImageUrl } from '@/lib/util';
6import React from 'react';
7
8export const ReturnDetailsPage = ({
9 topStats,
10 graph,
11 topFeatures,
12}: {
13 topStats: Array<{ title: string; description: string }>;
14 graph: Array<IArticleRatioData>;
15 topFeatures: Array<{
16 icon: {
17 url: string;
18 };
19 title: string;
20 description: string;
21 }>;
22}) => {
23 return (
24 <div>
25 <AnnualReturns data={graph} />
26 <h2 className='text-2xl font-bold text-blue-900 mt-8 mb-2'>Top stats</h2>
27 <div className='grid grid-cols-2 gap-4'>
28 {topStats?.map((item) => (
29 <div
30 key={item?.title}
31 className='bg-white p-2 rounded-md border border-gray-100'
32 >
33 <h4 className='text-[#ac56f5] text-xl font-black'>
34 {item?.description}
35 </h4>
36 <p className='text-[#666687] text-[10pt]'>{item?.title}</p>
37 </div>
38 ))}
39 </div>
40 <h2 className='text-2xl font-bold text-blue-900 mt-6 mb-2'>
41 Top features
42 </h2>
43 <div className='grid grid-cols-3 gap-4'>
44 {topFeatures?.map((feature, item) => (
45 <div key={item} className='bg-white p-3 rounded-md'>
46 <div className='flex gap-2 items-center mb-3'>
47 <img
48 src={getImageUrl(feature?.icon?.url)}
49 alt=''
50 width={30}
51 height={30}
52 />
53 <h6 className='text-[12pt] text-[#292875] font-bold'>
54 {feature?.title}
55 </h6>
56 </div>
57 <p className='text-[#666687] text-[8pt]'>{feature?.description}</p>
58 </div>
59 ))}
60 </div>
61 </div>
62 );
63};You will be building a dummy Strapi end-of-year report PDF with top articles, top stats, etc.
TLDR; A request comes in → Next.js figures out the target page → Puppeteer renders it → PDF gets generated → PDF gets sent back.
In this module, you will use Next.js api features to process the Puppeteer, create a route for generating the API, and send it back to the users.
Let's dive into the PDF generation route.
1// Path: ./app/api/pdf/route.ts
2
3import { NextRequest } from 'next/server';
4import puppeteer from 'puppeteer';
5
6const browserArgs = [
7 '--no-sandbox',
8 '--disable-setuid-sandbox',
9 '--disable-dev-shm-usage',
10 '--disable-gpu',
11 '--disable-software-rasterizer',
12 '--disable-extensions',
13 '--disable-features=IsolateOrigins,site-isolation-trials',
14 '--no-zygote',
15 '--font-render-hinting=medium',
16 '--force-color-profile=srgb',
17 '--window-size=595,842',
18 '--hide-scrollbars',
19 '--mute-audio',
20 '--disable-speech-api',
21];
22
23export async function GET(req: NextRequest) {
24 const url = new URL(req.url);
25 const route = url.searchParams.get('route') || '/';
26 const documentId = url.searchParams.get('documentId') || '';
27 const targetUrl = `http://localhost:3000${route}?documentId=${documentId}`;
28
29 const browser = await puppeteer.launch({
30 headless: 'shell',
31 args: browserArgs,
32 userDataDir: '/tmp/puppeteer-cache',
33 });
34 try {
35 const page = await browser.newPage();
36 await page.emulateMediaType('print');
37 await page.setViewport({ width: 595, height: 842 });
38 await page.evaluate(() => document.fonts.ready);
39 await page.goto(targetUrl, {
40 waitUntil: 'networkidle0',
41 timeout: 5 * 60 * 6000,
42 });
43
44 const pdfBuffer = await page.pdf({
45 format: 'A4',
46 printBackground: true,
47 margin: { top: 0, right: 0, bottom: 0, left: 0 },
48 preferCSSPageSize: true,
49 });
50
51 await browser.close();
52
53 return new Response(pdfBuffer, {
54 headers: {
55 'Content-Type': 'application/pdf',
56 'Content-Disposition': `attachment; filename="output.pdf"`,
57 },
58 });
59 } catch (err) {
60 await browser.close();
61 return new Response(`Error generating PDF: ${err}`, { status: 500 });
62 } finally {
63 await browser?.close();
64 }
65}Our function in the API route exposes a GET endpoint in the Next.js app (App Router) then it;
/invoice) and a documentId query param.http://localhost:3000{route}?documentId=.Serverless and containerized setups can behave unpredictably. These flags help Chrome run more reliably while keeping it lightweight.
Visit Chromium Commandline Flags to learn more.
print-to-PDF Flow Configurationspage.emulateMediaType('print'): Forces CSS @media print rules—vital for page breaks, print-specific layouts, and hiding UI.page.setViewport({ width: 595, height: 842 }): Aligns the rendering area with A4 dimensions at 72 DPI. This prevents layout reflow between screen and print.1await page.evaluate(() => document.fonts.ready);networkidle0 waits until there are 0 network connections for at least 500 ms—useful when your page fetches data (e.g., from Strapi) before rendering.1await page.goto(targetUrl, {
2 waitUntil: 'networkidle0',
3 timeout: 5 * 60 * 6000,
4});1await page.pdf({
2 format: 'A4',
3 printBackground: true,
4 margin: { top: 0, right: 0, bottom: 0, left: 0 },
5 preferCSSPageSize: true,
6});In this section, you will be documenting the routes using Swagger to make this engine accessible and serve it via the API route.
You are defining a Zod schema for query parameters in a Next.js API endpoint. This schema is also annotated so it can be turned into an OpenAPI spec for documentation.
1// Path: ./lib/schema/pdf.ts
2
3import { z } from 'zod';
4import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
5
6extendZodWithOpenApi(z);
7
8export const PdfQuerySchema = z.object({
9 route: z.string().openapi({
10 description: 'Frontend route to render as PDF',
11 example: '/invoice',
12 }),
13 documentId: z.string().openapi({
14 description: 'Document ID',
15 example: '123',
16 }),
17});This code does the following:
extendZodWithOpenApi(z);: This function extends Zod so can attach .openapi({...}) metadata to your schemas. Without this, Zod wouldn’t know how to output OpenAPI descriptions/examples.z.object({...}) defines an object schema. In this case, the query parameters must include:route: must be a string, with OpenAPI description and example provided.
Example: /invoicedocumentId: must be a string, also documented with OpenAPI metadata. E.g. 123.The code below defines a utility function generateOpenApiSpec() that automatically generates the OpenAPI 3.0 specification document for your endpoint that creates PDFs.
It uses the @asteasolutions/zod-to-openapi library, which converts Zod schemas into OpenAPI-compliant documentation.
1// Path: ./lib/openapi.ts
2
3import {
4 OpenAPIRegistry,
5 OpenApiGeneratorV3,
6} from '@asteasolutions/zod-to-openapi';
7import { PdfQuerySchema } from '@/lib/schemas/pdf';
8
9export function generateOpenApiSpec() {
10 const registry = new OpenAPIRegistry();
11
12 registry.registerPath({
13 method: 'get',
14 path: '/api/pdf',
15 request: {
16 query: PdfQuerySchema,
17 },
18 responses: {
19 200: {
20 description: 'PDF generated successfully',
21 content: {
22 'application/pdf': {
23 schema: { type: 'string', format: 'binary' },
24 },
25 },
26 },
27 400: {
28 description: 'Invalid request',
29 },
30 },
31 summary: 'Generate PDF from frontend route',
32 tags: ['PDF'],
33 });
34
35 const generator = new OpenApiGeneratorV3(registry.definitions);
36
37 return generator.generateDocument({
38 openapi: '3.0.0',
39 info: {
40 title: 'PDF Generation API',
41 version: '1.0.0',
42 },
43 servers: [{ url: 'http://localhost:3000' }],
44 });
45}The code above contains the following:
OpenAPIRegistry: Lets you register schemas, routes, and responses.OpenApiGeneratorV3: Converts the registry into a valid OpenAPI v3 document.PdfQuerySchema: The Zod schema you created earlier for query validation.Method & Path: Defines a GET /api/pdf route.request.query: Uses your Zod schema (PdfQuerySchema) for validation + documentation.responses: 200 → A successful response returns a PDF (application/pdf) in binary format. 400 → Client error (invalid query params).summary & tags: Human-readable info for Swagger UI.Swagger UI: This creates a JSON object that represents your API in OpenAPI 3.0 format.You can now properly define our documentation schema here. And export the generateOpenApiSpec to be used as our spec in the SwaggerUIBundle, as seen below.
1// Path: ./app/api/docs/route.ts
2
3import { generateOpenApiSpec } from '@/lib/openapi';
4import { NextResponse } from 'next/server';
5
6export async function GET() {
7 const openApiSpec = generateOpenApiSpec();
8
9 const htmlContent = `
10 <!doctype html>
11 <html>
12 <head>
13 <title>PDF Generation engine</title>
14 <meta charset="utf-8" />
15 <meta name="viewport" content="width=device-width, initial-scale=1" />
16 <link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5.10.5/swagger-ui.css" />
17 <style>
18 html {
19 box-sizing: border-box;
20 overflow: -moz-scrollbars-vertical;
21 overflow-y: scroll;
22 }
23 *, *:before, *:after {
24 box-sizing: inherit;
25 }
26 body {
27 margin:0;
28 background: #fafafa;
29 }
30 </style>
31 </head>
32 <body>
33 <div id="swagger-ui"></div>
34 <script src="https://unpkg.com/swagger-ui-dist@5.10.5/swagger-ui-bundle.js"></script>
35 <script src="https://unpkg.com/swagger-ui-dist@5.10.5/swagger-ui-standalone-preset.js"></script>
36 <script>
37 window.onload = function() {
38 const ui = SwaggerUIBundle({
39 spec: ${JSON.stringify(openApiSpec)},
40 dom_id: '#swagger-ui',
41 deepLinking: true,
42 presets: [
43 SwaggerUIBundle.presets.apis,
44 SwaggerUIStandalonePreset
45 ],
46 plugins: [
47 SwaggerUIBundle.plugins.DownloadUrl
48 ],
49 layout: "StandaloneLayout"
50 });
51 };
52 </script>
53 </body>
54 </html>
55 `;
56
57 return new NextResponse(htmlContent, {
58 headers: {
59 'Content-Type': 'text/html',
60 },
61 });
62}You will use the Swagger HTML and serve our openApiSpec to the spec key in the script, and return the htmlContent as what the route resolves to.
Here is a demo of what our application does.
Check out the official documentation to read up on hosting your Strapi application to Strapi Cloud.
Feel free to clone the application from the GitHub repository and extend its functionality.
In this guide, you went through the steps of creating a PDF generation engine using Strapi, how you could build it as pages, and then use Next.js routes to serve them as pages. You also learned how to use Swagger and Zod to document the PDF generation endpoint.
This setup can be extended for invoices, receipts, certificates, and reports and deployed to Strapi Cloud for production scalability.
To learn more about Strapi and its other impressive features, you can visit the official documentation.
I am a Software Engineer and Technical writer, interested in Scalability, User Experience and Architecture.