Bismit Panda
TypescriptNext.jsReactPDF • June 3rd, 2025 • 7 min read

Generating My Resume PDF with React PDF and Next.js

BP
By Bismit Panda
Generating My Resume PDF with React PDF and Next.js
My resume is a Next.js route that renders a PDF from the same data as this site, so it lives in git and never goes stale.

While building this site, I wanted my resume in version control and always up to date, without keeping a separate document in sync by hand. So the resume is a route on the site. It renders a PDF with React PDF from the same data the site uses.

You'll need to know the Next.js App Router, basic React, and enough TypeScript to read interfaces.

The problem

Resumes are tedious to maintain. You end up with a version per role, formatting breaks when you convert between file types, and keeping them consistent across updates takes effort. I wanted to keep the content as structured data and get the same PDF out every time.

Setup

The site already runs on Next.js, so the resume is just another route. The only new dependency is @react-pdf/renderer, which renders React components to a PDF. TypeScript keeps the data and the components in agreement.

File structure

        The data

        Experience, projects, education, achievements, and certifications are YAML files under content/. Content Collections validates them with Zod schemas and generates typed arrays, and src/lib/content.ts sorts and filters those arrays. The resume uses the same arrays as the rest of the site.

        The things that only exist once, like contact details, the summary, and the skills list, live in a user object:

        src/lib/constants.ts

        export const user = {
          firstName: "Bismit",
          lastName: "Panda",
          location: "India",
          socials: {
            email: {
              label: "contact@bismitpanda.com",
              url: "mailto:contact@bismitpanda.com",
            },
            github: {
              label: "github.com/bismitpanda",
              url: "https://github.com/bismitpanda",
            },
            // ... phone, linkedin, twitter, website
          },
          about: "Co-Founder & CTO of AstraQ Cyber Defence, ...",
          technologies: {
            languages: "Rust, Go, TypeScript, Python, C++, Kotlin",
            // ... frontend, backend, databases, infrastructure, aiMl
          },
          projects: allResumeProjects,
          experience: allExperiencesByDate,
          education: allEducationsByDate,
          achievements: allAchievementsByDate,
          certifications: allCertificationsByDate,
        };

        Not every project belongs on a resume, so allResumeProjects keeps only the ones with a resumeSummary field:

        src/lib/content.ts

        export const allResumeProjects = allProjectsByDate.filter(
          (project) => project.resumeSummary,
        );

        Change a project's YAML file and both the projects page and the resume pick it up.

        PDF generation

        The PDF comes from a route handler at /resume. It renders the React PDF document to a buffer and returns it:

        src/app/resume/route.tsx

        import { renderToBuffer } from "@react-pdf/renderer";
        import { NextResponse } from "next/server";
        import { Resume } from "./_components/resume";
        
        export const dynamic = "force-static";
        
        export async function GET() {
          const buffer = await renderToBuffer(<Resume />);
        
          return new NextResponse(Buffer.from(buffer), {
            headers: {
              "Content-Type": "application/pdf",
              "Content-Disposition": "inline; filename=resume.pdf",
              "Content-Length": buffer.length.toString(),
            },
          });
        }

        force-static tells Next.js to run the handler once at build time and serve the result as a static file. The resume only changes when the content does, and the content only changes with a deploy, so rendering it per request would be wasted work.

        Resume is the React PDF document itself. It lays out each section in order and passes it the relevant slice of user. Achievements and certifications share one "Honors" section to save space:

        src/app/resume/_components/resume.tsx

        export function Resume() {
          return (
            <Document
              author="Bismit Panda"
              subject="Resume"
              title="Bismit Panda's Resume"
            >
              <Page size="LETTER" style={styles.page}>
                <HeaderSection data={user} />
                <AboutMeSection content={user.about} />
                <ExperienceSection experience={user.experience} />
                <TechnologiesSection technologies={user.technologies} />
                <ProjectsSection projects={user.projects} />
                <EducationSection education={user.education} />
                <HonorsSection
                  achievements={user.achievements}
                  certifications={user.certifications}
                />
              </Page>
            </Document>
          );
        }

        Component structure

        Each section is its own component. This is the experience section:

        src/app/resume/_components/sections/experience.tsx

        export function ExperienceSection({
          experience,
        }: {
          experience: Experience[];
        }) {
          return (
            <View style={styles.section} bookmark="Experience">
              <Text style={styles.sectionTitle}>Experience</Text>
              {experience.map((exp) => (
                <View key={`${exp.title}-${exp.company}`} style={styles.entryContainer}>
                  <View style={styles.entryHeader}>
                    <Text style={styles.entryTitle}>
                      {exp.title}, {exp.company}
                    </Text>
                    <Text style={styles.entryDate}>
                      {formatDate(exp.startDate, "MMM yyyy")} -{" "}
                      {exp.endDate ? formatDate(exp.endDate, "MMM yyyy") : "Present"}
                    </Text>
                  </View>
                  <Text style={styles.paragraph}>{exp.description}</Text>
                  <View style={styles.highlightsList}>
                    {exp.highlights.map((highlight) => (
                      <View key={highlight} style={styles.highlight}>
                        <Text style={styles.bullet}>•</Text>
                        <Text style={styles.highlightText}>{highlight.trim()}.</Text>
                      </View>
                    ))}
                  </View>
                </View>
              ))}
            </View>
          );
        }

        The bookmark prop adds an entry to the PDF's outline, so viewers that show a sidebar can jump straight to each section. React PDF supports it at runtime, but its type definitions don't include it on View, so the real file needs a @ts-expect-error above it.

        Styling

        React PDF doesn't use CSS files. You write style objects with StyleSheet.create() and pass them to components, much like React Native.

        The resume is set in Cambria. Each weight and style is a separate font file, registered under one family name so that fontWeight: "bold" and fontStyle: "italic" pick the right file:

        src/app/resume/_components/styles.ts

        import { Font, StyleSheet } from "@react-pdf/renderer";
        
        Font.register({
          family: "Cambria",
          src: "assets/fonts/Cambria.ttf",
          fontStyle: "normal",
          fontWeight: "normal",
        });
        
        Font.register({
          family: "Cambria",
          src: "assets/fonts/Cambria Bold 700.ttf",
          fontStyle: "normal",
          fontWeight: "bold",
        });
        
        // ... italic and bold italic are registered the same way
        
        export const styles = StyleSheet.create({
          page: {
            fontFamily: "Cambria",
            fontSize: 10,
            lineHeight: 1.5,
            paddingTop: 25,
            paddingBottom: 25,
            paddingLeft: 30,
            paddingRight: 30,
            backgroundColor: "#ffffff",
          },
          // ... more styles
        });

        The src paths are relative to the directory the build runs from, which is the project root here.

        Using it

        Edit a YAML file or constants.ts, then open http://localhost:3000/resume. In development the route re-renders on each request, so you see changes straight away. The browser shows the PDF or downloads it, depending on its settings. In production the PDF is rebuilt on every deploy, so it always matches the site.

        What I learned

        Layout took the most time. React PDF uses flexbox, but its layout engine isn't a browser, and some things behave differently. Time spent learning how its flexbox works saved me a lot of debugging later.

        Typed data paid off. Adding a section meant adding a field and a component, and the compiler pointed at everything else that needed to change.

        What's next

        I'd like different layouts for different kinds of jobs, and the typography could use more work. For now it does what I need: the resume lives in git, and I never have to update it separately from the site.

        Code

        The full code is on GitHub:

        Or browse the entire repository: bismitpanda/portfolio-next

        Related Blogs

        Generating Dynamic OpenGraph Images with next/og

        Generating Dynamic OpenGraph Images with next/og

        How I generate OpenGraph images for every page on this site with next/og, including custom fonts, Tailwind-style classes, and build-time generation.