---
title: 'Server-Side Templating'
source: 'https://academia.sh/en/courses/server-fundamentals/server-side-templating'
course: 'Server-Side Fundamentals'
language: en
updated: '2026-08-23T07:00:29+00:00'
license: 'CC BY-SA 4.0'
---

# Server-Side Templating

Building the view from data on the server: the template engine's parse and render steps, escaping as the default, raw output requested by an explicit marker, the difference the attribute context makes, and separating the layout template from the page template.

The previous lesson served files sitting on disk. The catalog page was one of those files,
but it did not carry its own content: the document went out as an empty container, and the
book list was built in the browser by a script. Since the server can already read the book
records, it can write that list into the document itself.

The mechanism that performs this merge is called a **template**: text with the places where
a value will go marked out. The program that merges a template with data is called a
**template engine**. This lesson centers on a single question: what happens when a piece of
text coming from data is placed inside markup?

## The Template's Two Steps

The engine does its work in two steps. First the template text is **parsed**: plain-text
fragments are separated from placeholders, and block tags build a nested structure. Then
this structure is walked against the data and **rendered**.

The two steps being separate is a requirement, not a stylistic choice. Parsing looks at the
template and never at the data; rendering looks at the data and never reads the template
text again. This lets the same template be parsed once and rendered a hundred thousand
times, and more importantly, **the content of the data can never alter the template's
structure under any circumstance**. A piece of text coming from data can look exactly like
a block tag, but it is never interpreted as one during rendering, because parsing has
already finished.

The engine below recognizes four tags: `{{ name }}` writes the value with escaping,
`{{! name }}` writes it raw, `{{# name }}` through `{{/ name }}` iterates over a list, and
`{{^ name }}` through `{{/ name }}` runs when the value is empty.

```js
// template.mjs — a small template engine that makes escaping the default
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
export const escape = (value) => String(value).replace(/[&<>"']/g, (c) => ESCAPES[c]);

// {{ name }} escapes, {{! name }} does not, {{# name }}..{{/ name }} iterates,
// {{^ name }}..{{/ name }} runs when the value is empty.
const TAG = /\{\{\s*([#^/!]?)\s*([\w.]+)\s*\}\}/g;

const resolve = (stack, name) => {
  if (name === ".") return stack[stack.length - 1];
  const parts = name.split(".");
  for (let i = stack.length - 1; i >= 0; i--) {
    let value = stack[i];
    if (value === null || typeof value !== "object") continue;
    if (!(parts[0] in value)) continue;
    for (const p of parts) value = value?.[p];
    return value;
  }
  return undefined;
};

// Converts the template to a tree in a single pass: string fragments and block nodes.
const parse = (text) => {
  const root = { children: [] };
  const stack = [root];
  let last = 0;
  for (const m of text.matchAll(TAG)) {
    const [whole, kind, name] = m;
    const parent = stack[stack.length - 1];
    if (m.index > last) parent.children.push(text.slice(last, m.index));
    last = m.index + whole.length;
    if (kind === "#" || kind === "^") {
      const node = { kind, name, children: [] };
      parent.children.push(node);
      stack.push(node);
    } else if (kind === "/") {
      if (stack.length === 1 || stack[stack.length - 1].name !== name)
        throw new Error(`unclosed block: ${name}`);
      stack.pop();
    } else {
      parent.children.push({ kind, name });
    }
  }
  if (stack.length !== 1) throw new Error(`unclosed block: ${stack[stack.length - 1].name}`);
  root.children.push(text.slice(last));
  return root;
};

const render = (node, stack) =>
  node.children.map((c) => {
    if (typeof c === "string") return c;
    const value = resolve(stack, c.name);
    if (c.kind === "#") {
      const list = Array.isArray(value) ? value : value ? [value] : [];
      return list.map((item) => render(c, [...stack, item])).join("");
    }
    if (c.kind === "^") {
      const empty = Array.isArray(value) ? value.length === 0 : !value;
      return empty ? render(c, stack) : "";
    }
    if (value === undefined || value === null) return "";
    return c.kind === "!" ? String(value) : escape(value);
  }).join("");

export const build = (text, data) => render(parse(text), [data]);
```

Name resolution runs over a scope stack: entering a block pushes that iteration's item
onto the stack, a name is looked up in the innermost scope first, and the search moves
outward when it is not found there. This is what lets code inside an iteration block still
reach fields from the outer scope.

## Escaping Is the Default

**Escaping** converts characters that carry structural meaning in the markup language into
their **character entity** form. When this conversion happens, text coming from data stays
text in the output; when it does not, that text becomes part of the markup.

Escaping is the default in the engine, and raw output is obtained only by writing
`{{! name }}` — an explicit decision made by whoever writes the template. The order could
have been reversed; what matters is what happens when one of them is forgotten. If escaping
is the default, forgetting the raw marker costs a visible oddity. If raw output is the
default, forgetting to escape costs data-borne text bleeding into the document's structure.

