Introduction & What You'll Build
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.
Prerequisites
- You are expected to be familiar with Node.js and Next.js.
- Node.js version requirements (e.g., "Node.js 18.x or higher")
- npm or yarn version
- Basic TypeScript knowledge
- Understanding of REST APIs
- Familiarity with React hooks
- Terminal/command line basics
- A code editor (VS Code recommended)
What you will learn
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.
- How to fetch Strapi data dynamically
- How to render print-optimized pages in Next.js
- How to automate PDF generation with Puppeteer
- How to document the API with Swagger & Zod
Setting up Project Folder
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-engineStrapi 5 Backend Setup
Create 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.
Design the Content Model for the PDF generation engine
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.
Collections Overview
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.
Allowing Public API Access in Strapi
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.
Next.js Frontend Setup
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.
Install Necessary Dependencies
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-openapi- Zod: A TypeScript schema validation library that ensures data matches expected structures.
- Puppeteer: A Node.js library for automating and controlling Chrome/Chromium, ideal for tasks like scraping, PDF generation, and automated testing.
- Recharts: A composable charting library for React, built with SVG, perfect for creating interactive and responsive data visualizations.
- @asteasolutions/zod-to-openapi: A tool that converts Zod schemas into OpenAPI specifications, keeping API documentation in sync with your validation logic
Folder Structure
The 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.jsonSet Up your Frontend CSS Rules
Open 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.
Set up environment variables
Create a .env file in the root of your frontend directory and add the following environment variables:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337/Building Core Application Components and Data Flow
This section explains how the application’s core components work together to manage data, display dynamic content, and generate PDFs.
1. Content Management with Strapi
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.
2. Dynamic Page Composition
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}3. PDF Generation Engine
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.
Key Features of the PDF Engine
- Route-based Generation: Accepts a routequery parameter to generate PDFs from any frontend route
- Print-Optimized Rendering: Uses page.emulateMediaType('print')for optimal PDF output
- A4 Format Support: Configures viewport and page settings for standard document formats
- Font Loading Optimization: Ensures all fonts are loaded before PDF generation
- Error Handling: Comprehensive error handling with proper browser cleanup
Browser Configuration:
The application uses optimized Puppeteer arguments for production environments:
- Disables unnecessary features for performance
- Sets specific window dimensions (595x842 for A4)
- Configures font rendering and color profiles
- Disables audio and speech APIs
Building Application Data Visualization
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:
- Reusable chart/content blocks built with Recharts and simple React components
- Page-level modules that assemble those blocks into A4-ready sections
- Short, high-signal explanations for every image and code sample
1. App layout
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}2. Article count ratio Component
Explanation
- Purpose: Small, focused chart for “Article Count Ratio by Year”.
- Props: { data: { year: string; ratio: number }[] }.
- Notes: Self-contained sizing and style to keep print output consistent.
Code walkthrough
- "use client": Recharts renders on the client; needed for this chart.
- 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}3. Annual returns Component
Explanation
- Purpose: Section wrapper around the chart with heading and spacing.
- Use: Drop into pages to present annual performance.
Code walkthrough
- Branding row: Simple logo above the chart for visual anchoring.
- Composition: Forwards datato 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};4. Details card component
Explanation
- Purpose: Compact card for an item (e.g., article) with image and dates.
- Notes: Fixed image box ensures alignment in print; date formatting handled locally.
Code walkthrough
- formatDate(...): Shared function for human-readable dates.
- Left column: Fixed-size media box prevents uneven rows when images vary.
- Right column: Title and small, muted meta details for clean print hierarchy.
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};5. Last page component
Explanation
- Purpose: Closing page with brand CTA and supportive copy.
- Notes: Uses brand artwork, single CTA, and supportive bullet blocks.
Code walkthrough
- Background block: Inline backgroundImagekeeps the hero visual in print mode.
- CTA: High-contrast button for clear calls-to-action.
- Benefit items: Repeated pattern (icon + heading + paragraph) improves skimmability.
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};6. Page component
Explanation
- Purpose: Print-friendly A4 wrapper. Every section lives inside Page.
- Prop: noPaddingfor full-bleed layouts when needed.
Code walkthrough
- A4 canvas sizing: w-[210mm] h-[297mm]aligns with the print CSS rules.
- Page counter: .pageNumberleverages counters set in global CSS.
- Padding control: Avoids clipping edge-to-edge artwork.
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};7. About component
Explanation
- Purpose: Shows “About” description and key attributes in a neat card.
- Inputs: aboutobject; values formatted for compact print.
Code walkthrough
- Infosub-component: Centralizes label/value alignment to avoid duplication.
- Truncation + max width: Prevents long values from overflowing in print.
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);8. Front page module
You’ll assemble these into full pages via the modules folder.
Explanation
- Purpose: Hero/front page of the PDF with title, short description, and CTA.
- Notes: Background artwork declared in inline style for print stability.
Code walkthrough
- Inline background style: Ensures Puppeteer includes the hero image.
- Large display title: Establishes typographic hierarchy for first-page impact.
- CTA via 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};9. About page module
Explanation
- Purpose: Combines “About” with a list of article cards under a section header.
- Flow: Logo → About card → “Top articles” list using DetailsCard.
Code walkthrough
- getImageUrl(...): Resolves Strapi media URLs to absolute paths.
- Mapping articles: Each item becomes a stable-widthDetailsCardrow.
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};10. Return Details Page
Explanation
- Purpose: Composite section featuring the returns chart, key stats, and top features.
- Notes: Simple grid layouts that also print cleanly on A4.
Code walkthrough
- Chart first: Reuses AnnualReturnsto set context with a quick visual.
- 2-col stats grid: Clear, compact layout for key metrics.
- 3-col features grid: Icon + title + description blocks; images via 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};PDF Generation Implementation: Next.js API/Server modules for PDF Generation
PDF Generation Flow
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.
- API Entry point (Swagger UI): The flow starts when a request hits our API endpoint in the Next.js app. The request usually carries parameters like which page (route) you want to render and the specific data (e.g., document ID).
- Next.js Handler: Next.js provides the handler that interprets the request and prepares the environment. Here, you extract query parameters (route and document ID) and build the target URL that Puppeteer should load.
- Puppeteer Loads the Page: Puppeteer opens the target URL you built earlier. That page itself pulls content from Strapi (our CMS), so Puppeteer is effectively loading a fully dynamic webpage just like a real user’s browser would. Puppeteer waits until the page has finished loading all fonts, styles, and data before moving forward.
- Puppeteer Prints the Page: Once the page looks exactly like it should, Puppeteer switches into “print mode.” It then generates a PDF version of the page, keeping things like layout, fonts, and background colors intact. This is equivalent to you (Ctrl P) Print - Save as PDF in Chrome, but automated.
- Download Link Sent to User: Finally, the server responds with the PDF file as an attachment. The browser prompts the user to download it (output.pdf).
Next.js + Puppeteer: Generate and Download PDFs via API Routes
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;
- Reads the route (e.g. /invoice) and adocumentIdquery param.
- Launches Puppeteer with a hardened set of Chromium flags tailored for server/cloud environments.
- Opens a real, headless Chrome page at http://localhost:3000{route}?documentId=.
- Forces print media rules, waits for fonts and network to settle, and then prints to PDF on an A4 canvas with background graphics.
- Streams the resulting PDF back to the client with proper response headers.
Why these Chromium flags?
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.
The print-to-PDF Flow Configurations
- page.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.
- Ensure that webfonts have fully loaded before rendering. Without this, you’ll get FOUT/FOIT or incorrect text metrics in the PDF:
1await page.evaluate(() => document.fonts.ready);- networkidle0waits 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});- Generate a PDF in A4 format that includes backgrounds and colors, uses zero margins since the layout handles its own spacing, respects any CSS @page size rules (falling back to A4 if none are set), and return it with headers specifying Content-Type: application/pdf and Content-Disposition: attachment; filename="output.pdf" to prompt a downloadable file with a friendly name.
1await page.pdf({
2  format: 'A4',
3  printBackground: true,
4  margin: { top: 0, right: 0, bottom: 0, left: 0 },
5  preferCSSPageSize: true,
6});API Documentation with Swagger: PDF Routes
In this section, you will be documenting the routes using Swagger to make this engine accessible and serve it via the API route.
Validation and Documentation with Zod
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:
- Zod: A TypeScript-first schema validation library. Let's define the shape of data and validate it at runtime.
- @asteasolutions/zod-to-openapi: A library that lets us convert Zod schemas into OpenAPI-compatible schemas, so your validation rules automatically become API documentation.
- 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: /invoice
- documentId: must be a string, also documented with OpenAPI metadata. E.g. 123.
Create Utility function to Generate OpenAPI 3.0 Specification
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.
Testing Your Application
Here is a demo of what our application does.
Hosting Your Strapi Application With Strapi Cloud
Check out the official documentation to read up on hosting your Strapi application to Strapi Cloud.
GitHub Repository
Feel free to clone the application from the GitHub repository and extend its functionality.
Conclusion
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.