inja: a C++ template engine driven by nlohmann/json
A Template Engine for Modern C++
At a glance
- What is it?
- inja ships as headers and renders jinja style templates from a single nlohmann/json object, so a C++ program can fill in a text file from data it already holds. Its strictness is uneven: the ## form must sit flush left, and a name that resolves to nothing prints JSON rather than always raising.
- Who is it for?
- inja fits a C++ codebase that already passes nlohmann/json between its layers and wants text templates without adding a runtime dependency or a second language. It does not fit work where templates must be compiled ahead of time, sandboxed, or kept out of the source tree.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 18 days ago.
- What is it written in?
- Mainly C++, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The one data model inja renders from
inja renders text from a single nlohmann/json value and accepts no other input shape. The free function takes a template string plus a json object and hands back a std::string, and render_to sends the same result to a stream such as std::cout. Because the data model is one JSON tree, the lookup rules are visible in the template itself: a number after a dot indexes an array, a name after a dot reads an object member, and arithmetic works inside the expression.
json data;
data["name"] = "world";
inja::render("Hello {{ name }}!", data); // Returns "Hello world!"Four lines set one key and print it. The variables block then shows the two lookup styles sitting next to each other.
json data;
data["neighbour"] = "Peter";
data["guests"] = {"Jeff", "Tom", "Patrick"};
data["time"]["start"] = 16;
data["time"]["end"] = 22;
// Indexing in array
render("{{ guests.1 }}", data); // "Tom"The first expression returns the second entry of the array, and the object form with time.start, time.end and time.end + 1 turns a nested member plus one into 16 to 23pm. A team whose layers already exchange json inherits a data model here without learning a second binding layer.
A minimum version in the README and an exact pin in the repository
The integration notes name nlohmann/json.hpp (>= v3.8.0) as the single dependency and require it to be includable from inja.hpp, which puts the version floor in your hands. The root of the repository disagrees with that in a way that shows up in builds: requirements.txt holds exactly one line, nlohmann/[email protected], the floor itself rather than a range above it.
Two files, two contracts. The prose states a minimum while the pin records a single combination with no upper bound written down in either place, so nothing tells you which json versions above 3.8.0 were exercised against inja. The library adds no version check of its own at configure time either, which means a project that vendors the newest json runs outside anything written down here. That is a cheap thing to settle in a scratch build and an expensive one to discover after a binary ships.
No adapter is described in the tree for reconciling two copies of json in one binary. If your dependency graph already carries a different version of those headers, resolving that collision comes before inja compiles at all.
Two vendorable folders and one include line
Everything ships as headers. The download, or either of the two folders at the root, holds the whole engine, and integration is an include plus a using declaration.
#include <inja.hpp>
// Just for convenience
using namespace inja;Two layouts means two choices for your tree: the per header tree under include/, or the amalgamated single_include/ folder for builds that would rather carry one file. Nothing is compiled as part of your build and no generation step is documented, because the engine is text at include time.
The licence is MIT, and since you copy the files rather than link a binary, that notice travels with the copy that sits in your repository.
The build side is quieter than the vendoring side. The root carries CMakeLists.txt, cmake/, meson.build and meson_options.txt, yet the integration notes name only Meson as the system you can wrap as a subproject and give no CMake steps at all. The remaining entries, doc/, test/, scripts/, third_party/ and .clang-tidy, are not explained anywhere in the write-up, so their layout is something you confirm by reading the repository rather than the documentation.
Line statements cannot be indented, block statements can
Two syntaxes do the same work and they are not interchangeable. The delimited form carries an opener and a closer, while the ## form is opened by one token with no closing marker, because it applies to the whole line.
env.set_expression("{{", "}}"); // Expressions
env.set_comment("{#", "#}"); // Comments
env.set_statement("{%", "%}"); // Statements {% %} for many things, see below
env.set_line_statement("##"); // Line statements ## (just an opener)
env.set_html_autoescape(true); // Perform HTML escaping on all stringsFour of those setters take a pair of delimiters and set_line_statement takes one string, which is the whole difference between the two forms.
The catch is indentation. The ## form has to start its line with no leading whitespace, and the loop example obeys that rule while indenting the interpolation with a tab.
render(R"(Guest List:
## for guest in guests
{{ loop.index1 }}: {{ guest }}
## endfor )", data)An editor configured to indent every line violates that rule on the ## lines and leaves the tab indented expression alone, so a reformatted template can look correct and still stop where the loop opens. Inside a loop, loop.index, loop.index1, loop.is_first and loop.is_last are defined, a nested loop reaches its parent through loop.parent.index, and an object iterates as pairs with {% for key, value in time %} instead of as one flat sequence.
The Environment object reads files and writes files
Past the free function sits an Environment that moves between strings and files in both directions.
// Or read the template file (and/or the json file) directly from the environment
result = env.render_file("./templates/greeting.txt", data);
result = env.render_file_with_json_file("./templates/greeting.txt", "./data.json");
// Or write a rendered template file
env.write(temp, data, "./result.txt");
env.write_with_json_file("./templates/greeting.txt", "./data.json", "./result.txt");Those six lines cover the file workflow: a template string held in memory, a template file read through parse_template, a template and a data file read together, and two write paths that put rendered text on disk. The constructor takes one path for template files or two when output belongs elsewhere, and the write methods still take a destination of their own, which makes that second argument a default rather than a rule.
This is the part that decides where the binary can run. Templates and data are resolved from the filesystem at render time, so the directories your deployment mounts have to be there at that moment. No documented fallback loads an embedded copy when the path is missing.
A missing variable prints JSON instead of always raising
A lookup miss does not fail in one uniform way, and the rule is stated in a single sentence: if no variable is found, valid JSON is printed directly, otherwise an inja::RenderError is thrown. Which JSON gets printed is not spelled out, and nothing says whether the check runs on the variable name or on the resolved value. A name that does not exist can therefore reach your output as text rather than as an exception, and a generated file with a blank field is harder to trace than a thrown error. Settle that in your own build before you depend on it.
Escaping is a switch rather than a default. set_html_autoescape(true) applies HTML escaping to all strings, so a template that emits markup and a template that emits C++ source need different calls, and no per expression opt out is documented. Rendering one template that mixes both is where this setting decides whether the output is usable at all.
Seven install routes and a cget pin two majors behind
The integration notes are the longest part of the write-up and they cover seven ways in. Meson users wrap the repository as a subproject. Conan users point at conan-inja, a separate repository whose own issue tracker is where packaging problems go. Hunter and vcpkg both carry inja as a package, with the vcpkg port under ports/inja.
cget install pantor/inja
cget install pantor/[email protected]
brew install inja
conda install -c conda-forge injacget pulls the latest development version from GitHub, Homebrew covers macOS, conda-forge covers conda. The third line is the one to read twice: v2.1.0 is the concrete version the notes hand you, while the version history on the project ends at v3.5.0 on 2025-10-18, with v3.4.0 on 2023-01-29 and v3.3.0 on 2021-07-28 behind it. Two majors of drift sit inside a command a reader is invited to copy.
Two of these routes also take code from outside this repository, so a lock file that names your build tools but not their sources leaves part of the tree unaccounted for.
Where inja differs from the Python engine it echoes
The write-up links jinja for python as the thing inja is loosely inspired by. The syntax is borrowed; the runtime is not. There is no interpreter to embed, no separate template compile step, and nothing in the dependency list beyond the json headers.
What that buys is a template engine inside the program, where parsing and execution happen at the moment you call env.render. A malformed template or a missing key is then a runtime condition of your own binary rather than a build failure, and inja::RenderError is the only failure type named anywhere in the write-up. A code generation pipeline that already runs a Python step in its build can produce the same files there and skip an in process engine entirely. Neither approach is wrong; the deciding question is where the text is produced and who is allowed to change the template.
Editorial conclusion
inja fits a C++ codebase that already passes nlohmann/json between its layers and wants text templates without adding a runtime dependency or a second language. It does not fit work where templates must be compiled ahead of time, sandboxed, or kept out of the source tree. Before adopting it, check two things in your own build: whether your editor indents the ## lines of a template, and what actually happens when a name in an expression resolves to nothing. Keep templates out of any auto-formatted file set, and pin nlohmann/[email protected] the way requirements.txt does.
Frequently asked questions
What does inja need in order to compile a template?
The inja headers plus nlohmann/json.hpp at v3.8.0 or newer. json is described as the single dependency, and it has to be includable from inja.hpp, so the include path of your build decides whether that works.
How do I render a template file with inja?
Create an Environment and hand it a path, for example env.render_file("./templates/greeting.txt", data). When the data also lives in a file, render_file_with_json_file takes the template path and the json path together.
What happens when a template refers to a name that is missing from the json data?
Valid JSON is printed directly into the output and anything else throws inja::RenderError. Which of the two happens for a given key is not spelled out, so check it in your own build.
Can I install inja on macOS or through conda?
Yes. Homebrew installs it with brew install inja, and conda users install from conda-forge with conda install -c conda-forge inja. cget, Conan, Hunter, vcpkg and a Meson subproject are the other documented routes.
Does inja escape HTML by default?
No. Call set_html_autoescape(true) on the Environment to apply HTML escaping to all strings. The write-up does not document a per expression opt out.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/pantor-inja)Was this useful?