[!NOTE] > This is an experimental project โ designed to demonstrate how single-page applications can serve SEO-friendly, route-specific content dynamically on the Internet Computer (ICP). This is not a polished library or CLI yet โ feel free to fork and experiment.
Traditional SPA setups (e.g., Vite-based apps) typically serve a single index.html
, which hinders SEO and social link previews. Search engines and social networks need per-route metadata (title, description, OpenGraph tags, etc.) to correctly index pages and generate rich previews.
This project explores a pattern to solve that by:
- Dynamically generating route-specific
index.html
responses on the "server", served by an ICP canister. - Certifying these responses using ICP's HTTP certification.
- Using file-based routing to map incoming requests to route handlers.
- Optionally supporting full server-side rendering (e.g., with HTMX or other frameworks).
๐ Live demo: https://blx6i-6iaaa-aaaal-qslxq-cai.icp0.io
pnpm i
dfx start --background --clean
dfx deploy
pnpm run dev
The repository contains:
- A demo Vite front-end app.
- A server canister written in Rust.
- A router library that enables dynamic routing and response generation.
- Integration with the ICP HTTP certification system for serving certified assets.
The goal is to enable this pattern within a single-canister ICP setup.
- On canister init, all static assets are certified.
- The default root (
/
) index.html is deleted, so a custom one can be generated on demand. - Incoming HTTP requests are routed via a dynamic file-based route tree.
- Each route has an associated handler function.
- The handler dynamically generates the content (e.g., custom
index.html
) and the router certifies it before serving. - On next request, the router checks if the content is already certified and serves it directly if available.
// The built frontend assets, the output of `pnpm run build`
static ASSETS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../dist");
#[init]
fn init() {
// Certify pre-built static assets
certify_all_assets(&ASSETS_DIR);
// Remove default root asset to ensure it's generated dynamically
delete_assets(vec!["/"]);
}
#[query]
pub fn http_request(req: HttpRequest) -> HttpResponse {
router_library::http_request(req)
}
#[update]
fn http_request_update(req: HttpRequest) -> HttpResponse {
ROUTES.with(|routes| router_library::http_request_update(req, routes))
}
A route handler accepts:
HttpRequest
: the incoming request.RouteParams
: extracted path parameters (e.g., from/subpath/:id
).
It returns an HttpResponse
struct, as defined by the ic_http_certification
crate.
- Loads the pre-built index.html with a {{ title }} placeholder.
- Uses minijinja to render the template with a route-specific title.
- Constructs and returns an HttpResponse with text/html content.
use std::{borrow::Cow, collections::HashMap};
use ic_http_certification::{HttpRequest, HttpResponse, StatusCode};
use minijinja::Environment;
use router_library::router::RouteParams;
pub fn handler(_: HttpRequest, params: RouteParams) -> HttpResponse<'static> {
let html = include_str!("../../../../dist/index.html");
let env = Environment::new();
let template = env.template_from_str(html).unwrap();
let mut ctx = HashMap::new();
ctx.insert("title", format!("Subpage {}", params.get("id").unwrap()));
let rendered = template.render(ctx).unwrap();
HttpResponse::builder()
.with_headers(vec![("Content-Type".into(), "text/html".into())])
.with_status_code(StatusCode::OK)
.with_body(Cow::Owned(rendered.into_bytes()))
.build()
}
The router library expects a statically defined route tree, generated at build time using a build script. The routes connect incoming requests to their respective handler functions.
use crate::routes;
use router_library::router::{NodeType, RouteNode};
thread_local! {
pub static ROUTES: RouteNode = {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("", routes::index::handler);
root.insert("*", routes::__any::handler);
root.insert("/subpath/:id", routes::subpath::id::handler);
root.insert("index2", routes::index2::handler);
root
};
}
In your build.rs
:
use router_library::build::generate_routes;
fn main() {
generate_routes();
}
This scans src/routes/
using file-based routing conventions and generates the route tree automatically.
src/routes/
โโโ index.rs --> "/"
โโโ index2.rs --> "/index2"
โโโ *.rs --> wildcard, matches any request
โโโ subpath/
โโโ :id.rs --> "/subpath/:id"
You start with a regular Vite SPA. Then:
- Add a
server/
folder at the root. - Inside
server/
, include:- Route handler modules (under
src/routes/
) - The asset-serving canister logic.
- Route handler modules (under
- Use the router library to:
- Match paths to handlers.
- Generate dynamic responses.
- Certify assets.
This lets you ship a single canister on ICP that supports dynamic, SEO-optimized rendering with route-level granularity.
Dynamic content is certified before being served using ICP's HTTP certification mechanisms. This ensures that clients and search engines can trust the content even when it's dynamically generated.
- Fine-grained caching control for dynamically generated assets.
- CLI tool to:
- Scaffold the server integration into any SPA project.
- Assist with deploying the project to the Internet Computer.
- Per-handler configuration of:
- Response headers.
- Content type.
- Cache settings.
- Support for ICP HTTP certification features:
- Asset aliasing.
- Fallback assets.
- Content encoding variants.
- Turn the router module into a reusable library crate.
๐ฌ This is an experimental repo โ Iโm eager to hear your thoughts, use cases, or improvements.
Feel free to file issues, fork it, or ping me for collaboration.