---
slug: 41a-build-a-website
title: Construí un sitio web
description: Un sitio web completo y con estilo en Synsema — layout, componentes con props, CSS inline con transiciones, un formulario clásico, páginas de error propias y rutas en un módulo — sin framework, un binario.
example_ids: [website]
---

# Construí un sitio web

Un sitio real — layout, componentes con estilo, un formulario de contacto, páginas de error como corresponde — **sin framework**: tu HTML, tu CSS, vanilla JS si querés. El servidor, los templates, el parseo de formularios y el pipeline de estáticos vienen incluidos.

## Los archivos

```
app.syn                 el bloque serve
shop.syn                rutas que viven en un módulo (opcional, para sitios grandes)
layouts/base.html       el chrome (head, nav, footer)
partials/nav.html       piezas compartidas
partials/card.html      un componente (recibe props)
pages/home.html         la página
static/app.css          tu stylesheet   (se recarga en caliente por request)
```

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

Un shell para todas las páginas: un `{ slot }` para el cuerpo y un **slot nombrado** para extras de `<head>` por página.

```html
<!DOCTYPE html>
<html lang="es">
<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>hecho con synsema</footer>
</body>
</html>
```

## Un componente — `partials/card.html`

`include … with` pasa **props**; el partial ve solo eso (más tus tasks):

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

## La página — `pages/home.html`

El CSS inline vive en un **bloque verbatim `{ raw }`** (las llaves quedan literales); la grilla loopea con índice y estado vacío:

```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>Muy pronto.</p>
    { end }
  </div>
  <form method="post" action="/contact">
    <input name="email" placeholder="vos@estudio.com"><button>Escribinos</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."))
```

## La 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                     -- los agentes conservan el error JSON

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"            -- un form post CLÁSICO — sin fetch, sin 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})
```

Corrélo: `synsema serve app.syn --watch`. Templates y CSS **se recargan en caliente por request** — editás, refrescás, listo; `--watch` reinicia el server cuando cambia el `.syn`. Un typo en un path de `render("…")` falla **al arranque**, no como un 500.

## Lo que te llevás gratis

- **Auto-escape en todos lados** (XSS-safe por defecto); `json_for_script` hace seguros también los datos de `<script>`.
- **Componentes de verdad** — `include … with {props}` es aislado; nada de globals filtrándose a los partials.
- **Páginas de error con status honestos** — la página 404 sale CON 404 (nada de soft-404); un 401 puede dar `give redirect("/login")`.
- **Formularios sin JavaScript** — `form of request` parsea urlencoded y multipart (los archivos llegan como bytes exactos).
- **Estáticos de producción** — ETag/304, Range, gzip, más tu política `cache "1h"`; agregá `fallback "index.html"` para una SPA.

## Cuando el sitio crece: rutas en módulos

Mové grupos enteros de rutas fuera de `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              -- o: mount shop.shop at "/store"
```

Los cuerpos montados llaman a los helpers **privados** del módulo directo — mirá [Módulos](14-modules).

## Siguiente

- **[Frontend](41-frontend)** — la referencia completa de templates (incluido `content()` para páginas legibles por agentes).
- **[Servidor HTTP](40-serve)** — auth, sesiones, SSE, rate limiting, TLS/auto-HTTPS.
- **[Construí una API REST](43-build-api)** — el lado JSON del mismo servidor.
