Source: handbook/ in the repository. Every code block compiles: CI runs ray check on each one.

Server-rendered site

The notes, now as a website rendered on the server. Every page is HTML that the server builds with compiled raylang templates; the browser runs no JavaScript at all. Forms post to the server, which saves and redirects. The notes are stored as files, one JSON file per note.

This design fits when content matters more than interaction: pages that are read, forms, admin panels. The first response is already the whole page, it works without JavaScript and there is no frontend to build. For an interface with a lot of state in the browser, the site with a React frontend chapter is the other option.

The complete project is in examples/apps/notes-ssr, with its tests. The raylang blocks are copied from it and CI checks that they still are.

1. The project

ray new notes-ssr && cd notes-ssr
ray add web

ray add web downloads the framework (and net, which it depends on) and declares it in ray.toml. The [native] section is added by hand:

[package]
name = "notes-ssr"
version = "0.1.0"

# Files baked into the native binary (served by `static_embedded`).
[native]
embed = ["static"]

[dependencies]
web = "^0.6.0"
notes-ssr/
├── ray.toml
├── ray.lock            # pinned versions of web and net
├── src/
│   ├── main.ray          # the server: routes and handlers
│   ├── pages.ray         # the pages as pure functions
│   ├── store.ray         # the notes as files
│   └── views/            # the .ray.html templates
├── static/app.css
└── tests/pages_test.ray

web is the application framework, in the style of Express, and runs on the HTTP server of the net package. The exact versions are pinned in ray.lock. The framework guide (Spanish) has every detail.

These are the site's routes. An HTML form can only send GET and POST, so editing and deleting are POST too:

Route What it does Answers
GET / the list of notes; ?q= searches 200
GET /notes/new the empty form 200
POST /notes creates a note 303 to the note, or 422 with the form
GET /notes/:id one note, its Markdown already converted 200, 404
GET /notes/:id/edit the form with the note 200, 404
POST /notes/:id saves the changes 303 to the note, or 422
POST /notes/:id/delete deletes 303 to /
GET /assets/… the stylesheet 200, 304

2. The server

The application is built by a top-level function. The server runs every connection in its own fiber, with isolated memory, and each fiber calls that function to get its own copy of the app. That is why the same code runs the same on the VM and compiled to a native binary.

