DEV Community

Cover image for How to Build a PDF-Exportable Resume with React, TypeScript, and Next.js
Radzion Chachura
Radzion Chachura

Posted on • Originally published at radzion.com

How to Build a PDF-Exportable Resume with React, TypeScript, and Next.js

🐙 GitHub | 🎮 Demo

Introduction

In this post, we’ll guide you through creating a single-page resume app that can be exported as a PDF. Built with React and TypeScript, this app is designed to help you effectively showcase your professional experience. You can view the final product here and access the source code here. We’ll also include carefully crafted prompts you can use with AI tools like ChatGPT to generate content for your resume and LinkedIn profile. Feel free to fork the project and effortlessly create a sleek, high-quality resume of your own!

Resume

Forking the Starter Kit

To kick off this project, we’ll fork the RadzionKit repository. This starter kit includes all the essential tools and configurations needed to create a NextJS app and deploy it seamlessly to AWS.

Optimizing the Layout and Responsiveness

Our single resume page will be styled within a container that provides padding and a contrasting background to highlight the resume. For smaller screens, we’ll optimize the layout by removing the padding from the container and adjusting the resume’s border radius, aspect ratio, and max-width.

import { centerContent } from "@lib/ui/css/centerContent"
import styled from "styled-components"
import { verticalPadding } from "@lib/ui/css/verticalPadding"
import { getColor } from "@lib/ui/theme/getters"
import { Resume } from "./Resume"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { resumeConfig } from "@lib/resume-ui/components/config"
import { PageMetaTags } from "@lib/next-ui/metadata/PageMetaTags"

const Container = styled.div`
  ${centerContent};
  ${verticalPadding(40)};
  background: ${getColor("foregroundExtra")};

  @media (max-width: ${toSizeUnit(resumeConfig.maxWidth + 40)}) {
    ${verticalPadding(0)};

    > * {
      &:first-child {
        border-radius: 0;
        max-width: initial;
        aspect-ratio: initial;
      }
    }
  }
`

export const ResumePage = () => (
  <Container>
    <Resume />
    <PageMetaTags
      title="Radzion Chachura - Front-End Engineer | Web3 Specialist"
      description="Explore the professional journey of Radzion Chachura, a seasoned front-end engineer specializing in web3. Learn about my experience with Terraform Labs, IOG, Zerion, and personal projects like Increaser and RadzionKit."
    />
  </Container>
)
Enter fullscreen mode Exit fullscreen mode

Configuring SEO with Meta Tags

To enhance SEO, we set meta tags like title and description for our resume page. This is achieved using the PageMetaTags component, which dynamically inserts all the necessary meta tags into the Next.js <Head> component. These tags help search engines better understand the content of your page, improving its visibility and ranking.

import Head from "next/head"

interface PageMetaTags {
  title?: string
  description?: string
  image?: string
  language?: string
}

export const PageMetaTags = ({
  title,
  description,
  image,
  language,
}: PageMetaTags) => (
  <Head>
    {title && (
      <>
        <title>{title}</title>
        <meta name="application-name" content={title} />
        <meta name="apple-mobile-web-app-title" content={title} />
        <meta property="og:title" content={title} />
        <meta name="twitter:title" content={title} />
      </>
    )}

    {description && (
      <>
        <meta name="description" content={description} />
        <meta property="og:description" content={description} />
        <meta name="twitter:description" content={description} />
        <meta property="og:image:alt" content={description} />
        <meta name="twitter:image:alt" content={description} />
      </>
    )}

    {image && (
      <>
        <meta property="og:image" content={image} />
        <meta name="twitter:image:src" content={image} />
      </>
    )}

    {language && <meta httpEquiv="Content-Language" content={language} />}
  </Head>
)
Enter fullscreen mode Exit fullscreen mode

Leveraging AI for Content Generation

Instead of crafting the title and description manually, we’ll leverage AI to generate them for us. This is where the prompts directory in our project comes into play.

