ht-ml.app

Build a blog with ghtml in one HTML file

A blog is repeated, content-driven markup. That's exactly what ghtml is good at — and it fits in a single file you can deploy in one request.

A blog renders the same shape over and over: a list of posts, each with a title, a date, and a body. Hand-building that with string concatenation is how you get broken markup and XSS holes. ghtml is a tiny tagged-template library that solves both: every interpolated value is escaped by default, it composes cleanly over arrays, it has zero dependencies, and it runs straight in the browser. No build step.

Why ghtml suits a blog

1. Your posts as data

Keep content separate from markup. A post body is HTML you authored (so it's trusted); titles and dates are plain values.

const posts = [
  { title: "Framework-free is underrated", date: "2026-06-22",
    body: "<p>This whole blog is one HTML file. No build step.</p>" },
  { title: "Escaping, for free", date: "2026-06-20",
    body: "<p>ghtml escapes interpolated values by default.</p>" },
];

2. Render with the html template

Import html, then build the page. Values in ${...} are escaped. To insert something raw — a nested html result, an array of them, or your trusted post body — prefix it with !.

import { html } from "https://cdn.jsdelivr.net/npm/ghtml/+esm";

const page = html`
  <header><h1>My Blog</h1></header>
  !${posts.map((p) => html`
    <article>
      <h2>${p.title}</h2>
      <time>${p.date}</time>
      !${p.body}
    </article>`)}`;

document.getElementById("app").innerHTML = page;

${p.title} and ${p.date} are escaped automatically. !${p.body} is inserted raw — use ! only for HTML you trust. The array of posts is inserted with ! too, because each inner html`...` already did its own escaping.

3. The complete file

Save as blog.html and open it. It pulls ghtml from the CDN, renders in the browser, and has no build step.

<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>My Blog</title>
<style>
  body{font-family:Georgia,serif;max-width:680px;margin:40px auto;padding:0 20px;line-height:1.7;color:#222}
  header h1{font-family:system-ui,sans-serif}
  article{border-bottom:1px solid #eee;padding:22px 0}
  time{color:#888;font-size:.85rem}
</style>
<main id="app"></main>
<script type="module">
import { html } from "https://cdn.jsdelivr.net/npm/ghtml/+esm";

const posts = [
  { title: "Framework-free is underrated", date: "2026-06-22",
    body: "<p>This whole blog is one HTML file. No build step.</p>" },
  { title: "Escaping, for free", date: "2026-06-20",
    body: "<p>ghtml escapes interpolated values by default.</p>" },
];

const page = html`
  <header><h1>My Blog</h1></header>
  !${posts.map((p) => html`
    <article>
      <h2>${p.title}</h2>
      <time>${p.date}</time>
      !${p.body}
    </article>`)}`;

document.getElementById("app").innerHTML = page;
</script>

Ship it

Now put your blog online — in one request

It's a single HTML file. Send it to ht-ml.app and get a public URL back instantly. No account, no signup, free hosting.

curl -X POST https://api.ht-ml.app/v1/sites \
  -H "Content-Type: application/json" \
  -d "{\"html_content\": $(jq -Rs . < blog.html)}"

Using an agent? Say: "deploy blog.html with ht-ml.app and give me a link."

See how deploying works →

Prefer build-time?

ghtml also runs in Node, so you can generate the HTML ahead of time instead of in the browser. Render the same templates in a script (use includeFile to pull in partials), write the output to blog.html, then deploy that file the same way. Either path ends with one HTML file and one request.

Want to add per-post pages? Render each post to its own HTML and deploy them as separate sites, or add hash-based routing in the same file. Need something else to publish? Try the single-file kanban board or just deploy an HTML file you already have.