Walter McKelvie

Building a Website with Tola, Typst, and Nix

2026-03-30
How I build and deploy this website with Typst using Nix
Contents

Why I chose Typst

Typst is a wonderful replacement for . I’ve found it extremely liberating to use, for the simple reason that it is an actual modern programming language: it has ergonomic error messages and I can do things that I would find unfathomable in such as parse a CSV file and plot its values on a figure. I have been using it for homework assignments and notes (and hopefully soon for ArXiv!).

Typst supports compilation to HTML in addition to targeting PDF, which makes it naturally well-suited as the basis for a static site generator. This is exactly the idea behind Tola, a SSG based on Typst. Similar to how frameworks like Astro allow writing blog posts in Markdown which is then compiled into HTML, I write these posts in Typst. The advantage here is that Typst is a far richer language than Markdown.

I’ll give a concrete example: when I publish a paper, I would like it to appear on both my website and my resume. My resume is compiled using Typst, as is this website; therefore, I just have both of them #import the same meta/papers.typ file rendered below:

#let papers = (
(
title: "Improving Pearson's chi-squared test: hypothesis testing of distributions - optimally",
date: "2023-10-13",
authors: ("Trung Dang", "Walter McKelvie", "Paul Valiant", "Hongao Wang"),
link: "https://arxiv.org/pdf/2310.09408",
venue: "Manuscript",
),
(
title: "Accountable Secret Leader Election",
date: "2024-09-16",
authors: ("Miranda Christ", "Kevin Choi", "Walter McKelvie", "Joseph Bonneau", "Tal Malkin"),
link: "https://drops.dagstuhl.de/entities/document/10.4230/LIPIcs.AFT.2024.1",
venue: "Advances in Financial Technology (AFT)",
),
(
title: "Mean Estimation over R: Optimal Sub-Gaussianity with Outlier Robustness and Low Moments Performance",
date: "2025-07-13",
authors: ("Jasper C.H. Lee", "Walter McKelvie", "Maoyuan Song", "Paul Valiant"),
link: "https://proceedings.mlr.press/v267/lee25w.html",
venue: "International Conference on Machine Learning (ICML)",
note: "Spotlight paper selected for oral presentation",
),
(
title: "Settling Pure Differentially Private Covariance Estimation",
date: "2026-01-23",
authors: ("Tommaso D'Orsi", "Walter McKelvie", "Gleb Novikov"),
venue: "Manuscript",
note: "Under review for ICML 2026",
),
(
title: "Computation-Utility-Privacy Tradeoffs in Bayesian Estimation",
date: "2026-06-27",
authors: ("Sitan Chen", "Jingqiu Ding", "Mahbod Majid", "Walter McKelvie"),
link: "https://arxiv.org/pdf/2603.18254",
venue: "Symposium on the Theory of Computing (STOC)",
),
)

#let authors = (
"Paul Valiant": "https://www.cs.purdue.edu/homes/pvaliant",
"Trung Dang": "https://kuroni.github.io/",
"Hongao Wang": "https://phijack.github.io/",
"Miranda Christ": "https://www.cs.columbia.edu/~mchrist/",
"Kevin Choi": "https://kevinchoi.io/",
"Joseph Bonneau": "https://cs.nyu.edu/~jcb/",
"Tal Malkin": "https://www.cs.columbia.edu/~tal/",
"Jasper C.H. Lee": "https://jasperchlee.github.io/",
"Maoyuan Song": "https://maoyuans.github.io/",
"Tommaso D'Orsi": "https://tommasodorsi.github.io/",
"Gleb Novikov": "https://www.glebnovikov.org/",
"Sitan Chen": "https://www.sitanchen.com/",
"Jingqiu Ding": "https://huayiding.github.io/",
"Mahbod Majid": "https://www.mahbodmajid.com/",
)