The block below feeds the same data to two placeholders at once and shows the difference.
The marked-up text is harmless: a highlight element.

```js
// compare.mjs — compares escaped and unescaped rendering with the same data
import { build } from "./template.mjs";

const TEMPLATE = `<ul class="catalog">
{{# books }}  <li>{{ title }} — <span class="author">{{ author }}</span></li>
{{/ books }}{{^ books }}  <li class="empty">No results</li>
{{/ books }}</ul>
<p class="note">{{ note }}</p>
<p class="note-raw">{{! note }}</p>`;

const DATA = {
  books: [
    { title: "In Search of Lost Time", author: "Marcel Proust" },
    { title: "Snow & Ice <archive>", author: "Branch 3" },
  ],
  note: "<mark>reserved</mark>",
};

const output = build(TEMPLATE, DATA);
console.log(output);
console.log("---");
console.log("output bytes:", Buffer.byteLength(output));
console.log("mark element count:", (output.match(/<mark>/g) ?? []).length);
console.log("--- empty list ---");
console.log(build(TEMPLATE, { books: [], note: "none" }));
```

```
<ul class="catalog">
  <li>In Search of Lost Time — <span class="author">Marcel Proust</span></li>
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Branch 3</span></li>
</ul>
<p class="note">&lt;mark&gt;reserved&lt;/mark&gt;</p>
<p class="note-raw"><mark>reserved</mark></p>
---
output bytes: 289
mark element count: 1
--- empty list ---
<ul class="catalog">
  <li class="empty">No results</li>
</ul>
<p class="note">none</p>
<p class="note-raw">none</p>
```

The same data produced two different results. In the escaped placeholder, the marked-up
text is **text meant to be read on screen**; in the raw placeholder, it is **an element
inserted into the document**. The count confirms this: the output has exactly one
highlight element, and it came from the raw placeholder.

The `&` and the angle brackets in the book title were escaped as well. This is a
correctness matter before it is a security one: without escaping, part of the title would
not have appeared on screen at all, because the markup parser would have read it as the
start of a tag.

## Text Context and Attribute Context

A single escaping function does not know where in the document a value lands. In a value
placed inside text, angle brackets matter; in a value placed inside an attribute, the
**quote character** matters. The function above converts both, so it produces a correct
result in either context, but why that is necessary still needs to be shown.

```js
// attribute.mjs — escaping's effect on an attribute value
import { build } from "./template.mjs";

const ESCAPED = `<a class="branch" title="{{ name }}" href="/branch/{{ code }}">Branch</a>`;
const RAW = `<a class="branch" title="{{! name }}" href="/branch/{{ code }}">Branch</a>`;
const DATA = { name: 'Central "Old Books" Hall', code: "s3" };

// The parser ends the attribute value at the first closing quote; the pattern below
// applies the same rule and shows where the value gets cut.
const titleValue = (text) => text.match(/title="([^"]*)"/)[1];

for (const [name, template] of [["escaped", ESCAPED], ["raw", RAW]]) {
  const output = build(template, DATA);
  console.log(name);
  console.log("  output:", output);
  console.log("  title :", JSON.stringify(titleValue(output)));
}
```

```
escaped
  output: <a class="branch" title="Central &quot;Old Books&quot; Hall" href="/branch/s3">Branch</a>
  title : "Central &quot;Old Books&quot; Hall"
raw
  output: <a class="branch" title="Central "Old Books" Hall" href="/branch/s3">Branch</a>
  title : "Central "
```

In the raw output, the attribute value ends at **"Central "**: the quote in the branch
name closed the attribute. The remaining words are no longer part of the value; they
become something else in the tag's body. In the escaped output, by contrast, the value
stays whole, and the parser reads the character entities back into the full branch name.

The rule that follows is this: attribute values are **always placed in quotes**, and a
value placed in a template is stripped of characters that could close that quote.
Unquoted attribute syntax is never used in this scheme, because it turns even characters
the escaping function does not convert — whitespace, for instance — into structural ones.

## Layout and Page

Every page's document skeleton is the same: the same language declaration, the same head
element, the same body frame. Instead of copying this shared part into every template, a
**layout template** is written once, and the page's own output is placed inside it. That
placement uses the single raw placeholder inside the layout — because what goes there is
not data, it is output that has already been escaped once.

The data structure the template sees is called the **view model**. The view model is the
contract between the data source and the template: the template does not know where the
records came from, and the code that reads the records does not know the markup.

```js
// view.mjs — builds and serves the catalog page on the server (same directory as template.mjs)
import { createServer } from "node:http";
import { build } from "./template.mjs";

const BOOKS = [
  { title: "In Search of Lost Time", author: "Marcel Proust", branch: "Central" },
  { title: "The Disconnected", author: "Oğuz Atay", branch: "Branch 3" },
  { title: "Snow & Ice <archive>", author: "Compilation", branch: "Branch 3" },
];

const LAYOUT = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>{{ title }}</title></head>
<body><h1>{{ title }}</h1>{{! content }}</body></html>
`;

const CATALOG = `<p class="summary">Records: {{ count }}</p>
<ul class="catalog">
{{# books }}  <li>{{ title }} — <span class="author">{{ author }}</span> ({{ branch }})</li>
{{/ books }}{{^ books }}  <li class="empty">No results</li>
{{/ books }}</ul>`;

// View model: the single data structure the template sees. The query and the
// records are merged here; the template itself never knows the data source.
const viewModel = (query) => {
  const q = (query ?? "").toLocaleLowerCase("en-US");
  const books = q ? BOOKS.filter((b) => b.title.toLocaleLowerCase("en-US").includes(q)) : BOOKS;
  return { books, count: books.length };
};

createServer((request, response) => {
  response.sendDate = false;
  const address = new URL(request.url, "http://local");
  if (address.pathname !== "/catalog") return response.writeHead(404).end();

  const content = build(CATALOG, viewModel(address.searchParams.get("q")));
  const document = build(LAYOUT, { title: "Library Catalog", content });

  response.setHeader("Content-Type", "text/html; charset=utf-8");
  response.setHeader("Content-Length", Buffer.byteLength(document));
  response.writeHead(200).end(document);
}).listen(8311, "127.0.0.1", () => console.log("listening: 127.0.0.1:8311"));
```

```bash
#!/usr/bin/env bash
# Starts view.mjs, shows the generated document and the filtered result, then stops it.
node view.mjs > /dev/null &
server=$!
sleep 1
A=http://127.0.0.1:8311

echo "--- full catalog ---"
curl -sS "$A/catalog"
echo "--- filter: q=snow ---"
curl -sS "$A/catalog?q=snow"
echo "--- non-matching query ---"
curl -sS "$A/catalog?q=zzz" | grep -c 'class="empty"'

kill "$server"
```

```
--- full catalog ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title></head>
<body><h1>Library Catalog</h1><p class="summary">Records: 3</p>
<ul class="catalog">
  <li>In Search of Lost Time — <span class="author">Marcel Proust</span> (Central)</li>
  <li>The Disconnected — <span class="author">Oğuz Atay</span> (Branch 3)</li>
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Compilation</span> (Branch 3)</li>
</ul></body></html>
--- filter: q=snow ---
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Library Catalog</title></head>
<body><h1>Library Catalog</h1><p class="summary">Records: 1</p>
<ul class="catalog">
  <li>Snow &amp; Ice &lt;archive&gt; — <span class="author">Compilation</span> (Branch 3)</li>
</ul></body></html>
--- non-matching query ---
1
```

The empty-result branch was tested as well: for the non-matching query, the number of
lines containing `class="empty"` is one. This confirms that the empty list was handled in
the template; if it had not been, the output would have kept a bodyless list item.

Port 8311 is arbitrary and must be free.

## The Load a Template Cannot Carry

A template is a view tool, and it breaks down as its load grows. Three limits are
decisive in practice.

**Computation does not happen in the template.** The day count on an overdue loan, the
fine amount, or a sort criterion is computed in the view model. If a template computes it
instead, the same rule starts producing two different results on two pages and becomes
untestable.

**Data access does not happen in the template.** If a query could be run from inside an
iteration block, a page would spawn as many queries as it has rows. The view model brings
in everything that is needed before rendering starts.

**Raw output must stay countable.** The raw placeholder is the one point in the system
that needs auditing. As its count shrinks and it stays confined to places where only
already-rendered output is inserted, the template layer's correctness stays something the
eye can verify.

## Summary

- The template engine first parses the template, then renders it against the data;
  because the data's content arrives after parsing has finished, it can never alter the
  template's structure.
- Escaping converts characters with structural meaning in markup into character entities
  and should be the default; raw output is requested by the template author's explicit
  decision.
- The measurement separates two contexts: in the text context an unescaped value inserts
  an element into the document, while in the attribute context it cuts the value off at
  the first quote.
- The layout template carries the shared document skeleton; the page output is placed
  into it through a single raw placeholder, because what is placed there is not data but
  output that has already been rendered.
- The view model is the contract between the template and the data source; computation
  and data access happen in the code that builds the view model, not in the template.

## Next Step

Up to this point, data has always come from the inside: files on disk, records in memory.
In the library service's real operation, data also comes from the outside — a member's
photo, the cover image of a donated book, a count file uploaded from a branch. As this
content arrives at the server, its size is not known in advance, its declared type may not
match its actual content, and where it gets written is a decision the server makes. The
next lesson takes up uploading with these three questions: when and how does the server
reject a payload that exceeds its limit, how does it decide the file's type without
looking at the extension, and why is the written file kept separate from the served
directory?
