How do you store and update your professional CV/resume?

As a budding Graphic Designer looking for my first job, my first Resume was created in InDesign. Over the 14 years in that first job I had been given offers and took a few meetings, but never got to the point of passing along my resume as part of a job application (other than a quick stint as an adjunct professor).

When an opportunity came along and I finally did need to update my resume, my old version of InDesign was no longer on my computer and it made no sense to me to start a new Adobe subscription just to update it. I wound up using Pages on my Mac to re-create it, pulling the content off of a previously saved PDF export.

I recently went to update me resume again and that got me thinking that maybe I should make it easier to update it and to get its source off of any platform where it might drastically change by the time I need to make another update (who knows, maybe Pages will someday just become a prompt field for Apple Intelligence on Vision Pro). I use my personal website, wbrowar.com, for my blog and a few other things, so I decided to re-create my resume once more and host it in my CMS.

I’ve hired several people over the years and have seen maybe hundreds of resumes at this point. Maybe it’s just me, but I think the best resume designs are those that are simple and easy to read above all else. A clever layout that makes the hiring person decipher anything is a point of friction that you don’t want when your resume is your first impression.

Adding some personality is okay, but make sure the resume is clear and easy to read first.

For my resume, I stick with this general format: name, current title, contact info and then sections of content (past employment, education, skills, work experience/portfolio). Sometimes a cover letter or references get mixed in, too.

Here’s what a kitchen sink template of my HTML-based resume could look like, including a cover letter at the top.

Resume template

The two-column thing goes back to my original print layout. On smaller screens this collapses to one-column.

CMS Setup

My approach to content managing this document is closely based on the template design. If, for example, you wanted to go with a one-column layout, you might use different fields instead or you might change the order.

Fields

I like to start off with breaking down my template into fields first. Most of my resume will be written in Markdown, so multi-line text fields will be used in several places. I plan on using matrix fields for some content that has a repeatable format. There are also a handful of one-off fields that need to be displayed using specific HTML tags. Let’s start with these one-off fields.

Page settings field

Being a developer and the only person updating this content, I stuck all of the one-off fields into a JSON object and used Craft CMS’s new JSON field to define an arbitrary JSON structure. I broke the fields out based on how I planned on rendering them in the template, so the address was set up as an array where each item is a line of text. In my template I will check for each key in this object and if a part is missing I’ll omit its HTML from the page.

To keep track of all of the potential keys in this object, I used the Guide plugin to display an example of all of the items in this object that I’ll use in my front-end template and I added as the default guide for a UI Element next to my JSON field.

The code for this Guide looks like this:

Guide "Content" field
{% set settings = {
    header: {
        address: [
            '123 Example Ave.',
            'ExampleTowne, NY 12345',
        ],
        email: 'myemail@example.com',
        phone: '123.456.7890',
        subheader: "Web Developer",
        website: "https://example.com",
    }
} %}

<h3>Page Settings Default</h3>

<code><pre>{{ settings|json_encode(constant('JSON_PRETTY_PRINT')) }}</pre></code>
Guide "CSS" field
[data-guide-slug="resume-settings"] {
    code {
        padding: 0;
    }
    pre {
        font-size: .8rem;
        white-space: pre-wrap;
    }
}

I then created two multi-line text fields and used them for an "Objective" field and "Cover" field to make up the cover letter section. I made a few more fields to use as pieces for two matrix fields.

Entry Types

The first matrix field would be used for an arbitrary set of two-column sections. I treated this like I would a print document in that I want to be able to control which line carries over from one column to the next—instead of leaving this up to CSS to decide. So I created an entry type, called Resume Section and populated it with a field for a subhead for the section, then two multi-line text fields for the columns.

Resume matrix field

The next matrix block was specific to the portfolio section and I wanted that to follow a specific format. I created another entry type, called Resume Work Experience and added the following fields to it: a field for the name of the project, optional fields for a URL or a thumbnail image, and a multi-line text field for the project description.

Work experience matrix field

I took all of these fields and put them together in an entry type, called Resume.

Resume entry type field layout

In some cases I used Craft CMS’s field instance feature to reduce creating extra fields in my site.

Sections

When sending over a resume, I typically will make changes based on the organization I’m sending it to. The cover letter is written specific to the opportunity and I might change things like which portfolio items are included based on what work is most relevant for the opportunity.

So instead of creating a Single section, I created a new Channel and called it Career. This way I can have several variations of my resume available and hosted at different URLs.

