Skip to content

Public pages

Screens marked public can be opened by anyone, also by visitors who never signed in: a landing page, a price list, a help center. Zeyn serves them so that they are cheap to deliver and easy for caches and search engines to handle.

No cookies for visitors

A public screen opened by a request without a session sets no cookie. The page carries an empty CSRF token, so every visitor in the same language and theme gets the same bytes.

The browser runtime asks GET /_zeyn/csrf for a token right before the first call that needs one (a form, an action, the language picker). That call sets the zeyn_csrf cookie and is never cached. Every POST is checked exactly as on any other page: without a matching token it is refused with 403, and nothing is retried silently.

Signed in users always get the cookie and the token in the page, as before.

HTTP caching

#[screen(route = "/pricing", public, cache = 300)]
pub fn pricing() -> Screen {
    Screen::new("Pricing")
}

cache = N lets browsers and shared caches keep the page for N seconds (1 to 86400). For a request without a session the page then gets:

HeaderValue
Cache-Controlpublic, max-age=300
ETagA strong tag over the rendered body
Varyx-zeyn-partial, Accept-Language, Cookie

A request with a matching If-None-Match gets 304 Not Modified without a body. Screens loaded by in-app navigation are cached the same way, with their own tag.

A request with a session never gets public cache headers: the page is sent with Cache-Control: private, no-store. cache on a screen that is not public does not compile.

The screen is still built and rendered for every request that reaches the server; the tag saves the transfer, and a cache in front of the app saves the request. Keep cached screens free of anything that depends on the visitor: the time of day, a counter, a random pick.

#[screen(route = "/pricing", public, cache = 300)]
pub fn pricing() -> Screen {
    Screen::new(t!("pricing.title"))
        .description(t!("pricing.description"))
        .canonical("/pricing")
        .og_image("https://cdn.example.com/pricing.png")
        .robots(Robots::Index)
        .json_ld(serde_json::json!({
            "@context": "https://schema.org",
            "@type": "Product",
            "name": "Team plan",
        }))
}
MethodHead tags
.description(text)<meta name="description"> and og:description, in the user's language
.canonical(url)<link rel="canonical"> and og:url
.og_image(url)og:image
.robots(Robots::Index) or Robots::NoIndex<meta name="robots">
.json_ld(value)<script type="application/ld+json">

Every value is escaped. JSON-LD is written with <, > and & as \u003c, \u003e and \u0026, so a value holding </script> cannot end the element; the browser never runs it, and the content security policy stays as strict as on every other page. App paths in address properties of JSON-LD (url, @id, image, logo, item and the like) are written absolute with App::site_url, at any depth, as search engines expect.

URLs must be https URLs or app paths starting with /. Anything else (http:, //host, javascript:, spaces, quotes) is left out with a warning in the log. App paths are written behind the site URL and the base path:

App::new(t!("app.name")).site_url("https://shop.example.com")

Without site_url head tags keep the app path, which browsers resolve but some crawlers do not.

In-app navigation updates the title and all of these tags without a page load.

Sitemap

#[action(public)]
pub async fn site_pages(part: SitemapPart, db: Db) -> Result<Vec<SitemapUrl>> {
    let rows = db.products_after(part.offset, part.limit).await?;
    Ok(rows.into_iter().map(|p| SitemapUrl::new(format!("/products/{}", p.slug)).lastmod(p.changed)).collect())
}

Feature::new("site").sitemap(site_pages)

/sitemap.xml asks the action for 1,000 URLs at a time (part.offset, part.limit) and streams the XML, so a large site never sits in memory. Up to 50,000 URLs it is one urlset. Above that it is a sitemap index of /sitemaps/1.xml, /sitemaps/2.xml and so on, each with 50,000 URLs. The action must be public and return the URLs in a stable order. App paths need App::site_url, because a sitemap holds absolute URLs; entries that are not https URLs or app paths are left out. List only public pages: search engines cannot open the others. One feature per app has a sitemap.

robots.txt and llms.txt

App::new(t!("app.name"))
    .robots_txt("User-agent: *\nAllow: /\nSitemap: https://shop.example.com/sitemap.xml\n")
    .llms_txt(include_str!("../llms.txt"))

Both are served as plain text to everyone.