fn build_app() -> App {
    var app = new_app();
    app.log_requests();
    app.use_mw(same_origin);
    // `static/` is embedded: read live from disk under `ray run`, baked into the native binary.
    app.static_embedded("/assets/", "static");
  • log_requests() writes one JSON line per request, with method, path, status and duration.
  • use_mw(same_origin) registers a middleware: it runs before the routes and can stop the request (section 6).
  • static_embedded serves static/ under /assets/, with ETag and 304.

The line log_requests() writes goes to standard output, ready for a log collector:

{"ts":"2026-10-02T00:48:47Z","level":"INFO","service":"web","trace_id":"9fe1bc0fa334f19c853116ea16dc31cd","msg":"request","method":"POST","path":"/notes","status":303,"ms":0}

A handler receives two values: c, the request, and r, the response it builds.

To read the request Returns
c.param("id") the :id segment of the route
c.query("q") a URL parameter, or "" if absent
c.form_field("title") a field of the submitted form, or ""
c.header_of("origin") a header, or ""
c.json_body() the body as JSON, in a Result
To answer Does
r.html(text), r.text(text), r.json(text) sets the body and its content type
r.status(422) sets the status; it chains: r.status(422).html(…)
r.header(name, value) adds a header
r.redirect(url) redirects

A route reads the request and returns a page. This one creates a note from the form fields:

    app.POST("/notes", fn(c: Ctx, r: Res) {
        let title = c.form_field("title");
        let body = c.form_field("body");
        match (store.save(notes(), "", title, body, time.now())) {
            Result.Ok(n) => go(r, "/notes/" + n.id),
            Result.Err(e) => r.status(422).html(pages.edit_page("/notes", title, body, e)),
        }
    });

If the note is not valid, the answer is the same form with the error and what the user typed, with status 422. If it is saved, the browser gets a redirect:

// After a POST, go to a page with GET (303 See Other).
fn go(r: Res, url: string) {
    r.redirect(url);
    let _ = r.status(303);
}

This is the POST → redirect → GET pattern: after submitting the form the browser lands on a normal page, and reloading it does not submit anything again.

3. The templates

A .ray.html template is a raylang module. Its first line declares its parameters with types, and the compiler turns it into a render(...) function. A mistake in a template variable is a compile error, not an empty hole in the page.

The layout defines the common structure and a slot, content:

{% params title: string %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} · Notes</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<header class="top">
    <a class="brand" href="/">Notes</a>
    <a class="button" href="/notes/new">New note</a>
</header>
<main>
{% block content %}
{% endblock %}
</main>
</body>
</html>

The home page inherits from the layout, receives the typed list of notes ([store.Note]) and includes a partial template for each note:

{% params title: string, query: string, notes: [store.Note] %}
{% extends layout %}
{% import store %}
{% block content %}
<form class="search" method="get" action="/">
    <input type="search" name="q" value="{{ query }}" placeholder="Search notes">
</form>
{% if notes.len() == 0 %}
<p class="empty">{% if query == "" %}No notes yet.{% else %}No notes match “{{ query }}”.{% endif %}</p>
{% else %}
<ul class="notes">
    {% for n in notes %}
    {% include views/card(n) %}
    {% endfor %}
</ul>
{% endif %}
{% endblock %}
{% params n: store.Note %}
{% import store %}
<li>
    <a href="/notes/{{ n.id }}">
        <strong>{{ n.title }}</strong>
        <span>{{ n.body.split("\n")[0] }}</span>
    </a>
</li>
  • {{ expr }} escapes HTML: a note titled <b>hi</b> shows exactly that, as text.
  • {% import store %} brings in the module to use its Note type in the parameters.
  • {% extends layout %} goes right after {% params %} and resolves next to the template; {% include views/card(n) %} resolves from src/.

All the tags:

Tag What it does
{% params a: T, b: U %} the first line: the parameters of render, with their types
{{ expr }} writes the value, with HTML escaped
{{& expr }} writes the value as it is, unescaped
{% if c %} … {% elif c %} … {% else %} … {% endif %} conditional
{% for x in xs %} … {% endfor %} loop
{% let n = expr %} a local variable
{% include path(args) %} inserts another template
{% extends path %} inherits from a layout
{% block name %} … {% endblock %} a slot in the layout, or what fills it
{% import module %} brings a module in to use its types and functions

Inside {{ }} and {% %} goes ordinary raylang: {{ n.body.split("\n")[0] }} is an expression like any other, and the editor completes and checks it.

A template is used like any other module: import views/index; and then index.render("All notes", query, notes). ray build --templates-only writes to disk the module each template generates, if you want to see what it becomes.

In raylang, pages are built with pure functions: data in, HTML out. The handlers only read the request, and the tests check every page without starting a server.

4. Markdown without risks

A note's body is written in Markdown and shown as HTML:

/// One note, its Markdown body rendered to HTML. `None` if it does not exist.
pub fn note_page(s: Store, id: string) -> Option<string> {
    let n = store.get(s, id)?;
    // std/markdown escapes any HTML the user wrote and drops `javascript:` links, so the result
    // is safe to insert unescaped with {{& … }}.
    Option.Some(note.render(n.title, n, markdown.to_html(n.body)))
}
{% params title: string, n: store.Note, body_html: string %}
{% extends layout %}
{% import store %}
{% block content %}
<article class="note">
    <h1>{{ n.title }}</h1>
    <div class="body">{{& body_html }}</div>
    <div class="actions">
        <a class="button" href="/notes/{{ n.id }}/edit">Edit</a>
        <form method="post" action="/notes/{{ n.id }}/delete">
            <button class="danger" type="submit">Delete</button>
        </form>
    </div>
</article>
{% endblock %}

{{& body_html }} inserts the HTML unescaped, so it has to be safe. It is: std/markdown escapes any HTML the user writes and drops javascript: links. A note with <script> shows the text <script>, and a test checks it:

@test
fn the_note_page_renders_markdown_safely() {
    let s = fresh("note");
    let n = store.save(s, "", "Doc", "**strong** <script>alert(1)</script>", 1000).unwrap();
    let html = pages.note_page(s, n.id).unwrap();
    assert(html.contains("<strong>strong</strong>"));
    assert(!html.contains("<script>"));
    assert(pages.note_page(s, "no-such-id").is_none());
}

5. Files as shared state

Each connection runs in its fiber with its own memory: two requests never share a variable. On this site, what they share is the disk. Each note is a JSON file and every write is atomic:

    let note = Note { id: key, title: clean, body: body, updated_ms: now_ms };
    // Write to a temporary file, then rename it over the real one (atomic).
    let tmp = store.dir + "/." + key + ".tmp";
    let _ = fs.write_file(tmp, note.to_json())?;
    let _ = fs.rename(tmp, path_of(store, key))?;
    Result.Ok(note)
}

