---
slug: 41-frontend
title: Frontend
description: Server-side HTML in Synsema — render() templates with verbatim CSS/JS blocks, components with props, named slots, safe script data, content() for agents, and static assets with cache policy.
example_ids: [frontend]
---

# Frontend

Synsema serves HTML from the server (SSR) — no imposed framework or CSS. Two paths: **`render()`** templates (full design control) and **`content()`** (agent-negotiable). For the end-to-end walkthrough (layout, styling, forms, error pages), see [Build a website](41a-build-a-website).

```synsema
-- Doc example: render() templates. HTML with { holes }, loops with an empty branch,
-- chained conditionals, VERBATIM blocks for inline CSS/JS, components with props,
-- named slots, and safe JSON-for-<script>. Auto-escaped by default (XSS-safe).
-- Self-contained: writes tiny templates, then renders them (runs anywhere, sandboxed).
intent: "doc example: frontend render()"
require file.write("_doctest_card.html")
require file.read("_doctest_card.html")
require file.write("_doctest_page.html")
require file.read("_doctest_page.html")
require file.write("_doctest_base.html")
require file.read("_doctest_base.html")

write_file("_doctest_card.html", "<div class=\"card\"><h2>{ title }</h2><ul>{ each tag in tags }<li>{ tag }</li>{ end }</ul></div>")

print(body of render("_doctest_card.html", {"title": "Synsema", "tags": ["fast", "secure"]}))

test "render fills holes, loops, and HTML-escapes by default (XSS-safe)"
    let html be body of render("_doctest_card.html", {"title": "A <b> tag", "tags": ["x", "y"]})
    assert(contains(html, "<h2>A &lt;b&gt; tag</h2>"))     -- auto-escaped
    assert(contains(html, "<li>x</li><li>y</li>"))         -- { each } loop

test "verbatim { raw } block: inline CSS/JS with literal braces"
    write_file("_doctest_page.html", "<style>{ raw }.card { color: red; }{ end }</style>{ when n == 1 }one{ otherwise when n == 2 }two{ otherwise }many{ end }")
    let html be body of render("_doctest_page.html", {"n": 2})
    assert(contains(html, ".card { color: red; }"))        -- braces intact (no holes inside)
    assert(contains(html, "two"))                          -- { otherwise when } chains

test "each empty branch + enumerate for indexes"
    write_file("_doctest_page.html", "{ each e in enumerate(xs) }[{ e.index }:{ e.item }]{ otherwise }empty{ end }")
    assert_eq(body of render("_doctest_page.html", {"xs": ["a", "b"]}), "[0:a][1:b]")
    assert_eq(body of render("_doctest_page.html", {"xs": []}), "empty")

test "include with props = a component (isolated scope)"
    write_file("_doctest_card.html", "<b>{ label }</b>")
    write_file("_doctest_page.html", "{ include \"_doctest_card.html\" with {\"label\": item} }")
    assert_eq(body of render("_doctest_page.html", {"item": "Props!"}), "<b>Props!</b>")

test "layout + named slot: { slot \"x\" } filled by { fill \"x\" }"
    write_file("_doctest_base.html", "<head>{ slot \"extra\" }</head><main>{ slot }</main>")
    write_file("_doctest_page.html", "{ layout \"_doctest_base.html\" }{ fill \"extra\" }<meta n=\"{ t }\">{ end }<h1>{ t }</h1>")
    let html be body of render("_doctest_page.html", {"t": "Hi"})
    assert(contains(html, "<head><meta n=\"Hi\"></head>"))
    assert(contains(html, "<main>") and contains(html, "<h1>Hi</h1>"))

test "json_for_script: data into an inline <script> without XSS"
    let js be json_for_script([{"name": "a</script>"}])
    assert(contains(js, "a\\u003c/script\\u003e"))         -- cannot close the tag
    assert(not contains(js, "</script>"))
    -- json_encode is for APIs/storage; json_for_script is for <script> embedding.
```

## `render()` — free-form templates

`render("page.html", data)` returns an HTML response; `body of render(...)` is the string. Templates are HTML with `{ ... }` holes:

```synsema
route "GET /"
    give render("pages/home.html", {"title": "My App", "items": items})
```

