Nestipy Web (Vite)
Nestipy Web lets you write frontend components in Python and compile them to TSX. The output is a standard React app that runs with Vite, so you can use any TypeScript/React library (shadcn/ui, Tailwind, Radix, etc.).
Folder Structure
Nestipy Web is designed to live inside the same repo as your backend (monorepo-style). If your Python backend lives under src/, a typical layout looks like this:
main.py
cli.py
uv.lock
pyproject.toml
README.md
src/
__init__.py
app_module.py
app_controller.py
app_service.py
web/ # Vite project (generated / managed by Nestipy Web)
app/ # Python UI sources (compiled to TSX)
page.py
layout.py
components/
card.pyThe compiler reads Python UI from app/ by default and writes the Vite project into web/ by default.
If your UI code also lives under src/ (e.g. src/app/), you can:
- run
nestipy run web:init --app-dir src/app - run
nestipy run web:dev --app-dir src/app --vite ...
UI-Only View
app/
page.py # /
layout.py # root layout
actions.py # RPC actions (optional)
users/
page.py # /users
[id]/
page.py # /users/:id
web/
index.html
src/
main.tsx
routes.tsx
pages/...Routing Rules
app/page.pymaps to/app/users/page.pymaps to/usersapp/users/[id]/page.pymaps to/users/:idapp/blog/[...slug]/page.pymaps to/*
Minimal Example
from nestipy.web import component, h
@component
def Page():
return h.div("Hello from Nestipy Web", class_name="p-6")You can use any HTML tag as h.tag, and class_name is converted to className.
Root Layout
Create app/layout.py to wrap all pages:
from nestipy.web import component, h, Slot
@component
def Layout():
return h.div(
h.header("My App"),
h(Slot),
class_name="min-h-screen bg-slate-950 text-white",
)Nested Layouts & Imports
You can add layout.py in any subfolder to wrap only that subtree. For example:
app/
layout.py # root layout
page.py # home page
api/
layout.py # wraps only /api/*
page.pyWhen importing from layouts, be explicit:
- Use
from app.layout import ThemeContextto always pull from the root layout. - Use
from .layout import ThemeContextto pull from the local layout.
from layout import ... resolves to the nearest layout (local if present, otherwise the root). If you need the root layout explicitly, use from app.layout import ....
External React Libraries
Use external() to import any TS/React library:
from nestipy.web import component, h, external
Button = external("shadcn/ui/button", "Button")
@component
def Page():
return h.div(
Button("Save", variant="outline"),
class_name="p-6",
)Use external_fn() for utility functions like clsx/twMerge:
from nestipy.web import component, h, external_fn
clsx = external_fn("clsx", "clsx")
@component
def Page():
return h.div(
"Hello",
class_name=clsx("base", True and "active"),
)JS Context Expressions
When writing hook callbacks, js(...) can be used for raw JS snippets, but the compiler also supports simple Python lambdas in JS contexts:
use_effect(lambda: api.ping().then(lambda value: set_status(f"Ping: {value}")), deps=[])Lambdas compile to JS arrow functions. Complex lambdas with default values or *args/**kwargs are not supported.
Action Guards
Actions are providers, so HTTP guards don't apply. Use action guards instead:
from nestipy.web import action, UseActionGuards, ActionGuard, ActionContext
class AuthGuard(ActionGuard):
def can_activate(self, ctx: ActionContext) -> bool:
return ctx.user is not None
class DemoActions:
@UseActionGuards(AuthGuard)
@action()
async def hello(self, name: str) -> str:
return f"Hello, {name}"You can also register global action guards via ActionsOption(guards=[...]).
Built-in Guards
Nestipy ships with a few common action guards:
OriginActionGuard— allow-listOrigin/Referer.CsrfActionGuard— double-submit CSRF validation (header or payload vs cookie).ActionSignatureGuard— HMAC + nonce replay protection.ActionPermissionGuard— enforce@ActionPermissions(...).
Example:
from nestipy.web import (
ActionsModule,
ActionsOption,
OriginActionGuard,
CsrfActionGuard,
ActionSignatureGuard,
ActionPermissionGuard,
)
module = ActionsModule.for_root(
ActionsOption(
guards=[
OriginActionGuard(allowed_origins=["http://localhost:5173"]),
CsrfActionGuard(),
ActionSignatureGuard(secret="dev-secret"),
ActionPermissionGuard(),
]
)
)For browser clients, prefer OriginActionGuard + CsrfActionGuard. Use ActionSignatureGuard mainly for trusted server-to-server calls.
Action Security Presets (Env + CLI)
You can enable a default guard stack via environment variables (used by ActionsModule.for_root when guards is empty):
NESTIPY_ACTION_SECURITY=1enable presetsNESTIPY_ACTION_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173NESTIPY_ACTION_ALLOW_MISSING_ORIGIN=1NESTIPY_ACTION_CSRF=1or0NESTIPY_ACTION_SIGNATURE_SECRET=...NESTIPY_ACTION_PERMISSIONS=1
CLI shortcuts map to these env vars:
nestipy start --dev --action-security --action-origins "http://localhost:5173" --action-csrfActionAuth Convenience Decorator
You can bundle permissions + guards in one decorator:
from nestipy.web import ActionAuth, ActionPermissionGuard, action
class AppActions:
@ActionAuth("hello:read", guards=[ActionPermissionGuard])
@action()
async def hello(self, name: str) -> str:
return f"Hello {name}"CSRF Token Endpoint
Nestipy exposes a CSRF endpoint at /_actions/csrf by default. It returns a token and sets a csrf_token cookie for double-submit validation:
import { fetchCsrfToken } from './actions';
await fetchCsrfToken(); // sets cookie + returns tokenIf you use the web scaffold, the generated main.tsx calls fetchCsrfToken() on startup so the cookie is already present for the first action call. The typed client defaults to createActionMetaProvider(), which will fetch the CSRF token on-demand if it isn’t available yet.
Server-to-Server Signatures
For internal services, you can sign action payloads:
import { createActionClient, createSignedMeta } from './actions';
const call = createActionClient({
meta: (ctx) => createSignedMeta(process.env.ACTION_SECRET!, ctx),
});Props (Typed)
from nestipy.web import component, props, h
@props
class CardProps:
title: str
active: bool = False
@component
def Card(props: CardProps):
return h.div(h.h2(props.title), class_name="card")Control Flow (Pure Python)
Nestipy Web supports Python control flow in components (compiled to JS):
from nestipy.web import component, h
@component
def Page():
items = ["A", "B"]
rows = []
for item in items:
rows.append(h.li(item))
if items:
message = h.p("Items found")
else:
message = h.p("No items")
return h.div(h.ul(rows), message)Multiple statements per branch are supported as long as each branch assigns the same variables:
if show:
label = "Shown"
message = h.p(label)
else:
label = "Hidden"
message = h.p(label)Nested loops are supported as long as each loop body appends to a list:
rows = []
for group in groups:
rows.append(h.h3(group["name"]))
for item in group["items"]:
rows.append(h.li(item))You can also use list comprehensions and ternary expressions:
return h.div(
h.ul([h.li(item) for item in items if item]),
h.p("Shown") if show else h.p("Hidden"),
)Control Flow Limits
forloops must build UI by callinglist.append(...)if/elif/elsemust either:- return in every branch, or
- assign the same variable(s) in every branch
while,break,continue, andfor/elseare not supported
Commands (nestipy-cli)
nestipy run web:init— createapp/scaffold and initial Vite outputnestipy run web:init --no-build— scaffoldapp/without generatingweb/nestipy run web:build— compile Python UI intoweb/nestipy run web:dev— watchapp/and rebuild on changesnestipy run web:dev --vite— also start Vite dev server (HMR)nestipy run web:dev --vite --install— install frontend deps before starting Vitenestipy run web:dev --vite --proxy http://127.0.0.1:8001— start Vite with backend proxynestipy run web:dev --vite --backend "python main.py"— start backend + frontend togethernestipy run web:dev --vite --backend "python main.py" --backend-cwd ./backend— backend in another foldernestipy run web:install— install frontend dependenciesnestipy run web:add react— add a frontend dependencynestipy run web:add -D tailwindcss— add a dev dependencynestipy run web:add --peer react-dom— add a peer dependencynestipy run web:codegen --output web/src/api/client.ts --lang ts— generate typed clientsnestipy run web:build --spec http://localhost:8001/_router/spec --lang ts— build + generate client intoweb/src/api/client.tsnestipy run web:actions --output web/src/actions.client.ts— generate typed action wrappersnestipy run web:build --actions— build and generateweb/src/actions.client.ts
Defaults via Environment Variables
If you don’t want to pass backend flags every time, set:
NESTIPY_WEB_BACKEND— default backend command forweb:devNESTIPY_WEB_BACKEND_CWD— default working directory for the backend command
Vite Scaffold
If web/ is empty, the build generates:
web/package.jsonweb/vite.config.tsweb/tsconfig.jsonweb/src/main.tsxweb/src/routes.tsxweb/src/actions.ts(RPC action client helper)web/src/index.css(Tailwind v4 via@tailwindcss/vite, no config file)
Server Actions (RPC)
Nestipy Web supports a Next.js-like RPC action flow using a single endpoint.
Full Working Example (Backend + Frontend)
This walkthrough gives you a working backend that exposes:
- HTTP routes (controllers)
- Server actions (single RPC endpoint at
/_actions) - Router spec (
/_router/spec) for typed HTTP clients (optional)
Backend
Example structure (backend under src/, frontend under web/):
main.py
cli.py
pyproject.toml
src/
__init__.py
app_module.py
user_actions.py
user_controller.pysrc/user_actions.py:
from nestipy.common import Injectable
from nestipy.web import action
@Injectable()
class UserActions:
@action()
async def hello(self, name: str) -> str:
return f"Hello, {name}!"
# Cache the result for 30 seconds (key defaults to args/kwargs).
@action(cache=30)
async def get_server_time(self) -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()src/user_controller.py:
from nestipy.common import Controller, Get
@Controller("/api")
class UserController:
@Get("/health")
async def health(self) -> dict:
return {"ok": True}src/app_module.py:
from nestipy.common import Module
from nestipy.web import ActionsModule, ActionsOption
from user_actions import UserActions
from user_controller import UserController
@Module(
imports=[ActionsModule.for_root(ActionsOption(path="/_actions"))],
providers=[UserActions],
controllers=[UserController],
)
class AppModule:
passmain.py:
from granian.constants import Interfaces
from nestipy.core import NestipyFactory
from src.app_module import AppModule
app = NestipyFactory.create(AppModule)
if __name__ == "__main__":
# Optional: enable RouterSpec (for `/_router/spec`) and protect it with a token.
# export NESTIPY_ROUTER_SPEC=1
# export NESTIPY_ROUTER_SPEC_TOKEN=secret
app.listen(
"main:app",
address="127.0.0.1",
port=8001,
interface=Interfaces.ASGI,
reload=True,
)Run backend:
python main.pyEndpoints you now have:
POST /_actions(RPC)GET /_actions/schema(schema for codegen)GET /api/health(HTTP example)GET /_router/spec(optional, enable withNESTIPY_ROUTER_SPEC=1; token viaNESTIPY_ROUTER_SPEC_TOKEN)
Frontend
Create/compile the frontend:
nestipy run web:init
nestipy run web:dev --vite --install --proxy http://127.0.0.1:8001Generate typed server-action wrappers (recommended):
nestipy run web:actions --spec http://127.0.0.1:8001/_actions/schema --output web/src/actions.client.tsWhen running nestipy start --dev --web, the web dev server enables --actions and will automatically refresh web/src/actions.client.ts by polling /_actions/schema as your backend reloads. If NESTIPY_WEB_ACTIONS_WATCH is set (defaulted by the CLI to ./src), the client will only refetch the schema when those files change. The schema endpoint uses ETag, so unchanged schemas return 304 Not Modified.
Optional: generate typed HTTP client from RouterSpec:
# If you configured a token, pass it via query or header:
# - query: /_router/spec?token=secret
# - header: x-router-spec-token: secret
nestipy run web:build --spec http://127.0.0.1:8001/_router/spec --lang ts --output web/src/api/client.tsNow you can call actions from React/TS:
import { createActions } from './actions.client';
const actions = createActions();
const res = await actions.UserActions.hello({ name: 'Nestipy' });
if (res.ok) {
console.log(res.data);
}You can also wire a React component that calls actions and mount it from app/page.py:
web/src/components/HelloAction.tsx:
import React from 'react';
import { createActions } from '../actions.client';
const actions = createActions();
export function HelloAction() {
const [value, setValue] = React.useState<string>('');
React.useEffect(() => {
actions.UserActions.hello({ name: 'Nestipy' }).then((res) => {
if (res.ok) {
setValue(res.data);
}
});
}, []);
return <div className="text-sm text-slate-300">Action says: {value}</div>;
}app/page.py:
from nestipy.web import component, h, external
HelloAction = external("../components/HelloAction", "HelloAction")
@component
def Page():
return h.div(
h.h1("Nestipy Web"),
h(HelloAction),
class_name="p-8 space-y-3",
)Backend
from nestipy.common import Module, Injectable
from nestipy.web import ActionsModule, ActionsOption, action
@Injectable()
class UserActions:
@action()
async def hello(self, name: str) -> str:
return f"Hello, {name}!"
@Module(
imports=[ActionsModule.for_root(ActionsOption(path="/_actions"))],
providers=[UserActions],
)
class AppModule:
passFrontend (Vite)
import { createActionClient } from './actions';
import { createActions } from './actions.client';
const callAction = createActionClient();
const res = await callAction<string>('UserActions.hello', ['Nestipy']);
if (res.ok) {
console.log(res.data);
}
const actions = createActions();
const res2 = await actions.UserActions.hello({ name: 'Nestipy' });
if (res2.ok) {
console.log(res2.data);
}State Management (Zustand)
You can use any React state library by defining the store in TS and calling it from Python via external_fn.
web/src/store.ts:
import { create } from 'zustand';
type CounterState = {
count: number;
inc: () => void;
dec: () => void;
};
export const useCounterStore = create<CounterState>((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}));app/counter/page.py:
from nestipy.web import component, h, external_fn
use_counter_store = external_fn("../store", "useCounterStore", alias="useCounterStore")
@component
def Counter():
count = use_counter_store(lambda s: s.count)
inc = use_counter_store(lambda s: s.inc)
dec = use_counter_store(lambda s: s.dec)
return h.section(
h.h2("Zustand Counter"),
h.div(h.span(count), class_name="counter-display"),
h.div(
h.button("-1", on_click=dec, class_name="btn"),
h.button("+1", on_click=inc, class_name="btn btn-primary"),
class_name="home-actions",
),
class_name="page",
)Install the dependency:
npm install zustandHot Reload
Run both the Python compiler and Vite dev server:
nestipy run web:dev --viteThis watches app/**/*.py, rebuilds TSX on change, and Vite handles HMR.
One Command (Backend + Frontend)
You can start both the backend and the frontend from a single Nestipy-CLI command:
nestipy run web:dev --vite --install --proxy http://127.0.0.1:8001 --backend "python main.py"If your backend entrypoint is not at the repo root, pass a working directory:
nestipy run web:dev --vite --backend "python main.py" --backend-cwd ./backendProduction Build + Serve
Nestipy can serve a built Vite app directly from the backend. Set a dist directory and Nestipy will register a static handler with SPA fallback (for HTML requests).
- Build the frontend (compile Python UI + Vite build):
nestipy run web:build --vite --installThis also generates:
web/src/actions.client.ts(actions client)web/src/api/client.ts(typed HTTP client)
- Serve it from the backend:
NESTIPY_WEB_DIST=web/dist python main.pyOr use CLI flags directly (no env needed):
python main.py --web --web-dist web/distIf --web-dist is omitted, Nestipy looks for web/dist, then src/dist, then dist.
Optional environment variables:
NESTIPY_WEB_STATIC_PATH=/(mount path)NESTIPY_WEB_STATIC_INDEX=index.htmlNESTIPY_WEB_STATIC_FALLBACK=1(serve index.html for HTML requests when file not found)
Vite Proxy
If your Nestipy backend runs on another port, you can configure a Vite proxy so the frontend can call:
/_actions(server actions)/_router/spec(router spec)/_devtools/*(optional)
nestipy run web:dev --vite --proxy http://127.0.0.1:8001Customize proxied paths:
nestipy run web:dev --vite --proxy http://127.0.0.1:8001 --proxy-paths /_actions,/_routerEnv vars:
NESTIPY_WEB_PROXYNESTIPY_WEB_PROXY_PATHS(comma-separated)
Actions Schema (For Codegen)
The actions endpoint exposes a schema:
GET /_actions/schema
Generate web/src/actions.client.ts from a running app:
nestipy run web:actions --spec http://127.0.0.1:8001/_actions/schema --output web/src/actions.client.tsNotes
- Output is plain React + Vite and can be integrated with Tailwind or any React UI kit.
- Use
js("...")only for raw JS snippets; most rendering can stay pure Python. - Components must return a
h(...)tree (no arbitrary Python execution in render). - Nested components in the same file should be decorated with
@componentso the compiler emits them.
