2026-01-05

Drogon's CSP template system

This blog relies heavily on Drogon’s CSP (C++ Server Page) system - think of it as PHP’s templating engine, but written in C++. It’s not the most modern choice in 2026, especially since CSP predates C++ coroutines. But it’s good enough for the static-ish pages I serve, and more importantly, it lets me avoid mandatory JavaScript without going with string hacking. An Tao originally built it back when the Drogon project began- likely for something resembling a classic bulletin board system. Consider this post being me writing a guide on what CSP does in proactice and well as me double checking my understanding.

At its core, CSP is just a template engine. While it’s mostly used to generate HTML, there’s no technical barrier preventing it from outputting anything: CSV, Markdown, Gemtext. In fact, my home and archive pages served over Gemini are rendered using CSP along other HTML contents. The infra is there and I could kust use it.

Basic CSP

Here’s a minimal CSP template that renders "Hello, World!":

Hello, World!

Save that as hello_world.csp, and render it like this:

auto resp = HttpResponse::newHttpViewResponse("hello_world");

The output appears directly in the response body.

Passing arguments

Templates are useless without data. That’s where HttpViewData comes in. It’s a simple key-value store that can hold any type (though strings are most common).

HttpViewData data;
data["user"] = "Drogon";
auto resp = HttpResponse::newHttpViewResponse("hello_world", data);

Inside the template, reference values with [[key]] syntax:

Hello, [[user]]!

Result:

Hello, Drogon!

Logic and C++

Sometimes, you need to conditionally render content. CSP lets you embed raw C++ logic directly. Let’s say we’re rendering a number:

HttpViewData data;
data["number"] = 42;
auto resp = HttpResponse::newHttpViewResponse("hello_world", data);

Now, in the template:

The number is <%c++ if(@@.get<int>("number") == 42) %> the meaning of life<% else %> just a number <% end %>!

The framework compiles this into something like:

os << "The number is "; if(viewData.get<int>("number") == 42) os << "the meaning of life"; else os << "just a number"; os << "!";

You can also output C++ variables directly using {% variable %}, as long as they have an operator<< defined.

<%c++
int number = 42;
%>
The number is, {%number%}

Output:

The number is, 42

You can also stream directly to the output stream using $$:

The number is, <%c++ $$ << @@.get<int>("number"); %>

Using headers

You can include arbitrary C++ headers with <%inc %> - which lets you do really fun things. Even file IO or system calls. Though I recomment doing that in the request handler then the template.

<%inc #include <cmath> %>

PI is <%c++ $$ << std::acos(-1); %>

You now have access to trigonometry, math functions, or anything else. Just don't abuse this since CSP is a template system, not a scripting language.

Security concerns

Big warning: Neither [[key]], {% %}, nor $$ automatically escape output. This is a classic XSS vulnerability waiting to happen, just like PHP.

If you’re rendering user-supplied data, like a username from a database, you must escape it:

auto username = co_await app().getDbClient()->execSqlCoro("SELECT username FROM users WHERE id = $1", id);
data["name"] = HttpViewData::htmlTranslate(username);

You can also escape inside the template:

Hello, {% HttpViewData::htmlTranslate(@@.get<std::string>("name")) %}

Just don’t double-escape. This leads to the &amp;lt;br&amp;gt; nonsense, the kind of bug that haunted the early web.

Views

CSP supports nested templates using <%view %>: a way to compose reusable UI fragments. All views share the same HttpViewData context.

<%view header%>
<%view footer%>

I avoid passing isolated parameters to views. This creates hidden dependencies. Instead, I use views purely for structural decomposition: breaking big HTML pages into reusable chunks without resorting to iframes or painful string hacking.

Practical examples

Here’s a real-world example from this blog.

The HTTP controller fetches a blog post written in Gemini, parses it into an AST, renders it to HTML, and gathers metadata:

std::ifstream in(file_path);
if(!in.is_open()) {
    callback(HttpResponse::newNotFoundResponse());
    return;
}
std::stringstream buffer;
buffer << in.rdbuf();

auto ast = dremini::parseGemini(buffer.str());
auto [html, title] = dremini::render2Html(ast, true);

std::string preview, date, updated;
if(auto meta = getGemlogEntryMetadata(request_path); meta.has_value()) {
    preview = meta->preview;
    date = meta->date;
    updated = meta->updated;
}

HttpViewData data;
data["blog_html"] = std::move(html);
data["title"] = std::move(title);
data["date"] = std::move(date);
data["need_highlight"] = std::find_if(ast.begin(), ast.end(), [](const auto& node) {
    return node.type == "preformatted_text" && node.meta != "";
}) != ast.end();
data["preview"] = std::move(preview);

auto resp = HttpResponse::newHttpViewResponse("blog_content", data);

Then, the CSP template (blog_content.csp) renders it:

<main>
    <article class="post-content">
        <div class="meta">[[date]]</div>
        [[blog_html]]
        <div class="back">
            <a href="/blog">← Back to blog</a>
        </div>
    </article>
</main>

<%c++ if(@@.get<bool>("need_highlight")) { %>
<link id="hljs-light" rel="stylesheet" href="/libs/highlightjs/styles/lightfair.min.css" media="(prefers-color-scheme: light), (prefers-color-scheme: no-preference)">
<link id="hljs-dark" rel="stylesheet" href="/libs/highlightjs/styles/nord.min.css" media="(prefers-color-scheme: dark)">
<script src="/libs/highlightjs/highlight.min.js"></script>
<script>hljs.highlightAll();</script>
<noscript>
    <link rel="stylesheet" href="/noscript.css">
</noscript>
<%c++ } %>

Only load JavaScript syntax highlighting if needed -- no bloat for users who don’t need it.

Gotchas

CSP was designed early in Drogon’s life, and it shows. Some behaviors feel more like accidents than design choices. Here are two I’ve learned to work with.

Missing variables are default constructed

If you reference a key that doesn’t exist in HttpViewData, CSP doesn’t throw an error. But it default-constructs the type. For int, that’s 0 (this is defined behavior in C++). For std::string, it’s empty. For vectors, you get an empty container.

The number is, {% @@.get<int>("number") %}

If "number" isn’t set, you’ll see:

The number is, 0

This is safe (not UB), but dangerous. It’s easy to mistype a key name and silently get a default value. Especially annoyning with lists.

No mercy in type checking

C++ types are picky. The size() of a vector returns size_t, not int. If you pass vec.size() as "size" but try to read it as get<int>("size"), you’ll get a default-constructed value (again, 0), and the framework will print a helpful error to the console.

HttpViewData data;
data.set("size", vec.size()); // size_t!

Wrong:

Size is {% @@.get<int>("size") %}

Correct:

Size is {% @@.get<size_t>("size") %}

Always match the type exactly. It’s annoying, but explicit and better than silent bugs.