Forte Pages
Pages are Rust handlers that return server-side data (Props) which the React frontend renders.
File Location
Place page handlers under rs/src/pages/. The directory structure maps directly to URL routes:
| File path | URL |
|---|---|
rs/src/pages/index/mod.rs or rs/src/pages/index.rs | / |
rs/src/pages/about/mod.rs or rs/src/pages/about.rs | /about |
rs/src/pages/product/[id]/mod.rs | /product/:id |
rs/src/pages/blog/[year]/[slug]/mod.rs | /blog/:year/:slug |
Both directory modules (about/mod.rs) and flat files (about.rs) are supported. The directory layout is preferred for pages that need companion files.
A page is discovered when its file contains pub async fn handler.
Basic Page
A minimal page handler returns a Props type via Result<Props>:
// rs/src/pages/index/mod.rs
use anyhow::Result;
use forte_sdk::ForteRequest;
use serde::Serialize;
#[derive(Serialize)]
pub enum Props {
Ok { message: String },
}
pub async fn handler(_req: ForteRequest<'_>) -> Result<Props> {
Ok(Props::Ok {
message: "Hello from Forte!".to_string(),
})
}
The Props type is serialized to JSON and passed to the React component via the SSR pipeline.
forte add page <path> scaffolds a backend handler and a React component with the correct signatures and patterns shown above.
ForteRequest
ForteRequest<'a, Body> is the request context injected into handlers:
pub struct ForteRequest<'a, Body = forte_sdk::http::Body> {
pub uri_authority: &'a str, // e.g. "example.com"
pub method: &'a http::Method,
pub headers: &'a http::HeaderMap,
pub jar: &'a mut CookieJar, // mutable: add/remove cookies here
pub raw_body: &'a [u8], // empty for streaming page and API handlers
pub body: Body, // streaming body by default
}
Page and API handlers consume req.body with read_chunk. The 100 MB request limit is a
transport limit; use an explicit bounded helper when a complete value is required. Generated
actions and hooks use a separate 1 MiB buffering limit for typed JSON input.
Path Parameters
For dynamic routes like /product/[id], declare a PathParams struct:
// rs/src/pages/product/[id]/mod.rs
use anyhow::Result;
use forte_sdk::ForteRequest;
use serde::{Deserialize, Serialize};
pub struct PathParams {
pub id: String,
}
#[derive(Serialize)]
pub enum Props {
Ok { id: String },
}
pub async fn handler(_req: ForteRequest<'_>, params: PathParams) -> Result<Props> {
Ok(Props::Ok { id: params.id })
}
The codegen reads the PathParams struct and generates the extraction code. The parameter names must match the directory segment names (e.g. [id] → field named id).
Supported param types: String, and any type that implements FromStr (e.g. u32, i64). Non-String types that fail to parse return HTTP 400.
Search Parameters
For query-string parameters, declare a SearchParams struct:
pub struct SearchParams {
pub q: String, // required
pub page: Option<u32>, // optional
}
pub async fn handler(_req: ForteRequest<'_>, search: SearchParams) -> Result<Props> {
// ...
}
Required SearchParams fields that are missing return HTTP 400. Optional fields use Option<T>.
Both Path and Search Parameters
pub async fn handler(
_req: ForteRequest<'_>,
path: PathParams,
search: SearchParams,
) -> Result<Props>
Redirects
Redirect is an enum generated by forte-codegen with one variant per page. Import it from route_generated:
use crate::route_generated::Redirect;
Variant naming
Variant names are derived from the page's route segments:
| Page file | Route | Redirect variant |
|---|---|---|
pages/index/mod.rs | / | Redirect::Index |
pages/about/mod.rs | /about | Redirect::About |
pages/login/mod.rs | /login | Redirect::Login |
pages/product/[id]/mod.rs | /product/:id | Redirect::Product_id_ { id: String } |
pages/blog/[year]/[slug]/mod.rs | /blog/:year/:slug | Redirect::Blog_year__slug_ { year: String, slug: String } |
Rules:
- Static segments are title-cased and concatenated (
/product/detail→ProductDetail). - Dynamic segments (
[param]) become_param_(underscore-wrapped, lowercase). - Pages with dynamic segments have struct-like variants whose fields match the
PathParamsfield names and types.
The unconditional external redirect variant is always Redirect::External { url: String }.
Redirect-only page
When a page only ever redirects (never renders), declare pub type Props = Redirect and return Ok(Redirect::...):
use anyhow::Result;
use forte_sdk::ForteRequest;
use crate::route_generated::Redirect;
pub type Props = Redirect;
pub async fn handler(_req: ForteRequest<'_>) -> Result<Redirect> {
Ok(Redirect::About)
}
Conditional redirect
To redirect from a regular page (which has its own Props type) based on some condition, return Err(Redirect::...into()):
use crate::route_generated::Redirect;
pub async fn handler(req: ForteRequest<'_>) -> Result<Props> {
if !is_authenticated(&req) {
return Err(Redirect::Login.into());
}
Ok(Props::Ok { ... })
}
Redirect::External { url } redirects to an arbitrary URL. Both patterns emit HTTP 302.
Response Headers
Dynamic SSR page responses include Cache-Control: no-store. This prevents CDNs and browser caches from storing request-specific HTML. Static pages opt into CDN caching only when their output is safe to share.
API endpoints (src/apis/) and actions do not include Cache-Control: no-store — they control their own headers via the response.
Lazy Static Page Caching
Mark a page route with #[forte_sdk::cache_static]. Forte codegen adds the declaration to the WASM cache-policy interface; fn0 uses that interface before creating Rust props or invoking JavaScript. The first eligible request renders the page through Rust and JavaScript and returns headers that let Cloudflare's native CDN cache the response. Later CDN hits do not reach fn0.
Declare the cache policy on the route handler:
use forte_sdk::ForteRequest;
#[forte_sdk::cache_static]
pub async fn handler(_req: ForteRequest<'_>) -> anyhow::Result<Props> {
Ok(Props { ... })
}
The annotation is optional. A route without it, or a WASM bundle without the optional fn0 cache-policy interface, remains private SSR. The cache-policy interface is part of fn0's generic WASM/HTTP contract and does not depend on Forte at runtime.
Dynamic routes
A dynamic route caches one response per concrete path, so it must also export cache_static_eligible to say which paths exist. Without it the build fails: an unvalidated route would let any probed path become a CDN entry, and pages that render "not found" as HTTP 200 would store one for every miss.
pub struct PathParams {
pub id: u32,
}
pub async fn cache_static_eligible(params: PathParams) -> anyhow::Result<bool> {
Ok(episode::exists(params.id).await?)
}
#[forte_sdk::cache_static]
pub async fn handler(_req: ForteRequest<'_>, params: PathParams) -> anyhow::Result<Props> {
...
}
It runs on the preflight, which is the request that was going to render anyway, and it decides caching only — returning false, returning an error, or a path parameter that fails to parse all fall back to normal SSR rather than failing the request.
The contract is a POST /__fn0_cache_policy request with the normalized target path in x-fn0-cache-path. A cacheable route returns 204 No Content and x-fn0-cache-policy: static; every other result means that fn0 must continue with normal SSR.
fn0 copies the incoming request's Host header onto the preflight request. A guest that rebuilds its request URI from scheme, authority and path must accept Host as the authority source, because the preflight carries no other authority.
Lazy static caching has these constraints:
- Paths must start with
/and cannot contain a query string, fragment, backslash, or dot segment. - Every configured path must return HTTP 200 with an HTML content type.
- Dynamic routes must export
cache_static_eligible. Exact routes are always eligible and do not use it. - Only
GETandHEADpaths without query strings are eligible. - A response is cached only when it is a final HTTP 200 HTML response without
Set-Cookie. - Requests with query strings continue to use SSR.
- Unannotated paths continue to use SSR.
- CDN caching is controlled by the response headers; a response that is not safe remains private SSR.
- Deployments purge the project cache tag before and after code-version activation. Static responses remain disabled until the purge queue completes.
- A page whose content comes from data the app writes at runtime must invalidate it itself — see Invalidating a page without deploying.
Only declare pages whose output is safe to share with every visitor. Request headers, authentication, cookies, and other per-user state are intentionally absent during generation.
Response headers
A static response carries:
cache-control: no-cache
cloudflare-cdn-cache-control: public, max-age=31536000
cache-tag: fn0-project-<project_id>
The edge TTL is a fixed year and is not configurable. Expiry is not the invalidation mechanism — cache-tag is what deploy-time purges target, so every static page of a project is invalidated together, and a shorter TTL would only add origin hits that return the same bytes. A single page is invalidated by URL instead, through static_page_cache::purge.
The CDN consumes cloudflare-cdn-cache-control and cache-tag and does not forward them. cache-control: no-cache does reach the browser, which is what makes a deploy purge effective: the browser revalidates against the edge on every request, the edge serves the cached copy until a purge, and a purge is therefore visible to repeat visitors immediately. A CDN configured to override the browser TTL instead breaks that — purges cannot reach a browser cache, so returning visitors would keep the previous version until their local TTL expired.
A dynamic SSR response carries cache-control: private, no-store and neither CDN header.
Invalidating a page without deploying
A deploy replaces every static page of a project at once. A page whose content is a function of the deployed code needs nothing else. A page built from data the app writes at runtime does: publishing an episode, editing a title, and deleting a record all change what the page should say while the code stays put.
static_page_cache::purge invalidates individual pages:
use forte_sdk::static_page_cache;
pub async fn publish(episode_id: u32) -> anyhow::Result<()> {
store::write_episode(episode_id).await?;
static_page_cache::purge(&[&format!("/episode/{episode_id}"), "/episodes"]).await?;
Ok(())
}
Paths are route paths as a visitor requests them, not object keys, and they are resolved against the project's own domain. Rules:
- The same path rules as
cache_static: leading/, no query string, fragment, backslash, or dot segment. - Percent-encoding must already be upper-case (
/docs/%7Euser, not/docs/%7euser). The edge keys its entry on the URL the visitor requested, so a path is checked and passed through unchanged rather than normalized — purging a different spelling would clear an entry nobody has. - At most 100 paths per call.
- Purges are budgeted per project per hour, shared with
object_storage::publicpurges. Over the budget the call returnsError::RateLimited. - Eligibility is not re-checked. A path that
cache_static_eligiblenow rejects is exactly the path that most needs purging — the record it described was just deleted. - The call returns once the invalidation is queued, not once the edge is consistent.
A project with no domain has no edge holding its pages, and the purge is a no-op.
The owner can do the same from the CLI without deploying:
forte purge-page /episode/1 /episodes
fn0 purge-page --project <name> /episode/1
Local development
fn0 local evaluates static pages on every request because there is no CDN in front of the local server. Cache policy and header emission behave as they do in production; deploy purges have no local equivalent. static_page_cache::purge succeeds and does nothing, so a publish step runs unchanged against the dev server.
Error Handling
If a handler returns Err and the error is not a Redirect, the error is logged to stderr and the client receives HTTP 500 "Internal Server Error". The error message does not reach the client. Use structured logging (tracing::error!) to capture errors with span context, since eprintln! output only goes to the worker's stderr.
pub async fn handler(req: ForteRequest<'_>) -> Result<Props> {
let data = fetch_data().await.map_err(|e| {
tracing::error!(error = ?e, "fetch_data failed");
e // Err propagates to HTTP 500
})?;
Ok(Props::Ok { data })
}
Cookies
Read and write cookies via req.jar:
use forte_sdk::cookie_sign::{sign_cookie, unsign_cookie};
use forte_sdk::time;
pub async fn handler(req: ForteRequest<'_>) -> Result<Props> {
// Read a signed cookie
let user: Option<User> = unsign_cookie(req.jar, "session");
// Write a signed cookie
sign_cookie(
req.jar,
"session",
&user,
Some(time::Duration::days(30)),
);
Ok(Props::Ok { ... })
}
sign_cookie / unsign_cookie use HMAC-SHA256 with the COOKIE_SECRET environment variable. Cookies are set HttpOnly, Secure, Path=/.
React Component
The frontend counterpart lives at fe/src/pages/<path>/page.tsx:
// fe/src/pages/index/page.tsx
import type { Props } from "./.props";
export default function IndexPage(props: Props) {
if (props.t !== "Ok") {
return <div>Error</div>;
}
return <div>{props.message}</div>;
}
The .props.ts file is auto-generated from the Rust Props type by forte-rs-to-ts. It exports a Zod schema and a Props type as a TypeScript discriminated union on the t field.
The JSON shape varies by variant kind:
| Rust variant kind | JSON shape | TypeScript type |
|---|---|---|
Unit: Ok | {"t":"Ok"} | { t: "Ok" } |
Tuple/newtype: Ok(String) | {"t":"Ok","v":"..."} | { t: "Ok", v: string } |
Struct: Ok { message: String } | {"t":"Ok","message":"..."} | { t: "Ok", message: string } |
Struct variant fields are spread alongside t (flat); there is no v wrapper. Rust field names are converted to camelCase (user_name → userName).
Example for Props::Ok { message: String }:
- JSON from Rust:
{"t":"Ok","message":"Hello"} - TypeScript access:
props.message(after narrowing withprops.t === "Ok")
Per-page Head
Pages can override the document <title> and meta tags by exporting a head function that receives the same props as the page component. See Per-page Head.
API Endpoints
Files under rs/src/apis/ work the same as pages but return JSON directly (no SSR). The route is prefixed with /api/:
rs/src/apis/users.rs → GET /api/users
The handler signature is identical, but the response is serialized as JSON and returned with Content-Type: application/json — the x-fn0-next: js header is not set, so the JS runtime is not invoked.