Skip to content

HandlebarsPDF

Résumé with a sidebar and skill ratings

A one-page CV in two columns, full bleed. The template sorts the positions by start date, so the data can arrive in any order, writes “present” for a job without an end date, and draws each skill rating as five dots with range and lt.

Free plan, no card. You get your own copy in the editor, published and ready for the API.

Rendered result

Rendered result: Résumé with a sidebar and skill ratings
PDF · 1 page · 100 KB ↓ download

What this example shows

  • The data lists the positions in any order; sortBy experience "start" "desc" puts the current one first, and the same helper sorts the degrees.
  • A position without an end date prints “present”: {{#if end}} chooses between the formatted date and the word.
  • {{#each (range 0 5)}} draws five dots per skill, and (lt this ../level) fills as many as the level; ../ reaches the skill from inside the inner loop.
  • join stack " · " turns each position’s list of technologies into one line.
  • Zero margins let the sidebar run to the paper’s edge, and min-height: 297mm stretches it over the whole page.

Helpers used

Template folder

templates/resume/template.htmlHandlebars
<main class="cv">
  <aside>
    <h1>{{person.name}}</h1>
    <p class="headline">{{person.headline}}</p>

    <h2>Contact</h2>
    <ul class="plain">
      <li>{{person.email}}</li>
      <li>{{person.phone}}</li>
      <li>{{person.location}}</li>
      <li>{{person.web}}</li>
    </ul>

    <h2>Skills</h2>
    <ul class="skills">
      {{#each skills}}
      <li>
        <span>{{name}}</span>
        <span class="dots">{{#each (range 0 5)}}<i class="{{#if (lt this ../level)}}on{{/if}}"></i>{{/each}}</span>
      </li>
      {{/each}}
    </ul>

    <h2>Languages</h2>
    <ul class="plain">
      {{#each languages}}
      <li><strong>{{name}}</strong> · {{level}}</li>
      {{/each}}
    </ul>
  </aside>

  <div class="content">
    <h2>Profile</h2>
    <p>{{person.summary}}</p>

    <h2>Experience</h2>
    {{#each (sortBy experience "start" "desc")}}
    <article>
      <h3>{{role}}</h3>
      <p class="where">{{company}} · {{location}} · {{date start "MMM yyyy"}} – {{#if end}}{{date end "MMM yyyy"}}{{else}}present{{/if}}</p>
      <ul>
        {{#each highlights}}
        <li>{{this}}</li>
        {{/each}}
      </ul>
      <p class="stack">{{join stack " · "}}</p>
    </article>
    {{/each}}

    <h2>Education</h2>
    {{#each (sortBy education "start" "desc")}}
    <article>
      <h3>{{degree}}</h3>
      <p class="where">{{school}} · {{date start "yyyy"}} – {{date end "yyyy"}}</p>
    </article>
    {{/each}}
  </div>
</main>
templates/resume/style.cssCSS
body { margin: 0; font-family: 'Inter', sans-serif; font-size: 9pt; line-height: 1.5; color: #1e293b; }
.cv { display: grid; grid-template-columns: 68mm 1fr; min-height: 297mm; }
aside { padding: 16mm 9mm; background: #1e1b4b; color: #e0e7ff; }
aside h1 { margin: 0; font-size: 22pt; line-height: 1.1; color: #fff; }
.headline { margin: 2mm 0 0; font-size: 10.5pt; color: #a5b4fc; }
aside h2 { margin: 10mm 0 3mm; font-size: 7.5pt; letter-spacing: 0.12em; text-transform: uppercase; color: #a5b4fc; }
ul { margin: 0; padding: 0; list-style: none; }
.plain li { margin-bottom: 1.2mm; overflow-wrap: anywhere; }
.skills li { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.6mm; }
.dots { display: flex; gap: 1.2mm; }
.dots i { width: 2.4mm; height: 2.4mm; border-radius: 50%; background: #3730a3; }
.dots i.on { background: #a5b4fc; }
.content { padding: 16mm 14mm 16mm 12mm; }
.content h2 { margin: 0 0 3mm; padding-bottom: 1.5mm; border-bottom: 1px solid #e2e8f0; font-size: 8pt; letter-spacing: 0.12em; text-transform: uppercase; color: #4f46e5; }
.content > p { margin: 0 0 8mm; }
article { margin-bottom: 6mm; break-inside: avoid; }
h3 { margin: 0; font-size: 10.5pt; color: #0f172a; }
.where { margin: 0.5mm 0 1.5mm; color: #64748b; }
article ul { padding-left: 4mm; list-style: disc; }
article li { margin-bottom: 0.8mm; }
.stack { margin: 1.5mm 0 0; font-size: 8pt; color: #4f46e5; }
templates/resume/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/resume/settings.jsonJSON
{
  "pdf": {
    "metadata": {
      "title": "{{person.name}}"
    }
  },
  "paper": {
    "format": "A4"
  },
  "margin": {
    "top": "0",
    "right": "0",
    "bottom": "0",
    "left": "0"
  },
  "locale": "en-GB"
}
templates/resume/data/default.jsonJSON
{
  "person": {
    "name": "Jane Doe",
    "headline": "Senior backend engineer",
    "email": "jane@doe.example",
    "phone": "+44 0000 000000",
    "location": "Exampletown, United Kingdom",
    "web": "janedoe.example",
    "summary": "Backend engineer with ten years of building order, billing and document systems. I like boring infrastructure, small pull requests and documents that come out the same on every run."
  },
  "experience": [
    {
      "role": "Software engineer",
      "company": "Fennlor Studio GmbH",
      "location": "Musterstadt, Germany",
      "start": "2017-09-01",
      "end": "2021-02-28",
      "highlights": [
        "Built the order and invoicing service that replaced a nightly spreadsheet export.",
        "Introduced contract tests between the shop and the warehouse system."
      ],
      "stack": [
        "Python",
        "Django",
        "PostgreSQL",
        "RabbitMQ"
      ]
    },
    {
      "role": "Senior backend engineer",
      "company": "Olvarest Ltd",
      "location": "Exampletown",
      "start": "2021-03-01",
      "end": null,
      "highlights": [
        "Lead the billing platform team of five, from usage metering to the monthly invoice run.",
        "Moved invoice and contract PDFs from a home-grown renderer to templates in Git with snapshot tests.",
        "Cut the month-end batch from six hours to forty minutes."
      ],
      "stack": [
        "TypeScript",
        "Node.js",
        "PostgreSQL",
        "Kubernetes"
      ]
    },
    {
      "role": "Junior developer",
      "company": "Olvarest GmbH",
      "location": "Beispielstadt, Germany",
      "start": "2016-10-01",
      "end": "2017-08-31",
      "highlights": [
        "Maintained the customer portal and its reporting exports."
      ],
      "stack": [
        "PHP",
        "MySQL"
      ]
    }
  ],
  "education": [
    {
      "degree": "BSc Computer Science",
      "school": "University of Exampletown",
      "start": "2012-10-01",
      "end": "2015-07-31"
    },
    {
      "degree": "MSc Software Engineering",
      "school": "University of Exampletown",
      "start": "2015-10-01",
      "end": "2016-09-30"
    }
  ],
  "skills": [
    {
      "name": "TypeScript",
      "level": 5
    },
    {
      "name": "PostgreSQL",
      "level": 5
    },
    {
      "name": "Python",
      "level": 4
    },
    {
      "name": "Kubernetes",
      "level": 3
    },
    {
      "name": "Go",
      "level": 2
    }
  ],
  "languages": [
    {
      "name": "English",
      "level": "native"
    },
    {
      "name": "German",
      "level": "fluent (C1)"
    },
    {
      "name": "French",
      "level": "basic (A2)"
    }
  ]
}

Render it through the API

Save the sample data as data.json and send it with the template’s name. TypeScript and Python use the SDKs; every other language posts the same JSON to the REST API with its standard library. Renders with a key that starts with ff_test_ are not metered; on the Free plan they carry a small watermark.

render.shShell
jq '{template: "resume", data: ., filename: "cv-jane-doe.pdf"}' data.json \
  | curl https://api-eu.formfeed.dev/v1/renders \
      -H "Authorization: Bearer ff_test_..." \
      -H "Content-Type: application/json" \
      --data-binary @-
render.tsTypeScript
import { Formfeed } from '@formfeed/sdk';
import data from './data.json' with { type: 'json' };

const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });

const render = await formfeed.renders.create({
  template: 'resume',
  data,
  filename: 'cv-jane-doe.pdf',
});
console.log(render.status, render.download_url);
render.pyPython
import json
import os

from formfeed import Formfeed

formfeed = Formfeed(api_key=os.environ["FORMFEED_API_KEY"])

with open("data.json", encoding="utf-8") as file:
    data = json.load(file)

render = formfeed.renders.create(
    template="resume",
    data=data,
    filename="cv-jane-doe.pdf",
)
print(render.status, render.download_url)
render.phpPHP
<?php
$body = [
    'template' => 'resume',
    'data' => json_decode(file_get_contents('data.json'), true),
    'filename' => 'cv-jane-doe.pdf',
];

$curl = curl_init('https://api-eu.formfeed.dev/v1/renders');
curl_setopt_array($curl, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('FORMFEED_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode($body),
]);
$render = json_decode(curl_exec($curl), true);

echo $render['status'], ' ', $render['download_url'], PHP_EOL;
main.goGo
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	data, err := os.ReadFile("data.json")
	if err != nil {
		panic(err)
	}
	body, _ := json.Marshal(map[string]any{
		"template": "resume",
		"data":     json.RawMessage(data),
		"filename": "cv-jane-doe.pdf",
	})

	request, _ := http.NewRequest("POST", "https://api-eu.formfeed.dev/v1/renders", bytes.NewReader(body))
	request.Header.Set("Authorization", "Bearer "+os.Getenv("FORMFEED_API_KEY"))
	request.Header.Set("Content-Type", "application/json")

	response, err := http.DefaultClient.Do(request)
	if err != nil {
		panic(err)
	}
	defer response.Body.Close()

	var render struct {
		Status      string `json:"status"`
		DownloadURL string `json:"download_url"`
	}
	json.NewDecoder(response.Body).Decode(&render)
	fmt.Println(render.Status, render.DownloadURL)
}
Program.csC#
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var body = new
{
    template = "resume",
    data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
    filename = "cv-jane-doe.pdf",
};

using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("FORMFEED_API_KEY"));

var response = await http.PostAsJsonAsync("https://api-eu.formfeed.dev/v1/renders", body);
var render = await response.Content.ReadFromJsonAsync<JsonElement>();

Console.WriteLine($"{render.GetProperty("status")} {render.GetProperty("download_url")}");
Render.javaJava
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;

public class Render {
  public static void main(String[] args) throws Exception {
    String data = Files.readString(Path.of("data.json"));
    String body = """
        {
          "template": "resume",
          "filename": "cv-jane-doe.pdf",
          "data": %s
        }
        """.formatted(data);

    HttpRequest request = HttpRequest.newBuilder(URI.create("https://api-eu.formfeed.dev/v1/renders"))
        .header("Authorization", "Bearer " + System.getenv("FORMFEED_API_KEY"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(body))
        .build();

    HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
    System.out.println(response.body()); // status, download_url, page_count, units …
  }
}
render.rbRuby
require "json"
require "net/http"

body = {
  template: "resume",
  data: JSON.parse(File.read("data.json")),
  filename: "cv-jane-doe.pdf",
}

response = Net::HTTP.post(
  URI("https://api-eu.formfeed.dev/v1/renders"),
  JSON.generate(body),
  "Authorization" => "Bearer #{ENV.fetch("FORMFEED_API_KEY")}",
  "Content-Type" => "application/json"
)
render = JSON.parse(response.body)

puts render["status"], render["download_url"]

Start rendering today

The free plan includes 100 units a month and free test renders. Change plans when you need more.