HTML Templating: The Backend Doesn't Matter

Pick a backend — C#, Python, Go, PHP, Ruby, even Node without a framework. The language is irrelevant. The pattern is identical: one layout file, a handful of placeholder tokens, and a function that reads files and calls replace(). That's a template engine. You can build one in an afternoon, and it'll power a full site.

The example below uses C# with .NET — specifically BasicSiteServer on Codeberg — but the concept translates directly to any backend. Swap string.Replace() for str.replace() and you're writing Python. The HTML doesn't change at all.

The problem: repeated HTML everywhere

Without templates, every page file looks something like this:

<!DOCTYPE html>
<html>
<head>
    <title>About</title>
    <link rel="stylesheet" href="/css/site.css">
</head>
<body>
    <header><a href="/">My Site</a></header>

    <main>
        <h1>About</h1>
        <p>Page content here.</p>
    </main>

    <footer><small>© 2026</small></footer>
</body>
</html>

Multiply that by ten pages. Change one nav link. Spend the afternoon touching every file. This is the problem templates exist to solve — and it has nothing to do with which language your backend is written in.

The solution: one layout file with placeholders

Pull the shared structure out into a single file. Use placeholder tokens for the parts that change per page. The server fills them in at request time.

Here's layout.html from BasicSiteServer — the entire page shell:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>My Site</title>
    <link rel="stylesheet" href="/css/site.css">
</head>
<body>
    {{HEADER}}

    <main class="main-content">
        {{CONTENT}}
    </main>

    {{FOOTER}}
</body>
</html>

The tokens {{HEADER}}, {{CONTENT}}, and {{FOOTER}} get replaced at request time with the contents of their matching HTML files. Change header.html once — every page on the site picks up the change automatically.

The important thing: these tokens are just strings. Any language that can read a file and call replace on the result can implement this in under 20 lines of code.

One implementation: C# in about 20 lines

Here's BasicSiteServer's entire template engine — two functions in Program.cs:

string RenderPage(string pageName)
{
    var layout   = SafeRead(layoutPath, "<html><body>{{CONTENT}}</body></html>");
    var header   = SafeRead(headerPath, "");
    var footer   = SafeRead(footerPath, "");
    var pageFile = Path.Combine(pagesPath, pageName + ".html");
    var content  = SafeRead(pageFile, $"<p>Page '{pageName}' not found.</p>");

    return layout
        .Replace("{{HEADER}}", header)
        .Replace("{{CONTENT}}", content)
        .Replace("{{FOOTER}}", footer);
}

string SafeRead(string path, string fallback)
{
    try
    {
        return File.Exists(path) ? File.ReadAllText(path) : fallback;
    }
    catch
    {
        return fallback;
    }
}

Read four files. Call Replace() three times. Return a string. That's the whole engine. SafeRead just prevents crashes during development if a file doesn't exist yet — a small convenience, not a framework feature.

In Python this is layout.replace("{{CONTENT}}", content). In Go it's strings.Replace(). In PHP it's str_replace(). The concept is identical across every language. Pick the one you know.

Routes: connecting URLs to pages

Routes receive a request, call RenderPage, and return the HTML string. The implementation varies by language; the job is the same everywhere.

In BasicSiteServer, a catch-all route handles any URL automatically — it looks for a matching file or falls back to a 404:

app.MapGet("/{page}", (string page) =>
{
    var filePath = Path.Combine(pagesPath, $"{page}.html");
    if (!File.Exists(filePath))
    {
        var notFoundHtml = RenderPage("404");
        return Results.Content(notFoundHtml, "text/html", statusCode: 404);
    }

    var html = RenderPage(page);
    return Results.Content(html, "text/html");
});

Want to add a contact page? Drop pages/contact.html into the folder. The route is already waiting.

The file structure

Here's how BasicSiteServer organises its files:

BasicSiteServer/
├── Program.cs              ← startup, routes, RenderPage, SafeRead
├── BasicSiteServer.csproj
└── wwwroot/
    ├── layout.html         ← full page shell with placeholders
    ├── header.html         ← shared nav/branding HTML
    ├── footer.html         ← shared footer HTML
    ├── css/
    │   └── site.css
    └── pages/
        ├── index.html      ← home page content only
        ├── about.html      ← about page content only
        └── 404.html        ← not found page

Content files only contain the inner HTML for the <main> area. Everything shared lives in the layout and partials. This structure works the same whether your backend is C#, Python, or anything else — it's just files.

How it fits together

When a browser requests /about:

  1. The route handler calls RenderPage("about")
  2. The function reads layout.html, header.html, footer.html, and pages/about.html
  3. Three replace() calls assemble them into one complete HTML document
  4. That string goes back to the browser

No virtual DOM. No hydration. No build step. The server reads files, joins strings, and sends HTML. This is what every web framework has been abstracting over for decades — it still works, and you don't need a framework to do it.

Try it yourself

Clone BasicSiteServer on Codeberg, then:

git clone https://codeberg.org/CoderB/BasicSiteServer
cd BasicSiteServer
dotnet run

Open http://localhost:5500, edit wwwroot/pages/index.html, and reload. No build step, no restart — the file is re-read on every request. From there, each improvement is additive: add more tokens, extract more partials, wire in a database. Nothing needs to be ripped out to grow.

justhtml.dev started here — and kept going

This site is built on exactly the same foundation: a layout file, placeholder tokens, string replacement. From that starting point, it grew. Tokens got richer — {{ title }}, {{ description }}, {{ user.displayName }}. Content files gained YAML front matter so each page could carry its own metadata. The engine became a service class. A database came in. Then authentication, a gallery, user profiles, a verification system that scans submitted sites for vanilla compliance.

All of it is still just HTML files, string replacement, and a backend doing the assembly work. The foundation never changed — it just got more to do.

If BasicSiteServer makes sense to you, you can read this site's codebase and follow every line. That's the point.