← Methodology

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 — 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.

justhtml.dev started here

This site is built on exactly the engine described above. The layout file, the token replacement, the pages folder — it's all running in production. The code you read on this page is the code that served you this page.

If you want a production-ready version of this setup in C# or Rust — with Docker support, a 404 middleware, environment-based configuration, and a full deployment guide — it's packaged as BasicSiteServer.

Get BasicSiteServer →