Front-End HTML and CSS

I wanted my resume to look like its own standalone document, so I created a new page template that doesn't include my site’s header, footer, and background elements. While some of the site’s CSS is reused here, I wound up writing specific CSS for the HTML elements included in my resume.

I wanted my resume to be able to be served up in three different ways.

  • If someone wants to see my resume online I can send them a URL.
  • If a paper copy is required, I want to be able to print the resume and keep things like URLs visible so they can be read off the document.
  • Along the same lines, if a PDF is required I want to be able to make it easy to generate one from the site’s HTML—using the system print dialog’s export to PDF feature.

Routing

The URI pattern for the Career section of my site starts with career/{slug} and this is set in the Career section’s settings page. When you go to something like https://wbrowar.com/career/some-company you would be brought to a page that displays the entire resume.

There may be times where someone might ask for a URL for just the cover letter or just the resume so I also set up a custom route to handle that. I broke up the sections of the resume in to parts and named each one:

  • cover – The cover letter portion.
  • cv – The contents of the Resume matrix field.
  • objective – The first part of the cover letter.
  • resume – Another name for the Resume Section.
  • work – The contents of the Work Experience matrix field.

So the idea is that if I sent out the URL, https://wbrowar.com/career/some-company/resume, that would only show the sections of the resume as populated by the Resume matrix field.

In addition to showing one section, I also added support to allow multiple sections to be shown if the third URI segment is formatted in kebab-case. So the URL, https://wbrowar.com/career/some-company/resume-work, would show both the Resume and Work Experience but would not show the cover letter.

In order to set this up, I added a custom route to my config/routes.php file.

<?php

return [
    'career/<slug:{slug}>/<part:([\w-]*)>' => ['template' => 'pages/career.twig'],
];

The regex for the part variable accepts letters and - characters used in kebab-case.

Twig Templates

I have a general Twig layout that I use for pages that are content managed and don’t use the default page layout for my site:

templates/layouts/blank-layout.twig
<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/>

    {% set cssAsset = siteAsset('css') %}
    {% if cssAsset ?? false %}
      <link rel="stylesheet" href="{{ cssAsset }}"/>
    {% endif %}
  </head>
  <body class="[ blank-layout : body ] ltr">
    {{ craft.blitz.includeDynamic('includes/admin-bar.twig', { entryUri: entry.uri ?? '' }) }}

    {% block content %}
    {% endblock %}

    {% css adminBarCssFile({ contents: true }) %}
    {% css adminBarOnPageCss() %}
    {% js adminBarJsFile() with { defer: true, type: 'module' } %}

    <script type="module" src="{{ siteAsset('js') }}"></script>
  </body>
</html>

The Blitz plugin dynamically loads an Admin Bar onto the page when I'm logged into the site. The siteAsset() method is part of a custom module that loads my site’s CSS and JS files.

In the settings for the Career channel and for my custom route in the config/routes.php file, I point all of the career pages to a template at templates/pages/career.twig:

templates/pages/career.twig
{% extends 'layouts/blank-layout.twig' %}

{% import _self as self %}

