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
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: 297mmstretches it over the whole page.
Helpers used
Template folder
<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>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; }<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">{
"pdf": {
"metadata": {
"title": "{{person.name}}"
}
},
"paper": {
"format": "A4"
},
"margin": {
"top": "0",
"right": "0",
"bottom": "0",
"left": "0"
},
"locale": "en-GB"
}{
"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.
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 @-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);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)<?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;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)
}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")}");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 …
}
}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.