The context.md file is the core of the prompts directory. Here, we include detailed information about our professional experience: the job we’re looking for, previous roles, responsibilities, technologies used, and any relevant projects. Providing thorough and specific details helps the AI generate precise and relevant content for the resume.

When using ChatGPT, start by pasting the content of context.md into the prompt field. ChatGPT will acknowledge with a simple "Yes," allowing you to follow up with another prompt. For example, to generate meta tags, you could ask: Write title and description meta tags for my resume page.

To streamline the process of updating the resume, it’s helpful to save all such prompts in a separate file. This way, you can quickly copy and paste them into ChatGPT whenever needed, ensuring consistency and saving time during future updates.

Building the Resume Structure

With the responsive page container ready and meta tags configured for SEO, the next step is the Resume component. This component acts as the main structure of the resume, consisting of a container with four key sections: Header, Jobs, Projects, and Contacts.

import { HStack, VStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"
import { Tag } from "@lib/ui/tags/Tag"
import { useTheme } from "styled-components"
import { ComponentProps, useRef } from "react"
import { ResumeContainer } from "@lib/resume-ui/components/ResumeContainer"
import { DownloadResume } from "@lib/resume-ui/components/DownloadResume"
import { differenceInYears } from "date-fns"
import { useRhythmicRerender } from "@lib/ui/hooks/useRhythmicRerender"
import { convertDuration } from "@lib/utils/time/convertDuration"
import { firstJobStartedAt } from "@product/config"
import { Jobs } from "./jobs/Jobs"
import { Projects } from "./projects/Projects"
import { Contacts } from "./Contacts"

export const Resume = (props: ComponentProps<typeof ResumeContainer>) => {
  const { colors } = useTheme()
  const containerElement = useRef<HTMLDivElement>(null)

  const now = useRhythmicRerender(convertDuration(10, "min", "ms"))

  return (
    <ResumeContainer {...props} ref={containerElement}>
      <HStack alignItems="start" justifyContent="space-between">
        <VStack gap={8}>
          <HStack wrap="wrap" alignItems="center" gap={8}>
            <Text size={20} color="contrast" weight="600">
              Radzion
            </Text>
            <Tag $color={colors.getLabelColor(5)}>
              {differenceInYears(now, firstJobStartedAt)} years of experience
            </Tag>
            <Tag $color={colors.getLabelColor(10)}>CS Degree</Tag>
          </HStack>
          <HStack>
            <Text centerVertically={{ gap: 6 }} color="contrast" weight="500">
              <span>💪</span>
              <span>React, TypeScript, UX/UI, Web3</span>
              <Text as="span" color="supporting">
                + AWS, Node.js, DynamoDB
              </Text>
            </Text>
          </HStack>
        </VStack>
        <DownloadResume render={() => containerElement.current} />
      </HStack>
      <Jobs />
      <Projects />
      <Contacts />
    </ResumeContainer>
  )
}
Enter fullscreen mode Exit fullscreen mode

To make the resume page resemble a single PDF sheet, we set an aspect-ratio and a fixed max-width to ensure it fits within standard dimensions. The container has no padding, allowing section dividers to span the full width. Padding is applied directly to child elements for consistent spacing. For printing, we remove the border and border-radius so the content fills the entire page.

import { borderRadius } from "@lib/ui/css/borderRadius"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { getColor } from "@lib/ui/theme/getters"
import styled from "styled-components"
import { resumeConfig } from "./config"
import { vStack } from "@lib/ui/css/stack"
import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { verticalPadding } from "@lib/ui/css/verticalPadding"

export const ResumeContainer = styled.div`
  max-width: ${toSizeUnit(resumeConfig.maxWidth)};
  width: 100%;
  aspect-ratio: 1 / 1.414;

  line-height: 1.5;

  ${vStack()}

  background: ${getColor("background")};
  ${borderRadius.m}
  overflow: hidden;

  @media print {
    width: 100%;
    height: 100%;
    border-radius: 0;
  }

  > * {
    ${horizontalPadding(resumeConfig.padding)}
    ${verticalPadding(resumeConfig.padding * 2)}

    &:first-child {
      padding-top: ${toSizeUnit(resumeConfig.padding)};
    }

    &:nth-last-child(2) {
      padding-bottom: ${toSizeUnit(resumeConfig.padding)};
    }

    &:last-child {
      ${verticalPadding(resumeConfig.padding)};
    }
  }
`
Enter fullscreen mode Exit fullscreen mode

Highlighting the Header and Skills

The header highlights the most important information. It starts with the name and two tags: one showing years of experience and the other indicating a CS degree. The CS degree is included here instead of a dedicated section, as it’s not a standout detail worth emphasizing separately. Below the name, there’s a line listing technologies and skills. Primary skills are styled with a contrasting color, while secondary skills are shown in a supporting color followed by a plus sign.

Enabling PDF Downloads

To enable printing the resume, we keep a reference to the resume component and pass it to the DownloadResume component, which uses the react-to-print library to convert the content into a PDF when the download button is clicked. The DownloadResume button is styled to remain hidden during printing using @media print, ensuring it doesn’t appear on the printed resume.

import styled from "styled-components"
import ReactToPrint from "react-to-print"
import { DownloadIcon } from "@lib/ui/icons/DonwloadIcon"
import { Button } from "@lib/ui/buttons/Button"
import { HStack } from "@lib/ui/css/stack"
import { Text } from "@lib/ui/text"

type DownloadResumeProps = {
  render: () => React.ReactInstance | null
}

const Container = styled(Button)`
  @media print {
    display: none;
  }

  svg {
    font-size: 16px;
  }
`

export const DownloadResume = ({ render }: DownloadResumeProps) => (
  <ReactToPrint
    trigger={() => (
      <Container size="s" kind="primary">
        <HStack alignItems="center" gap={8}>
          <DownloadIcon />
          <Text>Download</Text>
        </HStack>
      </Container>
    )}
    content={render}
  />
)
Enter fullscreen mode Exit fullscreen mode

Showcasing Job Experiences

The Jobs section comes next, listing full-time job experiences. Since my current focus is on web3 and crypto roles, I’ve split this section into two parts: one highlighting my work at web3 companies and the other covering my previous full-stack experience.

import { JobExperience } from "@lib/resume-ui/components/JobExperience"
import { ResumeSection } from "@lib/resume-ui/components/ResumeSection"
import { VStack } from "@lib/ui/css/stack"
import { IogIcon } from "./IogIcon"
import { TflIcon } from "./TflIcon"
import { ZerionIcon } from "./ZerionIcon"

import { firstJobStartedAt } from "@product/config"
import { KreoIcon } from "./KreoIcon"
import { KontistIcon } from "./KontistIcon"
import { ProductLink } from "@lib/resume-ui/components/ProductLink"
import { Tech } from "@lib/resume-ui/components/Tech"

export const Jobs = () => (
  <>
    <ResumeSection title={<>Web3</>}>
      <JobExperience
        position="Front-end Engineer"
        company="Terraform Labs"
        startedAt={new Date(2022, 2)}
        finishedAt={new Date(2024, 7)}
        companyIcon={<TflIcon />}
        responsibilities={[
          <>
            Developed and maintained features for{" "}
            <ProductLink to="https://app.anchorprotocol.com/">
              Anchor Protocol
            </ProductLink>{" "}
            using <Tech>React</Tech> and <Tech>TypeScript</Tech>.
          </>,
          <>
            Built front-ends for two NFT protocols on Terra with{" "}
            <Tech>Next.js</Tech>.
          </>,
          <>
            Led the front-end and back-end development of{" "}
            <ProductLink to="https://enterprise.money/">
              Enterprise Protocol
            </ProductLink>
            , supporting DAOs across Cosmos chains.
          </>,
          <>
            Implemented a TypeScript monorepo with <Tech>React</Tech> and{" "}
            <Tech>Node.js</Tech>, integrating DynamoDB.
          </>,
        ]}
      />
      <VStack gap={20}>
        <JobExperience
          position="Front-end Engineer"
          company="IOG"
          finishedAt={new Date(2022, 1)}
          startedAt={new Date(2021, 8)}
          companyIcon={<IogIcon />}
          responsibilities={[
            <>
              Built the NFT gallery and transaction history for{" "}
              <ProductLink to="https://www.lace.io/">Lace</ProductLink> using{" "}
              <Tech>React</Tech> and <Tech>TypeScript</Tech>.
            </>,
            <>
              Enhanced wallet functionalities and general UX/UI improvements.
            </>,
          ]}
        />
        <JobExperience
          position="Front-end Engineer"
          company="Zerion"
          finishedAt={new Date(2021, 7)}
          startedAt={new Date(2020, 9)}
          companyIcon={<ZerionIcon />}
          responsibilities={[
            <>
              Improved wallet management and token exchange for{" "}
              <ProductLink to="https://app.zerion.io/">Zerion</ProductLink>.
            </>,
            <>
              Integrated staking features and internationalization support with{" "}
              <Tech>React</Tech>.
            </>,
          ]}
        />
      </VStack>
    </ResumeSection>
    <ResumeSection title={"Full-Stack"}>
      <JobExperience
        companyIcon={<KontistIcon />}
        position="Senior Software Developer"
        company="Kontist"
        startedAt={new Date(2019, 6)}
        finishedAt={new Date(2020, 8)}
        responsibilities={[
          <>
            Improved onboarding and account creation using <Tech>React</Tech>{" "}
            and <Tech>React Native</Tech>.
          </>,
          <>
            Developed a web app for the product with <Tech>TypeScript</Tech>.
          </>,
          <>
            Built and maintained back-end features using <Tech>Node.js</Tech>{" "}
            and PostgreSQL.
          </>,
        ]}
      />
      <JobExperience
        position="Software Developer"
        company="KREO"
        companyIcon={<KreoIcon />}
        startedAt={firstJobStartedAt}
        finishedAt={new Date(2019, 5)}
        responsibilities={[
          <>
            Led development of a web app for construction software with{" "}
            <Tech>React</Tech>.
          </>,
          <>Implemented a floor plan editor and site management tools.</>,
          <>
            Managed infrastructure with <Tech>AWS</Tech> and{" "}
            <Tech>Terraform</Tech>.
          </>,
        ]}
      />
    </ResumeSection>
  </>
)
Enter fullscreen mode Exit fullscreen mode

The ResumeSection component separates sections with a line, positioning the title in the center using position: absolute and centerContent. It organizes the content in a two-column layout with the UniformColumnGrid component.

import { centerContent } from "@lib/ui/css/centerContent"
import { horizontalPadding } from "@lib/ui/css/horizontalPadding"
import { round } from "@lib/ui/css/round"
import { vStack } from "@lib/ui/css/stack"
import { UniformColumnGrid } from "@lib/ui/css/uniformColumnGrid"
import { ComponentWithChildrenProps, UIComponentProps } from "@lib/ui/props"
import { text } from "@lib/ui/text"
import { getColor } from "@lib/ui/theme/getters"
import { ReactNode } from "react"
import styled from "styled-components"

type ResumeSectionProps = ComponentWithChildrenProps &
  UIComponentProps & {
    title: ReactNode
  }

const Container = styled.div`
  ${vStack({
    gap: 20,
  })}
  position: relative;
`

const TitleContainer = styled.div`
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 1px;
  background: ${getColor("foregroundExtra")};
  ${centerContent};
`

const Title = styled.div`
  height: 32px;
  ${horizontalPadding(20)};

  border: 1px solid ${getColor("foregroundExtra")};
  background: ${getColor("background")};
  ${text({
    color: "primary",
    weight: 600,
  })}
  ${centerContent};
  ${round};
`

export const ResumeSection = ({
  title,
  children,
  ...rest
}: ResumeSectionProps) => (
  <Container {...rest}>
    <TitleContainer>
      <Title>{title}</Title>
    </TitleContainer>

    <UniformColumnGrid minChildrenWidth={240} maxColumns={2} gap={40}>
      {children}
    </UniformColumnGrid>
  </Container>
)
Enter fullscreen mode Exit fullscreen mode

Defining Job Experience Properties

The JobExperience component takes in a set of properties that together shape how each professional role is presented. Here’s what each prop represents:

  • position: The specific role you held at the company.
  • company: The organization where you worked.
  • companyIcon: An optional icon or symbol representing the company.
  • startedAt: The start date of your employment.
  • finishedAt: The end date of your employment, if applicable.
  • responsibilities: An array listing the duties, achievements, or contributions you made in the role.
import { HStack, VStack } from "@lib/ui/css/stack"
import { dotSeparator } from "@lib/ui/layout/StackSeparatedBy"
import { Text } from "@lib/ui/text"
import { ReactNode } from "react"
import { intervalToDuration } from "date-fns"

type JobExperienceProps = {
  position: string
  company: string
  companyIcon?: ReactNode
  startedAt: Date
  finishedAt?: Date
  responsibilities: ReactNode[]
}

const { format: formatDate } = new Intl.DateTimeFormat("en-US", {
  month: "short",
  year: "numeric",
})

function formatExperienceDuration(startedAt: Date, finishedAt?: Date): string {
  const endDate = finishedAt ?? new Date()
  const { years = 0, months = 0 } = intervalToDuration({
    start: startedAt,
    end: endDate,
  })
  const parts = []

  if (years) {
    parts.push(`${years} ${years === 1 ? "year" : "years"}`)
  }

  if (months) {
    parts.push(`${months} ${months === 1 ? "month" : "months"}`)
  }

  if (parts.length === 0) {
    parts.push("Less than a month")
  }

  return parts.join(" ")
}

export const JobExperience = ({
  position,
  company,
  responsibilities,
  startedAt,
  finishedAt,
  companyIcon,
}: JobExperienceProps) => {
  return (
    <VStack gap={8}>
      <Text centerVertically={{ gap: 4 }} color="primary" size={16}>
        <span>{position}</span>
        <Text as="span" color="shy">
          at
        </Text>
        <Text nowrap centerVertically={{ gap: 8 }} as="span">
          {company}
          {companyIcon}
        </Text>
      </Text>

      <HStack alignItems="center" gap={8}>
        <Text weight="500" size={14}>
          {formatExperienceDuration(startedAt, finishedAt)}
        </Text>
        <Text size={14} color="supporting">
          ({formatDate(startedAt)} -{" "}
          {finishedAt ? formatDate(finishedAt) : "Present"})
        </Text>
      </HStack>
      <VStack gap={8}>
        {responsibilities.map((responsibility, index) => (
          <HStack key={index} gap={4}>
            <Text color="shy">{dotSeparator}</Text>
            <Text color="supporting">{responsibility}</Text>
          </HStack>
        ))}
      </VStack>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

At the top, the component highlights the position and the company’s name, optionally displaying the company’s icon if available. Just beneath this header, the employment duration is shown in years and months, followed by the precise start and end dates. Each responsibility is then presented as a bullet point, separated by a dot, ensuring clear and easy-to-scan content.

Using AI Prompts for Responsibilities

To populate the responsibilities prop for each job, use a dedicated prompt that directs the AI to incorporate the Tech component for highlighting technologies and the ProductLink component for linking to previously mentioned projects.

Fill the `responsibilities` prop for each position in my resume with concise, impactful bullet points—no more than four per role. Reduce the number of points if I had fewer contributions in that position due to shorter tenure.

When listing responsibilities, you may reference projects using the `ProductLink` component, but only if the project’s URL has been mentioned previously. The `ProductLink` component takes a `to` prop for the URL. To highlight a technology, use the `Tech` component. The `responsibilities` prop expects an array of `React.Node`. Do not repeat the same technology within a single job. You do not need to highlight every technology used in that role; use your best judgment.
Enter fullscreen mode Exit fullscreen mode

Featuring Projects and Achievements

After the job experience section, we move on to the Projects section, which includes three items: my productivity app, Increaser; a full-stack development toolkit, RadzionKit; and my YouTube channel, Radzion Dev.

import { PersonalProject } from "@lib/resume-ui/components/PersonalProject"
import { ResumeSection } from "@lib/resume-ui/components/ResumeSection"
import { GithubRepoResumeItem } from "@lib/resume-ui/github/components/GithubRepoResumeItem"
import { VStack } from "@lib/ui/css/stack"
import { IncreaserIcon } from "./IncreaserIcon"
import { useApiQuery } from "@product/api-ui/hooks/useApiQuery"
import { ProjectPrimaryStat } from "@lib/resume-ui/components/ProjectPrimaryStat"
import { YouTubeColoredIcon } from "@lib/ui/icons/YouTubeColoredIcon"
import { MatchQuery } from "@lib/ui/query/components/MatchQuery"
import { Tech } from "@lib/resume-ui/components/Tech"

export const Projects = () => {
  const query = useApiQuery("stats", undefined)

  return (
    <ResumeSection style={{ flex: 1 }} title={"My Projects"}>
      <PersonalProject
        icon={<IncreaserIcon />}
        name="Increaser"
        url="https://increaser.org"
        description={
          <>
            A productivity toolkit built with <Tech>TypeScript</Tech> and{" "}
            <Tech>React</Tech>, featuring tools for planning, time tracking, and
            habits. Deployed on <Tech>AWS</Tech> using <Tech>Terraform</Tech>.
          </>
        }
      />
      <VStack gap={20}>
        <GithubRepoResumeItem
          value={{
            owner: "radzionc",
            name: "radzionkit",
          }}
          name="RadzionKit"
          description={
            <>
              A full-stack development toolkit with reusable components, hooks,
              and utilities for <Tech>React</Tech>,<Tech>Node.js</Tech>, and{" "}
              <Tech>DynamoDB</Tech>. Built as a <Tech>TypeScript</Tech>{" "}
              monorepo.
            </>
          }
        />
        <PersonalProject
          icon={<YouTubeColoredIcon />}
          name="Radzion Dev"
          url="https://www.youtube.com/c/radzion"
          description={
            <>
              A YouTube channel covering <Tech>React</Tech>,{" "}
              <Tech>TypeScript</Tech>, and <Tech>AWS</Tech>, with practical
              content for web developers.
            </>
          }
          primaryStat={
            <MatchQuery
              value={query}
              success={({ devChannelSubscribers }) => (
                <ProjectPrimaryStat>
                  {devChannelSubscribers} subscribers
                </ProjectPrimaryStat>
              )}
            />
          }
        />
      </VStack>
    </ResumeSection>
  )
}
Enter fullscreen mode Exit fullscreen mode

Each personal project is represented by the PersonalProject component, which is responsible for displaying the project’s key details in a structured and engaging format. Here are the props it accepts:

  • name: The project’s title, prominently displayed.
  • url: A link to the live project or its repository.
  • description: A brief summary of the project’s purpose and features.
  • primaryStat: A highlight or key metric that provides additional insight into the project’s impact or performance.
  • icon: An optional visual element, such as a logo or icon, to help brand or differentiate the project.
import { ExternalLink } from "@lib/ui/navigation/Link/ExternalLink"
import { HStack, VStack } from "@lib/ui/css/stack"
import { text, Text } from "@lib/ui/text"
import styled from "styled-components"
import { getColor } from "@lib/ui/theme/getters"
import { ReactNode } from "react"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { resumeConfig } from "./config"

interface Props {
  name: string
  url: string
  description: ReactNode
  primaryStat?: ReactNode
  icon?: ReactNode
}

const Title = styled.p`
  border-bottom: 1px dashed;
  ${text({
    centerVertically: true,
    color: "contrast",
    weight: 600,
  })}
  height: ${toSizeUnit(resumeConfig.personalProjectTitleHeight)};
`

const Link = styled(ExternalLink)`
  &:hover ${Title} {
    color: ${getColor("textPrimary")};
  }
`

export const PersonalProject = ({
  name,
  url,
  description,
  primaryStat,
  icon,
}: Props) => {
  return (
    <VStack alignItems="start" gap={4}>
      <HStack alignItems="center" gap={16}>
        <Link to={url}>
          <HStack alignItems="center" gap={8}>
            {icon}
            <Title>{name}</Title>
          </HStack>
        </Link>
        {primaryStat}
      </HStack>
      <Text color="supporting">{description}</Text>
    </VStack>
  )
}
Enter fullscreen mode Exit fullscreen mode

Here, we also use an AI prompt to generate the description for each project.

Fill the `description` prop for each project in my resume. Keep the descriptions short and effective. To highlight a technology, use the `Tech` component. The `description` prop accepts a `React.Node`.
Enter fullscreen mode Exit fullscreen mode

Adding Dynamic Statistics

To make the resume more engaging, consider adding a dynamic primary statistic—such as the number of GitHub stars a project has. This is achieved through the useGithubRepoQuery hook, which fetches the star count directly from the public GitHub API without any need for authentication. Incorporating a live metric like this can lend authenticity and real-time relevance to your resume, helping you stand out from the crowd.

import { useQuery } from "@tanstack/react-query"
import { GithubRepo } from "../GithubRepo"
import { queryUrl } from "@lib/utils/query/queryUrl"

interface GitHubRepoResponse {
  stargazers_count: number
}

export function useGithubRepoQuery({ owner, name }: GithubRepo) {
  return useQuery({
    queryKey: ["repo", owner, name],
    queryFn: () =>
      queryUrl<GitHubRepoResponse>(
        `https://api.github.com/repos/${owner}/${name}`
      ),
  })
}
Enter fullscreen mode Exit fullscreen mode

Incorporating YouTube Subscriber Counts

When it comes to YouTube, directly querying subscriber counts from the front-end isn’t as straightforward. To handle this, we’ve set up a simple API endpoint that returns the current number of subscribers for the Radzion Dev channel. We won’t dive into the technical details of creating this endpoint here—if you’re interested, check out my dedicated video and accompanying guide on how to easily implement an API within a TypeScript repository here.

import { assertFetchResponse } from "@lib/utils/fetch/assertFetchResponse"
import { getEnvVar } from "../../getEnvVar"
import { ApiResolver } from "../../resolvers/ApiResolver"
import { addQueryParams } from "@lib/utils/query/addQueryParams"

const youTubeChannelId = "UC15iv69GgcJWa8GoohNQzpw"

export const stats: ApiResolver<"stats"> = async () => {
  const url = addQueryParams("https://www.googleapis.com/youtube/v3/channels", {
    part: "statistics",
    id: youTubeChannelId,
    key: getEnvVar("YOUTUBE_API_KEY"),
  })

  const response = await fetch(url)

  await assertFetchResponse(response)

  const data = await response.json()
  if (!data.items || data.items.length === 0) {
    throw new Error(`No statistics found for channelId: ${youTubeChannelId}`)
  }

  const subscriberCount = parseInt(data.items[0].statistics.subscriberCount, 10)

  return {
    devChannelSubscribers: subscriberCount,
  }
}
Enter fullscreen mode Exit fullscreen mode

Presenting Contact Information

Finally, we display the contacts in the footer using ResumeFooterLink components. Each link includes an icon, the contact's name, and a URL.

import { ResumeFooterLink } from "@lib/resume-ui/components/ResumeFooterLink"
import { HStack } from "@lib/ui/css/stack"
import { GitHubIcon } from "@lib/ui/icons/GitHubIcon"
import { LinkedinIcon } from "@lib/ui/icons/LinkedinIcon"
import { MailIcon } from "@lib/ui/icons/MailIcon"
import { TelegramIcon } from "@lib/ui/icons/TelegramIcon"
import { XIcon } from "@lib/ui/icons/XIcon"
import {
  email,
  xHandle,
  linkedInHandle,
  githubHandle,
  telegramHandle,
} from "@product/config"

export const Contacts = () => (
  <HStack
    fullWidth
    justifyContent="space-between"
    alignItems="center"
    wrap="wrap"
    gap={16}
  >
    <ResumeFooterLink
      icon={<MailIcon />}
      name={email}
      url={`mailto:${email}`}
    />
    <ResumeFooterLink
      icon={<XIcon />}
      name={xHandle}
      url={`https://twitter.com/${xHandle}`}
    />
    <ResumeFooterLink
      icon={<LinkedinIcon />}
      name={linkedInHandle}
      url={`https://www.linkedin.com/in/${linkedInHandle}`}
    />
    <ResumeFooterLink
      icon={<GitHubIcon />}
      name={githubHandle}
      url={`https://github.com/${githubHandle}`}
    />
    <ResumeFooterLink
      icon={<TelegramIcon />}
      name={telegramHandle}
      url={`https://t.me/${telegramHandle}`}
    />
  </HStack>
)
Enter fullscreen mode Exit fullscreen mode

Deploying to AWS

Our website is just a single static page, so we will deploy it to AWS S3 and serve it through CloudFront. To setup most of the infrastructure we'll use Terraform, we also won't get into the details about deploying the website here, since there is a dedicated post about it here.

#!/bin/zsh -e

# Required environment variables:
# - BUCKET: S3 bucket name
# - DISTRIBUTION_ID: CloudFront distribution ID

yarn build

OUT_DIR=out

aws s3 sync $OUT_DIR s3://$BUCKET/ \
  --delete \
  --exclude $OUT_DIR/sw.js \
  --exclude "*.html" \
  --metadata-directive REPLACE \
  --cache-control max-age=31536000,public \
  --acl public-read

aws s3 cp $OUT_DIR s3://$BUCKET/ \
  --exclude "*" \
  --include "*.html" \
  --include "$OUT_DIR/sw.js" \
  --metadata-directive REPLACE \
  --cache-control max-age=0,no-cache,no-store,must-revalidate \
  --acl public-read \
  --recursive

process_html_file() {
  file_path="$1"
  relative_path="${file_path#$OUT_DIR/}"
  file_name="${relative_path%.html}"

  aws s3 cp s3://$BUCKET/$file_name.html s3://$BUCKET/$file_name &
}

find $OUT_DIR -type f -name "*.html" | while read -r html_file; do
  process_html_file "$html_file"
done

wait # Wait for all background jobs to complete

aws configure set preview.cloudfront true
aws cloudfront create-invalidation --distribution-id $DISTRIBUTION_ID --paths "/*"

Enter fullscreen mode Exit fullscreen mode

Conclusion

In this guide, we’ve explored how to build a single-page, PDF-exportable resume using React, TypeScript, and Next.js. We covered setting meta tags for better SEO, enhancing presentation with dynamic stats, and deploying to AWS. With a solid foundation and helpful prompts, you now have the tools to effortlessly create and maintain a professional, standout resume.

Top comments (0)