{# If this route contains a `slug` segment, use the slug to get the page entry. #}
{% if slug ?? false %}
  {% set entry = craft.entries.section('career').slug(slug).one() %}
{% endif %}

{# Get page settings from JSON field. #}
{% set pageSettings = entry.pageSettings %}

{# Set all of the parts shown in this page template. #}
{% set parts = ['cover', 'cv', 'objective', 'resume', 'work'] %}

{# if this route contains a `part` segment, reduce the list to parts specified in the kebab-bases segment. #}
{% if part ?? false %}
  {# Break kekbab string into an array. For example `cv-work` would be turned into `['cv', 'work']`. #}
  {% set partList = part|split('-') %}
  {# Create a new variable to hold all available parts. #}
  {% set allParts = parts %}
  {# Reset the original list of parts so we can re-add each part back in based on the `partList` items. #}
  {% set parts = [] %}

  {% for partItem in partList %}
    {% if partItem in allParts %}
      {% set parts = parts|merge([partItem]) %}
    {% endif %}
  {% endfor %}
{% endif %}

{% block content %}
  <article class="[ page : career ]">
    <header class="[ header ]">
      <h1>Will Browar</h1>

      {% if pageSettings.header.subheader ?? false %}
        <p>{{ pageSettings.header.subheader }}</p>
      {% endif %}

      {% if pageSettings.header.email ?? false
        or pageSettings.header.phone ?? false
        or pageSettings.header.address ?? false
        or pageSettings.header.website ?? false %}
        <address>
          {% if pageSettings.header.email ?? false %}
            <a href="mailto:{{ pageSettings.header.email }}">{{ pageSettings.header.email }}</a>
          {% endif %}

          {% if pageSettings.header.phone ?? false %}
            <a href="tel:+1{{ pageSettings.header.phone|replace('.', '')|replace('/D+/', '-') }}">{{ pageSettings.header.phone }}</a>
          {% endif %}

          {% if pageSettings.header.address ?? false and pageSettings.header.address|length %}
            <span>
              {% for line in pageSettings.header.address %}
                <span>{{ line }}</span>
              {% endfor %}
            </span>
          {% endif %}

          {% if pageSettings.header.website ?? false %}
            <a href="{{ pageSettings.header.website }}">{{ pageSettings.header.website }}</a>
          {% endif %}
        </address>
      {% endif %}
    </header>

    <main class="[ main ]">
      {% if 'objective' in parts or 'cover' in parts and (entry.objective ?? false or entry.cover ?? false) %}
        <section class="[ letter ]">
          {% if entry.objective ?? false %}
            <div class="[ objective ]">
              {{ entry.objective|md('gfm') }}
            </div>
          {% endif %}

          {% if entry.cover ?? false %}
            <div class="[ cover ]">
              {{ entry.cover|md('gfm') }}
            </div>
          {% endif %}
        </section>
      {% endif %}

      <section class="[ resume ]">
        {% if 'cv' in parts or 'resume' in parts and entry.resume|length %}
          {% for block in entry.resume.all() %}
            <div class="[ resume-section ]">
              <h2>{{ block.subheader|nl2br }}</h2>
              <div>{{ block.text|md('gfm') }}</div>
              {% if block.text2 %}
                <div>{{ block.text2|md('gfm') }}</div>
              {% endif %}
            </div>
          {% endfor %}
        {% endif %}

        {% if 'work' in parts and entry.workExperience|length %}
          {% set workExperienceHalfTotal = (entry.workExperience|length / 2)|round(0, 'ceil') %}
          <div class="[ resume-section ]">
            <h2>Work<br>Experience</h2>
            <div>
              {% for block in entry.workExperience.limit(workExperienceHalfTotal).all() %}
                {{ self.workExperience(block) }}
              {% endfor %}
            </div>
            <div>
              {% for block in entry.workExperience.offset(workExperienceHalfTotal).all() %}
                {{ self.workExperience(block) }}
              {% endfor %}
            </div>
          </div>
        {% endif %}
      </section>
    </main>
  </article>
{% endblock %}

{% macro workExperience(block) %}
  <div class="project">
    <h3>{{ block.title }}</h3>

    {% if block.basicLink.url ?? false %}
      <a href="{{ block.basicLink.url }}">{{ block.basicLink.label }}</a>
    {% endif %}

    {% if block.text ?? false %}
      {{ block.text|md('gfm') }}
    {% endif %}
  </div>
{% endmacro %}

The first part of this template makes sure we are getting content from the right entry. Then it parses the route to figure out which sections need to be shown on the page. The content block uses checks to display all of the content on the page. Finally, there is a macro used to keep the work section of the template DRY.

CSS

The CSS file for my site starts off with some global tokens for things like colors, spacing, and font families. Some of that will get inherited on this page, but most of the other global styles will not be used. The majority of the CSS used on this page can be found in a CSS file I paired up with my Twig template:

templates/pages/career.css
.page.career {
  background-color: light-dark(var(--color-white), var(--color-gray-800));
  color: light-dark(var(--color-gray-900), var(--color-white));

  a {
    overflow-wrap: break-word;
    transition: color .2s ease-out;

    &:hover {
      color: var(--color-red);
    }
  }

  & > .header {
    container-name: header;
    container-type: inline-size;
    padding: var(--xl);

    h1 {
      font-family: var(--font-futura);
      font-size: 2rem;
      font-weight: var(--font-medium);
      text-align: center;
      text-transform: uppercase;
    }
    & > p {
      font-family: var(--font-futura);
      font-size: 1.2rem;
      font-weight: var(--font-medium);
      text-align: center;
      text-transform: uppercase;
    }
    address {
      display: flex;
      flex-flow: row wrap;
      justify-content: center;
      gap: var(--sm);
      margin-block-start: var(--lg);
      padding-block-start: var(--xs);
      border-block-start: 1px solid var(--color-gray-200);
      font-size: .9rem;
      text-align: center;

      & > span {
        display: flex;
        flex-flow: row wrap;
        justify-content: center;
        gap: var(--sm);
      }
    }
  }

  & > .main {
    container-name: main;
    container-type: inline-size;

    & > .letter {
      max-width: 70ch;
      margin: calc(var(--xl-em) * 3) auto calc(var(--xl-em) * 4);
      padding-inline: var(--lg);

      * + :is(p) {
        margin-block-start: var(--lg-rem);
        text-wrap: pretty;
      }

      .objective {
        font-size: 1.3em;
        font-weight: var(--font-medium);
      }
      .objective + .cover {
        margin-block-start: var(--lg-rem);
      }

      @media print {
        & {
          font-size: .8rem;
        }
      }
    }

    & > .resume {
      display: grid;
      grid-template-columns: var(--resume-columns, 1fr);
      gap: var(--xl);
      margin: var(--lg) auto;
      max-width: max-content;
      padding-inline: var(--lg);

      .resume-section {
        display: grid;
        grid-column: 1 / -1;
        grid-template-columns: subgrid;
        gap: var(--xl);
        position: relative;

        &:not(:first-child) {
          &:before {
            content: '';
            display: block;
            grid-column: var(--section-text-grid-column, 2 / 3);
            position: absolute;
            top: calc(var(--xl) * -0.4);
            width: 100%;
            border-block-start: 1px solid var(--color-gray-200);
          }
        }

        :is(h2, h3, h4) {
          text-box: trim-both cap alphabetic;

          &:not(:first-child) {
            margin-block-start: var(--lg-em);
          }
        }
        & > h2 {
          font-size: var(--section-header-font-size, 1.6rem);
          text-align: var(--section-header-text-align);
        }
        h3 {
          &:first-child {
            margin-block-start: .2em;
          }
        }
        h4 {
          font-size: 1.1rem;
          font-style: italic;
          font-weight: var(--font-normal);
        }
        & > div {
          text-wrap: pretty;

          &:nth-child(2) {
            margin-inline-end: var(--resume-columns-margin);
          }
          &:nth-child(3) {
            grid-column: -2 / -1;
            margin-inline-start: var(--resume-columns-margin);
          }

          p {
            &:not(:first-child) {
              margin-block-start: var(--sm);
            }
          }
        }
        .project {
          &:not(:first-child) {
            margin-block-start: var(--lg);
          }
        }
      }
      @container main (width > 500px) {
        & {
          --section-header-font-size: 1rem;
          --section-header-text-align: end;
          --resume-columns: max-content minmax(0px, 70ch);
        }
      }
      @container main (width > 650px) {
        & {
          --section-header-font-size: 1.5rem;
        }
      }
      @container main (width > 1200px) {
        & {
          --resume-columns: max-content minmax(0px, 50ch) minmax(0px, 50ch);
          --resume-columns-margin: var(--md-em);
          --section-text-grid-column: 2 / 4;
        }
      }
      @media print {
        & {
          --resume-columns: max-content minmax(0px, 50ch) minmax(0px, 50ch);
          --resume-columns-margin: var(--md-em);
          --section-header-font-size: 1rem;
          --section-text-grid-column: 2 / 4;

          p {
            font-size: 12px;
          }
        }
      }
    }
  }

  @media print {
    body:has(&) admin-bar {
      display: none;
    }
  }
}

I rely heavily on nested styles here. Most of this stylesheet formats the text. Container queries and subgrid are used to lay the content out into columns on screens that are wide enough.

There are also a couple of overrides used when printing out this page or saving it as a PDF. I’m assuming a standard 8.5 x 11 in. page size would be used for printing, but the page should still flex and maintain its max width if printed on a larger sheet of paper.

Fin

This setup could be used for all sorts of documents, but one thing I'll note is that there's no security preventing strangers from viewing this document. If you use an SEO plugin you may want to wall off the career section from crawlers or add some of you own basic auth to prevent someone from accessing your resume.

Feel free to copy the code above and use it in your own Craft CMS site. If you have suggestions to make the HTML markup better, or if you have some CSS recommendations, feel free to drop me a line.

💼