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 = ()> {
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],
pub body: Body, // typed body (default: ())
}
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
SSR page responses always include Cache-Control: no-store. This prevents CDNs and browser caches from storing the rendered HTML, which is intentional: every page response embeds request-specific data (hook results, cookie-derived state) that must not be reused across requests.
API endpoints (src/apis/) and actions do not include Cache-Control: no-store — they control their own headers via the response.
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.