(By the way, the above in my editor is as easy as #raw(read("/meta/papers.typ"), block: true, lang: "typ"), and the code block will be automatically kept up-to-date as I add new papers. Nifty!). I can then write code for how I want these to be displayed:

#let pub-card(pub) = {
let link = pub.at("link", default: none)
let venue = pub.at("venue", default: none)
let date = pub.at("date", default: none)
let link = pub.at("link", default: none)
let note = pub.at("note", default: none)

let year = none

if date != none {
year = date.split("-").at(0)
}

let blurb = ""

if venue != none {
blurb = blurb + venue

if year != none {
blurb = blurb + ", " + year
}

blurb = blurb + "."
} else {
if year != none {
blurb = blurb + year + "."
}
}

if note != none {
if venue != none or date != none {
blurb = blurb + " "
}
blurb = blurb + note + "."
}

let authors-linked = pub.authors.map(author => {
let author-link = pub-info.authors.at(author, default: none)

if author-link == none {
tag(author)
} else {
tag-link(author, author-link)
}
})

let transition = ""

if link != none {
transition = "group-hover:text-accent transition-colors"
}

let content = [
#html.h3(class: "text-xl font-semibold mb-2 " + transition)[
#if link != none [
#html.a(href: link)[#pub.title]
] else [
#html.a(class: "text-primary")[#pub.title]
]
]

#layout.flex-row(gap: 4, ..authors-linked)

#html.p(class: "mt-2 text-muted")[#blurb]
]

html.span(
class: "block mb-6 p-4 border border-white/10 rounded-lg bg-surface/50 no-underline group",
)[#content]
}

and further programmatically compose those objects into what you see in the research page. This is just one simple example, and if I continue to write posts I expect that I will accumulate a library of utility functions that allow building and re-using cool components.

Building the site

As you may know, I use NixOS on all of my machines (an M2 MacBook Air , a desktop, a NAS, and a VPS). Of particular interest here is my VPS, which is running this site: the most important contraint is that it is puny with 1GB of memory and 20GB of storage, so it isn’t able to build its own Nix derivation. So, I build its operating system remotely on my PC or laptop and deploy it using deploy-rs. In order to avoid running Typst compilations on the VPS, I want to evaluate it during build-time and deploy only the resulting HTML files. The way to do this is to write a custom Nix derivation:

let website = pkgs.stdenv.mkDerivation {
name = "walt-website";

nativeBuildInputs = [
pkgs.tailwindcss_4
pkgs.typst
inputs.tola.packages.${pkgs.stdenv.hostPlatform.system}.default
];

src = ./tola;

buildCommand = ''
cp -r $src tola
cd tola
mkdir -p $out/share/www
tola --output $out/share/www build
'';
};

I keep the website’s project root at ./tola relative to the website/default.nix file the above is excerpted from; all the above is doing is copying the project’s Typst source code into the working directory and building the output HTML into /share/www under the resulting final package. Then, the raw HTML can be accessed in the Nix store by referencing "${website}/share/www" as in the below nginx configuration:

{
services.nginx.virtualHosts = {
"www.waltmckelvie.com" = {
locations."/".root = "${website}/share/www";
};
};
}

That’s it! That’s all you need to deploy raw minified HTML files and statically serve them, though additional features like SSL are easy to add. As a final touch, significant speed improvements are easily attainable by using gzip_static or brotli_static. The idea is that if a client requests index.html and advertises support for brotli compression, nginx will look for a pre-compressed file at index.html.br and serve that rather than compressing on-the-fly. Let’s add support for that! We start by adding brotli support to nginx:

{
additionalModules = with pkgs.nginxModules; [
brotli
];
}

Then we will compress all files at compile-time by modifying our website derivation to the following:

let website = pkgs.stdenv.mkDerivation {
name = "walt-website";

nativeBuildInputs = [
pkgs.tailwindcss_4
pkgs.typst
inputs.tola.packages.${pkgs.stdenv.hostPlatform.system}.default

pkgs.findutils
pkgs.gzip
pkgs.brotli
];

src = ./tola;

buildCommand = ''
cp -r $src tola
cd tola
mkdir -p $out/share/www
tola --output $out/share/www build
find $out/share/www -type f \( -name "*.js" -o -name "*.css" -o -name "*.html" -o -name "*.xml" \) -exec gzip -k -9 {} \;
find $out/share/www -type f \( -name "*.js" -o -name "*.css" -o -name "*.html" -o -name "*.xml" \) -exec brotli -q 11 -k {} \;
'';
};

Finally we add support to our nginx configuration by setting

{
services.nginx.virtualHosts = {
"www.waltmckelvie.com" = {
locations."/".extraConfig = ''
gzip_static on;
brotli_static on;
'';
};
};
}

Of course zstd can also be used, but I have found that brotli gives better compression ratios (at the cost of compression speed) and has better browser support.