Data Fetching
Caspian has two data paths, and picking the right one is most of the job.
Use page() for everything the
first render needs, and
@rpc() plus
pp.rpc() for everything the
browser asks for afterwards.
First render → page()
Runs on the server before the page is returned. This is what search engines and the initial paint see, so SEO-sensitive content, dashboards, lists, and detail views load here.
After render → pp.rpc()
Refreshes, form submits, toggles, pagination, infinite scroll, uploads,
and streams. Bind the event in the route markup with a native
on* attribute and call the Python action
directly.
page(), keep
browser-requested work in route-local @rpc()
actions, and move a helper to src/lib/** only
when more than one route or feature shares it.
Loading Initial Data
Export a page() function from the route's
index.py, do the work, and pass the results
into the route's readable html(...) markup.
from casp.component_decorator import html from src.lib.prisma import prisma async def page(): todos = await prisma.todo.find_many() return html(r""" <section> <ul> <template pp-for="todo in todos"> <li key="{todo.id}">{todo.title}</li> </template> </ul> <script> const [todos, setTodos] = pp.state({{ todos | json }}); </script> </section> """, todos=[todo.to_dict() for todo in todos])
Read that context in the inline markup with server-side Jinja. Use the
json filter (aliased as
dump) when the value is being handed to
browser JavaScript — it returns
Markup, so the JSON survives Caspian's brace
escaping intact.
<section> <ul> <template pp-for="todo in todos"> <li key="todo.id">todo.title</li> </template> </ul> <script> // Server data becomes the initial client state const [todos, setTodos] = pp.state({{ todos | json }}); </script> </section>
What page() Receives
Caspian inspects your page() signature and
supplies only what you declare. Nothing is injected that you did not ask
for, so the simplest useful signature is
def page():.
| Declare | You get |
|---|---|
| a first positional parameter |
The dynamic route segments as a single
dict, passed positionally. A route
at src/app/blog/[slug]/index.py
receives {"slug": "hello-world"}.
It is only passed when the route actually has segments.
|
| request |
The FastAPI Request, matched by
name. Use it for headers, cookies, and the full URL.
|
| any other named parameter | The matching query-string value, coerced to the parameter's type annotation. See below. |
Typed query parameters
If a parameter name matches a key in the query string, Caspian reads it
and converts it using the annotation. Scalars
(str, int,
float, bool)
and their Optional[...] forms are
supported, plus list[T] and
Optional[list[T]], which collect repeated
keys such as ?tag=a&tag=b. A parameter
whose name is absent from the query string keeps its Python default,
so give every optional filter one.
from fastapi import Request from casp.component_decorator import html async def page( params: dict, request: Request, page: int = 1, tag: list[str] | None = None, ): # params -> "slug": "hello" (dynamic segment) # page -> 2 (coerced to int) # tag -> ["python", "web"] (repeated key) posts = await load_posts(params["slug"], page, tag or []) return html(r"""<main>{{ posts | length }} posts</main>""", posts=posts)
Validate / Rule
from casp.validate.
What page() Can Return
The return value decides which pipeline runs. Caspian checks the type before finalizing the route.
| Return | Behavior |
|---|---|
| html(...) | The normal page path. Finalizes the inline markup, applies nested layouts, then metadata. |
| (content, props) |
A 2-tuple whose second item is a dict
is treated as layout props, merged into the render context for the
route's layouts.
|
a Response
|
Returned as-is; the template engine is skipped entirely. This is how JSON endpoints and redirects work — see API Development. |
| a generator |
A sync or async generator is wrapped in
SSE(...) automatically, turning the
route itself into a Server-Sent Events stream.
|
Shared Data With layout()
Use layout.py for values shared by a route
subtree, such as shell classes or request-derived labels. Keep heavy
database and service I/O in page()
or route-owned actions — a layout runs for every page in its subtree.
from casp.layout import Metadata def layout(context: dict): Metadata(title="Dashboard") return r"""<div class="{{ layout.shell_class }}"><slot /></div>""", "shell_class": "dashboard-shell",
layout() may be a plain function or an
async def — the engine awaits the result
when it is awaitable — and may return the shell plus an optional props
dict. Those props flow into the layout's own markup and the render context
beneath it.
After First Render
Everything the browser triggers goes through RPC. Bind the event in HTML
with a native on* attribute and call the
Python action — do not assign ids and wire
querySelector,
addEventListener, or a manual
fetch to a hand-rolled JSON endpoint.
<form onsubmit="submitForm(event)"> <input name="title" /> <button type="submit">Save</button> </form> <script> async function submitForm(event) event.preventDefault(); // Read every named control in one line const payload = Object.fromEntries( new FormData(event.currentTarget).entries() ); const todo = await pp.rpc("create_todo", payload); setTodos([...todos, todo]); </script>
@rpc() action, not in the browser. RPC
filters the incoming payload against the function signature, so a key is
only settable by the client when the parameter is actually declared.
Rules Of Thumb
-
Prefer
async def page()whenever the client is async-capable. -
Keep reusable clients and query helpers in
src/lib/; keep route-specific orchestration insrc/app/. -
Move logic into
src/lib/only once more than one route needs it — not in anticipation. -
Reach for WebSockets only for long-lived bidirectional channels, and
only when
caspian.config.jsonhaswebsocket: true. They are not a replacement for ordinary CRUD.