First a temporary file is written, then it is renamed over the real one. A rename is atomic: a reader at that moment sees the old note or the new one, never a mix.

A note's id becomes part of a file path, so the store only accepts the ids it generates itself. A request for /notes/../../etc/passwd never reaches the disk:

// An id becomes part of a file path, so only ids this store generates are accepted: a request
// for `../../etc/passwd` must not reach the filesystem.
fn valid_id(id: string) -> bool {
    uuid.is_uuid_v7(id)
}

For a site with few writers, files are enough and need no service. With many concurrent writes, the next step is a database, as in the API chapter.

6. Forms and CSRF

A form on another site can send a POST to your server with the user's cookies. That is the CSRF attack. Browsers always set the Origin header on a cross-site POST, so it is enough to refuse the ones that do not match the server's own host:

// CSRF guard: a form posted from ANOTHER site carries that site's Origin. Browsers always send
// Origin on a cross-site POST, so refusing a mismatch blocks forged requests.
fn same_origin(c: Ctx, r: Res) -> Step {
    if (c.req.method == "POST") {
        let origin = c.header_of("origin");
        if (origin != "" && origin_host(origin) != c.header_of("host").to_lower()) {
            r.status(403).text("cross-site request refused");
            return Step.Done;
        }
    }
    Step.Next
}

7. Test and run

ray test                       # the store and the pages, without a server
ray run                        # http://127.0.0.1:8080
ray dev                        # reloads the browser on save

With the server running, curl shows the POST, redirect, GET pattern:

curl -i -d 'title=Compras&body=**leche**+y+pan' http://127.0.0.1:8080/notes
HTTP/1.1 303 See Other
Location: /notes/01a0fa15-c9d5-7201-91f7-b7a2d893f9ca

And what the site refuses:

Request Response
a form with an empty title 422, the form with the error and what was typed
a POST with another site's Origin header 403
GET /notes/../../etc/passwd 404: the id is not valid and the disk is never touched
GET /assets/app.css with the ETag the browser already has 304, no body

main reads the port from PORT and shuts the server down cleanly on SIGTERM or Ctrl-C: it stops accepting connections and waits up to 5 seconds for the ones in flight.

fn main() -> int {
    let port = env("PORT").unwrap_or("8080").parse_int().unwrap_or(8080);
    print("notes: http://127.0.0.1:" + to_string(port));
    // SIGTERM/SIGINT: stop accepting, let the requests in flight finish (5 s), exit 0.
    match (listen_graceful(build_app, "127.0.0.1", port, 5000)) {
        Result.Ok(_) => 0,
        Result.Err(e) => {
            eprint(e);
            1
        },
    }
}

8. Ship a single binary

ray build --native --release -o notes-ssr
PORT=8080 NOTES_DIR=/var/lib/notes ./notes-ssr

The binary carries the compiled templates and the stylesheet inside ([native] embed), so it runs from any folder. The Notes one is about 3 MB. Behind a proxy such as nginx or Caddy, which handles HTTPS, it is ready for production. If you prefer the program to serve HTTPS itself, listen_tls takes the certificate and the key.

Variable What for Default
PORT the port it listens on 8080
NOTES_DIR the folder with the notes notes-data, in the current directory

The server listens on 127.0.0.1 only: the proxy is on the same machine and is the only thing that should reach it. As a systemd service:

[Unit]
Description=Notes
After=network.target

[Service]
ExecStart=/usr/local/bin/notes-ssr
Environment=PORT=8080 NOTES_DIR=/var/lib/notes
User=notes
Restart=on-failure

[Install]
WantedBy=multi-user.target

systemctl stop sends SIGTERM, and the program finishes the requests in flight before exiting.

Next step

The same kind of server, without pages: a web API that answers JSON, with Postgres and a connection pool.

<!-- sync: sha256:649199af7f81 -->