---
title: Generating My Resume PDF with React PDF and Next.js
excerpt: 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.
date: Tue Jun 03 2025 00:00:00 GMT+0000 (Coordinated Universal Time)
tags: [Typescript, Next.js, React, PDF]
---

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.

<Callout title="Why not Google Docs or Canva?" icon="info" variant="info">
  I didn't want to spend time designing a resume and fighting the formatting every time I changed a
  line. With the layout in code, I only had to get it right once.
</Callout>

## 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

<FileTree
  tree={[
    {
      type: "folder",
      name: "content",
      children: [
        { type: "folder", name: "experiences", children: [] },
        { type: "folder", name: "projects", children: [] },
        { type: "folder", name: "education", children: [] },
      ],
    },
    {
      type: "folder",
      name: "src",
      children: [
        {
          type: "folder",
          name: "app",
          children: [
            {
              type: "folder",
              name: "resume",
              children: [
                {
                  type: "folder",
                  name: "_components",
                  children: [
                    {
                      type: "folder",
                      name: "sections",
                      children: [
                        { type: "file", name: "about-me.tsx" },
                        { type: "file", name: "education.tsx" },
                        { type: "file", name: "experience.tsx" },
                        { type: "file", name: "header.tsx" },
                        { type: "file", name: "honors.tsx" },
                        { type: "file", name: "projects.tsx" },
                        { type: "file", name: "technologies.tsx" },
                      ],
                    },
                    { type: "file", name: "resume.tsx" },
                    { type: "file", name: "styles.ts" },
                  ],
                },
                { type: "file", name: "route.tsx" },
              ],
            },
          ],
        },
        {
          type: "folder",
          name: "lib",
          children: [
            { type: "file", name: "constants.ts" },
            { type: "file", name: "content.ts" },
          ],
        },
      ],
    },
  ]}
  defaultOpen
/>

## 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:

```ts filename=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, // [!code highlight]
  experience: allExperiencesByDate, // [!code highlight]
  education: allEducationsByDate, // [!code highlight]
  achievements: allAchievementsByDate,
  certifications: allCertificationsByDate,
};
```

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

```ts filename=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:

```tsx filename=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"; // [!code highlight]

export async function GET() {
  const buffer = await renderToBuffer(<Resume />); // [!code highlight]

  return new NextResponse(Buffer.from(buffer), {
    headers: {
      "Content-Type": "application/pdf", // [!code highlight]
      "Content-Disposition": "inline; filename=resume.pdf", // [!code highlight]
      "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.

<Callout title="Note" icon="circle-check-big" variant="info">
  You can use `Content-Disposition: "attachment"` to force the browser to download the PDF instead of displaying it in the browser.

```ts filename=src/app/resume/route.tsx
  return new NextResponse(Buffer.from(buffer), {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": "attachment; filename=resume.pdf",
      "Content-Length": buffer.length.toString(),
    },
  });
```

  </Callout>

<Callout title="Note" icon="circle-check-big" variant="info">
  If you'd rather name the file `route.ts`, you can't write JSX in it. Call the `Resume` component as a plain function instead:

```ts filename=src/app/resume/route.ts
export async function GET() {
  const buffer = await renderToBuffer(Resume());
  // ... return the response as above
}
```

</Callout>

`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:

```tsx filename=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}> // [!code highlight]
        <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:

```tsx filename=src/app/resume/_components/sections/experience.tsx
export function ExperienceSection({
  experience,
}: {
  experience: Experience[];
}) {
  return (
    <View style={styles.section} bookmark="Experience"> // [!code highlight]
      <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:

```ts filename=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", // [!code highlight]
    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:

- [Resume Generator Route](https://github.com/bismitpanda/portfolio-next/blob/main/src/app/resume/route.tsx)
- [Resume Data](https://github.com/bismitpanda/portfolio-next/blob/main/src/lib/constants.ts)
- [Resume Components](https://github.com/bismitpanda/portfolio-next/tree/main/src/app/resume/_components)
- [Resume Styles](https://github.com/bismitpanda/portfolio-next/blob/main/src/app/resume/_components/styles.ts)

Or browse the entire repository: <GithubLink owner="bismitpanda" repo="portfolio-next" />