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_embeddedservesstatic/under/assets/, withETagand304.
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 itsNotetype in the parameters.{% extends layout %}goes right after{% params %}and resolves next to the template;{% include views/card(n) %}resolves fromsrc/.
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 -->