In this guide you'll build a small Pokédex: a page that loads a list of Pokémon in
Rust and renders it in React, a detail page on a dynamic route, and a loading
state. It's the quickest way to see how the two halves of Ossido fit together.
By the end you'll have touched routing, a Rust data handler, typed props,
application state, a dynamic route, and a loading.tsx fallback - each links to
the reference docs if you want to go deeper.
Before you start
You'll need the Ossido CLI and the Rust + Node/Bun toolchains (see
Installation). Scaffold a project and start the dev
server:
sh
ossido new pokedexcd pokedexossido dev
ossido dev compiles the server, bundles the client, generates types, and serves
everything with hot reload (see The CLI).
The shape of a route
Routes live in src/routes, where each folder is a URL segment. A route's view is
a page.tsx and its optional data loader is a page.rs beside it (see
File System Routing). We'll give the home route (/) a
handler that fetches data and a component that renders it.
Load data in Rust
We'll fetch from the PokéAPI with an HTTP client. Keep one
client for the whole app in src/app.rs as application state - it's created once
and injected into handlers by a matching parameter name (see
Dependency injection):
Now the home handler. It receives the fetch client from state (the parameter
name matches the field), fetches the list, and
returns a struct marked #[Props] - which becomes the page's props. The nested
Pokemon struct only needs #[Type] (see
the Props & Type macros):
The matching page.tsx receives the handler's return value as props. Type it with
OssidoPage<'/'> from the generated @ossido-labs/ossido/types module and the props are exactly
your handler's return type (see
React frontend and
TypeScript integration):
Rendering <title> inline works because this is React 19 - it hoists document
metadata into <head> for you.
Add a detail page
Wrap a segment in square brackets to capture it. A pokemons/[pokemon] folder
matches /pokemons/pikachu; the value arrives in req.params. Return a Response
so you can send a 404 when the name doesn't exist (see
the Request extractor):
rust
// src/routes/pokemons/[pokemon]/page.rsuse ossido::axum::http::StatusCode;use ossido::{handler, Props, Request, Response, Type};use reqwest::Client;#[Props]struct PokemonDetail { name: String, id: u16, height: u16, weight: u16,}#[handler]async fn get_pokemon(req: Request, fetch: Client) -> Response { let name = req.params.get("pokemon").unwrap(); let url = format!("https://pokeapi.co/api/v2/pokemon/{name}"); match fetch.get(url).send().await { Ok(res) if res.status().is_success() => res.json::<PokemonDetail>().await.unwrap().into(), _ => Response::Props(Props::empty_with_status(StatusCode::NOT_FOUND)), }}
While a route's data resolves, Ossido renders its loading.tsx as a <Suspense>
fallback. Add one next to the detail page:
tsx
// src/routes/pokemons/[pokemon]/loading.tsxexport default function Loading() { return <p>Loading...</p>;}
Link it together
You already used Link from @ossido-labs/ossido in the list. It keeps navigation client-side
fetching just the next route's props instead of a full page load (see
Client navigation). Click a
Pokémon and you'll jump to its detail page, with the loading fallback showing while
its handler runs.
Where to next
You've built a data-driven app across the Rust/React boundary. From here: