Skip to content

Jinja2PDF

Name badges, eight per A4 sheet

One request carries the whole guest list. chunk(8) cuts it into sheets of eight badges in a CSS grid of 90 × 67.5 mm cells, pageBreak() starts a new sheet between them, and default fills in a missing organisation or role. Ten guests give two pages.

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

Rendered result

PDF · 2 pages · 83 KB ↓ download

What this example shows

  • attendees | chunk(8) cuts the list into sheets; ten guests give two pages, the second with two badges.
  • A sheet is a CSS grid of two columns of 90mm and rows of 67.5mm, which fills the printable height of A4 exactly with four rows.
  • {% if not loop.last %}{{ pageBreak() }}{% endif %} breaks between sheets but leaves no empty page at the end.
  • default('Gast') and default('teilnehmer') fill a missing organisation or role, and the role doubles as a CSS class for the coloured bar.
  • The dashed hairline around each badge is the cutting guide.

Helpers used

Template folder

templates/namensschilder/template.htmlJinja2
{% for sheet in attendees | chunk(8) %}
<section class="sheet">
  {% for person in sheet %}
  <article class="badge">
    <p class="event">{{ event.name | upper }}</p>
    <h1>{{ person.first_name }}</h1>
    <p class="last">{{ person.last_name }}</p>
    <p class="org">{{ person.organisation | default('Gast') }}</p>
    <p class="role role-{{ person.role | default('teilnehmer') }}">{{ person.role | default('teilnehmer') | upper }}</p>
  </article>
  {% endfor %}
</section>
{% if not loop.last %}{{ pageBreak() }}{% endif %}
{% endfor %}
templates/namensschilder/style.cssCSS
body { margin: 0; font-family: 'Inter', sans-serif; color: #0f172a; }
.sheet { display: grid; grid-template-columns: repeat(2, 90mm); grid-auto-rows: 67.5mm; justify-content: center; }
.badge {
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  padding: 7mm 8mm 6mm;
  border: 0.2mm dashed #cbd5e1;
  text-align: center;
}
.event { margin: 0; font-size: 7pt; font-weight: 600; letter-spacing: 0.18em; color: #4f46e5; }
h1 { margin: 7mm 0 0; font-size: 26pt; line-height: 1.05; }
.last { margin: 1mm 0 0; font-size: 14pt; }
.org { margin: 3mm 0 0; font-size: 10pt; color: #475569; }
.role { margin: auto 0 0; padding: 1.2mm 0; border-radius: 1.5mm; font-size: 8pt; font-weight: 700; letter-spacing: 0.15em; background: #e2e8f0; }
.role-speaker { background: #312e81; color: #fff; }
.role-team { background: #0f766e; color: #fff; }
templates/namensschilder/head.htmlHTML
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap">
templates/namensschilder/settings.jsonJSON
{
  "pdf": {
    "metadata": {
      "title": "Namensschilder"
    }
  },
  "paper": {
    "format": "A4"
  },
  "margin": {
    "top": "13.5mm",
    "right": "15mm",
    "bottom": "13.5mm",
    "left": "15mm"
  },
  "locale": "de-DE"
}
templates/namensschilder/data/default.jsonJSON
{
  "event": {
    "name": "Fennlor Konferenz 2026"
  },
  "attendees": [
    {
      "first_name": "Erika",
      "last_name": "Mustermann",
      "organisation": "Fennlor Studio GmbH",
      "role": "speaker"
    },
    {
      "first_name": "Max",
      "last_name": "Mustermann",
      "organisation": "Olvarest GmbH"
    },
    {
      "first_name": "Jane",
      "last_name": "Doe",
      "organisation": "Olvarest Ltd",
      "role": "speaker"
    },
    {
      "first_name": "John",
      "last_name": "Doe",
      "organisation": "Fennlor Studio Ltd",
      "role": "team"
    },
    {
      "first_name": "Erika",
      "last_name": "Musterfrau"
    },
    {
      "first_name": "Max",
      "last_name": "Beispiel",
      "organisation": "Stadt Musterstadt"
    },
    {
      "first_name": "Jane",
      "last_name": "Roe",
      "organisation": "Olvarest GmbH"
    },
    {
      "first_name": "John",
      "last_name": "Roe",
      "organisation": "Fennlor Akademie",
      "role": "team"
    },
    {
      "first_name": "Erika",
      "last_name": "Beispiel",
      "organisation": "Hochschule Beispielstadt"
    },
    {
      "first_name": "Max",
      "last_name": "Musterfrau",
      "organisation": "Olvarest GmbH"
    }
  ]
}

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: "namensschilder", data: ., filename: "namensschilder.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: 'namensschilder',
  data,
  filename: 'namensschilder.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="namensschilder",
    data=data,
    filename="namensschilder.pdf",
)
print(render.status, render.download_url)
render.phpPHP
<?php
$body = [
    'template' => 'namensschilder',
    'data' => json_decode(file_get_contents('data.json'), true),
    'filename' => 'namensschilder.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": "namensschilder",
		"data":     json.RawMessage(data),
		"filename": "namensschilder.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 = "namensschilder",
    data = JsonDocument.Parse(File.ReadAllText("data.json")).RootElement,
    filename = "namensschilder.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": "namensschilder",
          "filename": "namensschilder.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: "namensschilder",
  data: JSON.parse(File.read("data.json")),
  filename: "namensschilder.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.