# FluidKit — Complete Reference
> Web development for the Pythonist
FluidKit bridges Python and SvelteKit into a unified fullstack framework. Decorate Python functions — FluidKit registers them as FastAPI endpoints and generates colocated `.remote.ts` files that SvelteKit imports as native remote functions with full type safety, cookie forwarding, file uploads, redirects, and single-flight cache invalidation.
- Website: https://fluidkit.github.io
- GitHub: https://github.com/AswanthManoj/Fluidkit
- PyPI: https://pypi.org/project/fluidkit/
- SvelteKit remote functions: https://svelte.dev/docs/kit/remote-functions
## Install
pip install fluidkit
No system Node.js required — FluidKit bundles it via nodejs-wheel.
## How it works
1. You decorate a Python function (async or sync) with @query, @command, @form, or @prerender
2. FluidKit registers it as a FastAPI endpoint (parameter types, validation, return types extracted automatically)
3. FluidKit generates a colocated `.remote.ts` file — a SvelteKit remote function wrapper with full TypeScript types
4. You import from `$lib/yourfile.remote` in Svelte and use it as a native SvelteKit remote function
Generated `.remote.ts` files update automatically on save in dev mode via HMR (Jurigged). They are real TypeScript you can inspect and version control.
All four decorators support both async and sync functions. Use async def when you need await — for database calls, HTTP requests, or .refresh() and .set() on async queries. Use plain def for simple synchronous logic. Sync functions run in a threadpool automatically. If a query is sync, its .refresh() and .set() are also sync — no await needed.
---
## @query — Read data
Use @query to read data from the server. Queries are cached on the client and can be refreshed on demand.
### Basic usage
```python
from fluidkit import query
@query
async def get_posts():
return [
{"id": 1, "title": "Hello World"},
{"id": 2, "title": "FluidKit"},
]
```
Svelte usage with await (recommended):
```svelte
{#each await get_posts() as post}
{post.title}
{/each}
```
Until the promise resolves — and if it errors — the nearest will be invoked.
Alternative usage with loading/error/current properties:
```svelte
{#if posts.error}
Something went wrong.
{:else if posts.loading}
Loading...
{:else}
{#each posts.current as post}
{post.title}
{/each}
{/if}
```
### Arguments
Query functions can accept typed arguments:
```python
from fluidkit import query, error
@query
async def get_post(slug: str):
post = db.get(slug)
if not post:
error(404, "Not found")
return post
```
```svelte
{post.title}
{@html post.content}
```
Arguments are validated by Python's type hints. FluidKit extracts the types from your function signature and generates the corresponding TypeScript types — no manual schema needed.
For richer validation, use Pydantic models:
```python
from pydantic import BaseModel
class PostFilter(BaseModel):
tag: str | None = None
limit: int = 10
@query
async def get_posts(filter: PostFilter):
...
```
FluidKit generates a TypeScript interface for PostFilter automatically and uses it in the generated .remote.ts file.
### Return types
Annotate your return type and FluidKit will reflect it into TypeScript:
```python
from pydantic import BaseModel
class Post(BaseModel):
id: int
title: str
content: str
likes: int
@query
async def get_posts() -> list[Post]:
...
```
The Svelte side gets full type safety — post.title autocompletes, post.nonexistent errors at build time. If you omit the return annotation, the generated type will be `any`.
### Errors
Call `error()` to return an HTTP error:
```python
from fluidkit import query, error
@query
async def get_post(slug: str):
post = await db.find(slug)
if not post:
error(404, "Not found")
return post
```
When using await in templates, this triggers the nearest . If you're using the loading/error/current properties instead, the error is available via the error property on the query.
### Refreshing queries
Any query can be refetched via its refresh method:
```svelte
```
Queries are cached while they're on the page, meaning get_posts() === get_posts(). You don't need to store a reference to update it.
### Batching — @query.batch
When multiple components each call the same query with different arguments, each call normally results in a separate request. @query.batch solves this by collecting concurrent calls into a single request.
```python
from fluidkit import query
@query.batch
async def get_post_likes(post_ids: list[int]):
likes = await db.get_likes_bulk(post_ids)
lookup = {row.post_id: row.likes for row in likes}
return lambda post_id, idx: lookup.get(post_id, 0)
```
The function receives a list of all the arguments from concurrent calls. It must return a callable with the signature (arg, index) -> result that resolves each individual call.
Svelte side usage looks identical to a regular query:
```svelte
{#each posts as post}
{#await get_post_likes(post.id) then likes}
{likes} likes
{/await}
{/each}
```
Even though each iteration calls get_post_likes individually, SvelteKit collects all calls within the same render and sends them as a single batched request.
Batch queries support .refresh() and .set() for individual arguments:
```python
@command
async def bump_likes(post_id: int) -> None:
await db.increment_likes(post_id)
await get_post_likes(post_id).refresh() # re-fetches just this post's likes
```
```svelte
```
Each .refresh() call re-executes the batch function with just the single argument — it does not refetch all active batch entries.
Use @query.batch when the same query is called many times with different arguments in a single render — lists of cards, rows in a table, items in a feed. If a query is only ever called once at a time, regular @query is simpler.
### Accessing the request
Use get_request_event() to access cookies and other request data:
```python
from fluidkit import query, error, get_request_event
@query
async def get_profile():
event = get_request_event()
session_id = event.cookies.get("session_id")
if not session_id:
error(401, "Unauthorized")
return await db.get_user(session_id)
```
Queries can read cookies but not set them. To set cookies, use @form or @command.
---
## @form — Write data via forms
Use @form to write data via
```
The form works as a native HTML form submission if JavaScript is unavailable. When JavaScript is present, SvelteKit progressively enhances it to submit without a full page reload.
### Fields
Each parameter in your function becomes a field. Call .as(...) on a field to get the attributes for the corresponding input type:
```python
@form
async def create_profile(name: str, age: int, bio: str) -> None:
...
```
```svelte
```
The .as(...) method sets the correct input type, the name attribute used to construct form data, and the aria-invalid state for validation.
### Nested types
Forms support Pydantic models, arrays, and nested objects as parameters. SvelteKit parses flat form fields into structured data before FluidKit forwards it to your Python handler:
```python
from typing import Optional
from pydantic import BaseModel
from fluidkit import form
class Info(BaseModel):
height: int
likesDogs: Optional[bool] = None
@form
async def create_profile(name: str, age: int, tags: list[str], info: Info) -> None:
await db.insert_profile(name, age, tags, info)
```
```svelte
```
Nested fields use dot notation for objects (info.height) and bracket notation for arrays (tags[0]). SvelteKit coerces values based on the input name prefix: n: for numbers, b: for booleans.
Files work alongside nested types. When files are present, FluidKit sends structured data as JSON and files as separate multipart fields:
```python
from pydantic import BaseModel
from fluidkit import form, FileUpload
class Info(BaseModel):
height: int
likesDogs: bool = False
@form
async def create_profile(
name: str,
info: Info,
tags: list[str],
photo: FileUpload,
docs: list[FileUpload],
) -> None:
await storage.save(photo.filename, await photo.read())
for doc in docs:
await storage.save(doc.filename, await doc.read())
await db.insert_profile(name, info, tags)
```
```svelte
```
### File uploads
Use FileUpload for file parameters:
```python
from fluidkit import form, FileUpload
@form
async def upload_avatar(username: str, photo: FileUpload) -> None:
contents = await photo.read()
await storage.save(photo.filename, contents)
await db.update_avatar(username, photo.filename)
```
```svelte
```
Add enctype="multipart/form-data" to the form when using file inputs. FileUpload extends FastAPI's UploadFile, so all its methods (read(), filename, content_type, etc.) are available.
### Redirects
Call `redirect()` to navigate after a successful submission:
```python
from fluidkit import form, redirect
@form
async def create_post(title: str, content: str) -> None:
slug = title.lower().replace(" ", "-")
await db.insert(slug, title, content)
redirect(303, f"/blog/{slug}")
```
The redirect is captured by the FluidKit backend and forwarded to SvelteKit, which performs the navigation on the client. Common status codes:
- 303 — See Other (most common for form submissions, redirects as GET)
- 307 — Temporary Redirect (preserves request method)
- 308 — Permanent Redirect (preserves request method, SEO transfers)
### Errors
Call `error()` to return an HTTP error:
```python
from fluidkit import form, error, get_request_event
@form
async def create_post(title: str, content: str) -> None:
event = get_request_event()
session_id = event.cookies.get("session_id")
if not session_id:
error(401, "Unauthorized")
await db.insert(title, content)
```
If an error occurs during form submission, the nearest +error.svelte page will be rendered. This is different from @query (which triggers ) and @command (which relies on your own try/catch).
### Validation
SvelteKit provides client-side validation via the issues() method on each field and the validate() method on the form:
```svelte
```
Server-side validation comes from Python's type system — if a parameter can't be coerced to the expected type (e.g. a string sent for an int field), the form handler returns a 400 error automatically.
### Returns
Instead of redirecting, a form can return data:
```python
@form
async def add_post(title: str, content: str) -> dict:
await db.insert(title, content)
return {"success": True}
```
```svelte
{#if add_post.result?.success}
Published!
{/if}
```
This value is ephemeral — it vanishes on resubmit, navigation, or page reload.
### Single-flight mutations
By default, all queries on the page are refreshed after a successful form submission. For more control, specify which queries to update inside the form handler:
Use .refresh() to re-execute a query and include its new result:
```python
from fluidkit import form, query
@query
async def get_posts() -> list[Post]:
return await db.get_all_posts()
@form
async def add_post(title: str, content: str) -> None:
await db.insert(title, content)
await get_posts().refresh() # re-runs get_posts, sends result with this response
```
Use .set() to update a query's value directly without re-executing it:
```python
@form
async def add_post(title: str, content: str) -> None:
new_post = await db.insert_and_return(title, content)
all_posts = await db.get_all_posts()
await get_posts().set(all_posts) # set value without re-running the query
```
Both .refresh() and .set() only work inside @form and @command handlers. Calling them elsewhere produces a warning.
### Cookies
Forms can read and set cookies:
```python
from fluidkit import form, get_request_event
@form
async def login(username: str, _password: str) -> None:
user = await db.authenticate(username, _password)
event = get_request_event()
event.cookies.set("session_id", user.session, httponly=True, path="/")
```
Prefix sensitive parameter names with an underscore (e.g. _password) to prevent them from being sent back to the client on validation failure — matching SvelteKit's convention.
### Enhance
Customize submission behavior with the enhance method on the Svelte side:
```svelte
```
When using enhance, the form is not automatically reset — call form.reset() explicitly if needed.
### Supported parameter types
@form supports any type that can be represented as form fields:
- str, int, float, bool — primitive inputs
- FileUpload, list[FileUpload] — file inputs
- list[str], list[int], etc. — multiple inputs with bracket notation
- Optional[...] — optional fields
- Pydantic BaseModel — nested objects via dot notation
---
## @command — Write data imperatively
Use @command to write data from anywhere — event handlers, button clicks, any imperative call. Unlike @form, commands are not tied to a