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.
<!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):
<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:
{ 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>
-- 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
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_scriptmakes<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 requestparses urlencoded and multipart (file uploads arrive as exact bytes). - Production statics — ETag/304, Range, gzip, plus your
cache "1h"policy; addfallback "index.html"for an SPA.
When the site grows: routes in modules
Move whole route groups out of app.syn:
-- 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.
Next
- Frontend — the full template reference (
content()for agent-readable pages included). - HTTP server — auth, sessions, SSE, rate limiting, TLS/auto-HTTPS.
- Build a REST API — the JSON side of the same server.