- `{ name }` / `{ expr }` interpolate (HTML-escaped; expressions can call builtins and your own tasks — define tasks **before** the `serve` block); `{ raw expr }` opts out for trusted HTML.
- **`{ raw }` … `{ end }` is a VERBATIM block** — inline CSS/JS with literal braces: `<style>{ raw } .card { color: red; } { end }</style>`.
- `{ each x in xs } … { otherwise } … { end }` — loop with an optional **empty-list branch**; indexes via `enumerate(xs)` (`{ e.index }` / `{ e.item }`). A non-list is a hard error.
- `{ when c } … { otherwise when c2 } … { otherwise } … { end }` — chained, like the language.
- **Compose:** `{ include "partials/nav.html" }` (current scope) or `{ include "partials/card.html" with {"title": t} }` (**a component with isolated props**); `{ layout "layouts/base.html" }` with `{ slot }`, plus **named slots** — `{ slot "head_extra" }` in the layout, `{ fill "head_extra" }…{ end }` in the page.
- `{ -- comment }` emits nothing. Template paths (including `include`/`layout`) resolve against the working directory.
- **Data into an inline `<script>`:** `{ raw json_for_script(x) }` — never `json_encode` there (a value containing `</script>` would break out of the tag).

Templates are parse-cached and **hot-reload per request** (edit → refresh, no restart); `render("literal.html")` paths are validated at startup and by `synsema check`. For the `.syn` itself, `synsema serve app.syn --watch` restarts on change.

**Capability (engine v0.6.14+):** rendering a template read from **disk** is a file read, so the
top-level `render(path)` needs `require file.read("<path>")` (or `require file("templates/*")` for a
tree), just like `read_file` — this closes reading arbitrary files through a request-derived path.
Nested `{ include }`/`{ layout }` don't need their own declaration (they're static and confined to the
working directory), and a template **baked into a `synsema build` binary** needs no capability at all
(it's part of the program). One line covers a template tree: `require file.read("templates/*")`.

**Installable on phones and desktop (engine v0.6.15+):** the same rendered site becomes an app — home-screen icon, full screen, offline shell, push notifications — with a manifest, a service worker and the `<head>` lines that `synsema init --pwa` writes for you. See **[Your app on the phone (PWA)](41b-pwa)**.

## `content()` — agent-negotiable pages

Build a semantic tree once; the runtime serves **HTML to humans and Markdown/JSON to agents** (by `Accept` header or a `.md`/`.json` suffix) — ideal for docs/blogs an LLM should read.

```synsema
route "GET /docs/:slug"
    give content(page([heading(1, "Title"), prose("…"), code("let x be 1", "synsema")], {
        "title": "Title", "description": "…", "stylesheet": "/assets/app.css"
    }))
```

## Static assets & client JS

The default is a **static mount** with production behavior (ETag/304, Range, gzip) plus your cache policy and SPA support:

```synsema
static "/assets" from "./static" cache "1h"          -- Cache-Control per mount ("immutable" for fingerprinted)
static "/app" from "./dist" fallback "index.html"    -- SPA history-fallback (try_files)
```

The client is unrestricted — vanilla JS, htmx, or any framework. Classic `<form method="post">` posts arrive as **`form of request`** (urlencoded and multipart, file uploads as exact bytes) — no fetch/JSON required. Dynamic responses (render/html/content/JSON) gzip automatically.

**Gotcha — declared routes beat `static` mounts.** If your app has wildcard routes (e.g. `GET /:lang/:slug`), a `static` mount is never reached, so you serve the assets from a **declared route** instead. Serve **text** (css/js/svg) with `respond`, and **binary** (images, fonts) with `read_file_bytes` + `binary` — plain `read_file` decodes UTF-8 and would corrupt a PNG:

```synsema
route "GET /assets/*path"
    let p be "static/" + params.path
    when not file_exists(p)
        give not_found("asset not found")
    when ends_with(p, ".png")
        give binary(read_file_bytes(p), "image/png")   -- byte-exact
    give respond(read_file(p), "text/css; charset=utf-8")
```

This very docs site is built exactly this way: its wildcard routes (`/:lang/:version/:slug`) force a declared `/assets/*path` route, and the Open Graph preview image is served with `binary(read_file_bytes(...), "image/png")`.

## Error pages and routes-in-modules

- **`errors with <task>`** on the serve block shapes 401/404/405/500 — HTML pages for browsers, the JSON default for agents, `redirect("/login")` for a 401. The error status is always preserved (no soft-404s). See [HTTP server](40-serve).
- A site's routes can live in **modules**: `export routes shop` in `shop.syn`, then `mount shop.shop` (optionally `at "/store"`) in the serve block — bodies call the module's private helpers directly. See [Modules](14-modules).

## Suggested project structure

```
app.syn          serve block: mounts, static, errors with
shop.syn         a module with `export routes`
layouts/         base.html (chrome with { slot } / { slot "head_extra" })
partials/        nav.html, card.html (components; card takes props via `with`)
pages/           home.html, … (use a layout + { fill })
static/          app.css, app.js, img/
```

## Charts

Server-side SVG charts and agent-readable `chart()` content nodes are built in — see [CSV, stats & charts](37-dataviz).
