---
slug: 41a-build-a-website
title: Build a website
description: A complete, styled website in Synsema — layout, components with props, inline CSS with transitions, a classic form, custom error pages, and routes split into a module — no framework, one binary.
example_ids: [website]
---

# Build a website

A real website — layout, styled components, a contact form, proper error pages — with **no framework**: your HTML, your CSS, vanilla JS if you want it. The server, the templates, the form parsing and the static pipeline are built in.

## The files

```
app.syn                 the serve block
shop.syn                routes that live in a module (optional, for bigger sites)
layouts/base.html       the chrome (head, nav, footer)
partials/nav.html       shared pieces
partials/card.html      a component (takes props)
pages/home.html         the page
static/app.css          your stylesheet   (hot-reloads per request)
```

## The layout — `layouts/base.html`

One shell for every page: a `{ slot }` for the body and a **named slot** for per-page `<head>` extras.

```html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{ title }</title>
<link rel="stylesheet" href="/assets/app.css">
{ slot "head_extra" }
</head>
<body>
{ include "partials/nav.html" }
{ slot }
<footer>made with synsema</footer>
</body>
</html>
```

## A component — `partials/card.html`

`include … with` passes **props**; the partial sees only them (plus your tasks):

```html
<article class="card"><h2>{ n }. { name }</h2><p>{ blurb }</p></article>
```

## The page — `pages/home.html`

Inline CSS lives in a **verbatim `{ raw }` block** (braces stay literal); the grid loops with an index and an empty state:

```html
{ layout "layouts/base.html" }
{ fill "head_extra" }<style>{ raw }
  .hero { padding: 4rem 2rem; }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 1rem; }
  .card { border-radius: 12px; padding: 1.25rem; background: #1a1d24;
          transition: transform .2s ease, box-shadow .2s ease; }
  .card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgb(0 0 0 / .4); }
{ end }</style>{ end }
<main class="hero">
  <h1>{ title }</h1>
  <div class="grid">
    { each e in enumerate(services) }
    { include "partials/card.html" with {"n": e.index + 1, "name": e.item.name, "blurb": e.item.blurb} }
    { otherwise }
    <p>Coming soon.</p>
    { end }
  </div>
  <form method="post" action="/contact">
    <input name="email" placeholder="you@studio.com"><button>Get in touch</button>
  </form>
</main>
<script>window.__SERVICES__ = { raw json_for_script(services) };</script>
```

```synsema
-- Doc example: the composition core of "Build a website" — a base layout with a named
-- slot, a nav partial, a card component with props, and a page that fills them all.
-- Self-contained: writes the templates, renders the page, asserts the assembled HTML.
intent: "doc example: website composition (layout + slots + components)"
require file.write("_doctest_site_base.html")
require file.read("_doctest_site_base.html")
require file.write("_doctest_site_nav.html")
require file.read("_doctest_site_nav.html")
require file.write("_doctest_site_card.html")
require file.read("_doctest_site_card.html")
require file.write("_doctest_site_home.html")
require file.read("_doctest_site_home.html")

-- layouts/base.html — the chrome: <head> with a named slot, nav, the page slot
write_file("_doctest_site_base.html", "<html><head><title>{ title }</title>{ slot \"head_extra\" }</head><body>{ include \"_doctest_site_nav.html\" }{ slot }</body></html>")
-- partials/nav.html
write_file("_doctest_site_nav.html", "<nav><a href=\"/\">Studio</a></nav>")
-- partials/card.html — a COMPONENT: sees only its props
write_file("_doctest_site_card.html", "<article class=\"card\"><h2>{ n }. { name }</h2></article>")
-- pages/home.html — inline CSS via the verbatim { raw } block + the grid of cards
write_file("_doctest_site_home.html", "{ layout \"_doctest_site_base.html\" }{ fill \"head_extra\" }<style>{ raw }.card { border-radius: 12px; transition: transform .2s ease; }{ end }</style>{ end }<main><h1>{ title }</h1>{ each e in enumerate(services) }{ include \"_doctest_site_card.html\" with {\"n\": e.index + 1, \"name\": e.item} }{ otherwise }<p>Coming soon.</p>{ end }</main>")

let html be body of render("_doctest_site_home.html", {"title": "Studio", "services": ["Branding", "Web"]})
print(html)

test "the page assembles: layout chrome + named slot + components with props"
    let html be body of render("_doctest_site_home.html", {"title": "Studio", "services": ["Branding", "Web"]})
    assert(contains(html, "<title>Studio</title>"))
    assert(contains(html, ".card { border-radius: 12px"))          -- verbatim CSS intact, in <head>
    assert(contains(html, "<nav><a href=\"/\">Studio</a></nav>"))  -- partial
    assert(contains(html, "<h2>1. Branding</h2>"))                 -- component props + index
    assert(contains(html, "<h2>2. Web</h2>"))

test "the empty state renders the { otherwise } branch"
    let html be body of render("_doctest_site_home.html", {"title": "Studio", "services": []})
    assert(contains(html, "Coming soon."))
```

## The app — `app.syn`

```synsema
require serve(8080)

task services()
    give [{"name": "Branding", "blurb": "Identity systems"},
          {"name": "Web", "blurb": "Sites that convert"}]

task error_page(status, message, request)
    let accept be accept of (headers of request)
    when accept == nothing
        set accept to ""
    when contains(accept, "text/html")
        give render("pages/error.html", {"status": status, "message": message})
    give nothing                     -- agents keep the JSON error

serve on 8080
    errors with error_page

    static "/assets" from "./static" cache "1h"

    route "GET /"
        give render("pages/home.html", {"title": "Studio", "services": services()})

    route "POST /contact"            -- a CLASSIC form post — no fetch, no JSON
        let f be form of request
        when not contains(keys(f), "email")
            give fail(422, "missing email")
        give render("pages/thanks.html", {"email": f.email})
```

Run it: `synsema serve app.syn --watch`. Templates and CSS **hot-reload per request** — edit, refresh, done; `--watch` restarts the server when the `.syn` changes. A typo in a `render("…")` path fails **at startup**, not as a 500.

## What you got for free

- **Auto-escaping everywhere** (XSS-safe by default); `json_for_script` makes `<script>` data safe too.
- **Real components** — `include … with {props}` is isolated; no globals leaking into partials.
- **Custom error pages with honest status codes** — the 404 page ships WITH a 404 (no soft-404s); a 401 can `give redirect("/login")`.
- **Forms without JavaScript** — `form of request` parses urlencoded and multipart (file uploads arrive as exact bytes).
- **Production statics** — ETag/304, Range, gzip, plus your `cache "1h"` policy; add `fallback "index.html"` for an SPA.

## When the site grows: routes in modules

Move whole route groups out of `app.syn`:

```synsema
-- shop.syn
export routes shop
    route "GET /shop"
        give render("pages/shop.html", {"items": catalog()})

-- app.syn
use "./shop.syn" as shop
serve on 8080
    mount shop.shop              -- or: mount shop.shop at "/store"
```

Mounted bodies call the module's **private** helpers directly — see [Modules](14-modules).

## Next

- **[Frontend](41-frontend)** — the full template reference (`content()` for agent-readable pages included).
- **[HTTP server](40-serve)** — auth, sessions, SSE, rate limiting, TLS/auto-HTTPS.
- **[Build a REST API](43-build-api)** — the JSON side of the same server.
