API Routes
Files under pages/api/ are API endpoints. They are plain Python files (not .pyxl) that handle HTTP requests and return JSON or other responses.
Basic API route
Create pages/api/hello.py. An API module exports an endpoint callable that receives the Starlette Request and returns a response:
from starlette.requests import Request
from starlette.responses import JSONResponse
async def endpoint(request: Request) -> JSONResponse:
return JSONResponse({"message": "Hello, world!"})endpoint handles every HTTP method bound to the route. This responds to GET /api/hello:
curl http://localhost:8000/api/hello
# {"message": "Hello, world!"}HTTP methods
endpoint receives every method bound to the route. Branch on request.method to handle more than one:
from starlette.requests import Request
from starlette.responses import JSONResponse
async def endpoint(request: Request) -> JSONResponse:
if request.method in ("GET", "HEAD"):
users = await fetch_all_users()
return JSONResponse({"users": users})
if request.method == "POST":
body = await request.json()
user = await create_user(body["name"], body["email"])
return JSONResponse({"user": user}, status_code=201)
return JSONResponse({"error": "Method not allowed"}, status_code=405)Handle HEAD wherever you handle GET. RFC 9110 defines HEAD as identical to GET without a response body, and it is what curl -I, uptime monitors, health probers and link checkers send. Pyxle deliberately lets HEAD through to your handler whenever the route accepts GET — so if your own branch only tests == "GET", the request falls past it to your 405, and the route you advertise as a GET endpoint refuses half the clients that would use it. The server strips the body from a HEAD response for you; returning the full JSONResponse is correct.
For multi-method endpoints with automatic 405 Method Not Allowed handling, use an HTTPEndpoint class (below) — Starlette dispatches each request to the matching get/post/… method and rejects the rest. A class-based endpoint gets the HEAD behaviour above for free: Starlette dispatches HEAD to get unless you define a head method.
Using HTTPEndpoint classes
For more structure, use Starlette's HTTPEndpoint:
from starlette.endpoints import HTTPEndpoint
from starlette.requests import Request
from starlette.responses import JSONResponse
class Users(HTTPEndpoint):
async def get(self, request: Request) -> JSONResponse:
return JSONResponse({"users": []})
async def post(self, request: Request) -> JSONResponse:
body = await request.json()
return JSONResponse({"created": True}, status_code=201)Sync endpoints and blocking calls
endpoint can also be a plain synchronous function. Pyxle dispatches sync
endpoints through Starlette's threadpool, so a blocking body — a database
driver, a sync SDK — occupies a worker thread instead of freezing the event
loop:
import sqlite3
import threading
from starlette.requests import Request
from starlette.responses import JSONResponse
_local = threading.local()
def _db() -> sqlite3.Connection:
# One persistent connection per worker thread: avoids paying the
# connect/teardown cost (and SQLite WAL churn) on every request.
conn = getattr(_local, "conn", None)
if conn is None:
conn = _local.conn = sqlite3.connect("app.db")
conn.row_factory = sqlite3.Row
return conn
def endpoint(request: Request) -> JSONResponse:
row = _db().execute("SELECT * FROM items WHERE id = ?", (1,)).fetchone()
return JSONResponse(dict(row) if row else {"error": "not found"})The same applies to sync get/post/… methods on HTTPEndpoint classes —
Starlette threadpools those natively.
Inside an async def endpoint, never call blocking libraries directly — that
stalls every request on the worker's event loop. Either make the endpoint
sync (above) or wrap the call:
import asyncio
async def endpoint(request: Request) -> JSONResponse:
rows = await asyncio.to_thread(blocking_query, "SELECT ...")
return JSONResponse({"rows": rows})For sub-millisecond calls the sync-endpoint form is usually faster — one threadpool hop per request instead of a hop per wrapped call.
Note: route hooks (and the default API policies) wrap function endpoints.
HTTPEndpoint classes are dispatched natively by Starlette and bypass route
hooks — the same rationale as WebSocket routes.
WebSocket endpoints
Since 0.3.0, an API module can export async def websocket(ws) to register a WebSocket handler at the same path. The file can export both endpoint (HTTP) and websocket — they bind to the same URL and Pyxle dispatches based on the protocol of the incoming request.
# pages/api/chat.py
from starlette.websockets import WebSocket
async def websocket(ws: WebSocket) -> None:
await ws.accept()
try:
while True:
message = await ws.receive_text()
await ws.send_text(f"echo: {message}")
except Exception:
# Client disconnected or socket closed; nothing to clean up.
passClient side:
const socket = new WebSocket(`ws://${location.host}/api/chat`);
socket.onmessage = (event) => console.log(event.data);
socket.onopen = () => socket.send('hello');You can also export a Starlette WebSocketEndpoint subclass for multi-method dispatch:
from starlette.endpoints import WebSocketEndpoint
class websocket(WebSocketEndpoint):
encoding = "text"
async def on_connect(self, ws): await ws.accept()
async def on_receive(self, ws, data): await ws.send_text(f"echo: {data}")
async def on_disconnect(self, ws, close_code): passNotes:
- WebSocket handlers run outside the HTTP route-hooks pipeline — hooks wrap request-to-response callables and the WS lifecycle doesn't match that shape. Authenticate, rate-limit, and log inside the handler body.
- CSRF doesn't apply to WebSocket upgrades. Enforce your own origin / session checks in
on_connectbeforeawait ws.accept().
Dynamic API routes
Use the same bracket syntax as page routes:
pages/api/users/[id].py --> /api/users/:idWhere API routes can live
An api directory can sit anywhere under pages/, not only at the top. A
.py file is an endpoint whenever the URL it maps to has an api segment:
pages/api/health.py --> /api/health
pages/s/[slug]/api/v2/summary.json.py --> /s/:slug/api/v2/summary.jsonThe second form is what a compatibility API needs — a shape another vendor's clients already expect, served per tenant. The path is derived from the file path in full, so dynamic segments and a file extension in the URL both work.
Everywhere else, .py files are ignored by routing. That is deliberate: it
lets you colocate helpers with the pages that use them without publishing them
by accident.
pages/s/[slug]/queries.py --> not a route, importable by neighboursAn api directory holds no pages
A directory named api is server ground, all the way through. One rule
decides everything about it, so you can predict all of it from the name:
In an api directory |
What happens |
|---|---|
.py files |
Endpoints — they serve URLs |
.jsx, .css, .json, … |
Never copied into the client build |
.pyxl files |
Refused: an api directory holds no pages |
| Links to its URLs | Left to the browser, never client-side navigations |
So a .pyxl page inside one is reported when your project is scanned, by
pyxle dev and pyxle build alike:
A directory named 'api' holds endpoints, not pages, but this page sits inside one:
pages/docs/api/overview.pyxl
An 'api' directory is server ground throughout: its .py files serve URLs, its
client assets (.jsx, .css, .json) are never shipped to the browser, and links to
its URLs are never client-side navigations — so a page there loads without the
components beside it. Rename the directory (for example 'reference/' or
'api-docs/'), or move the page out of it.Rename the directory — pages/docs/reference/overview.pyxl — and the page and
the components beside it work as they do anywhere else.
Only directories count, as everywhere else in this rule: pages/api.pyxl is an
ordinary page serving /api.
A page URL may still contain api
The reserved thing is the directory, not the URL shape. A dynamic route fills
its segments in from the request, so pages/docs/[...slug].pyxl serves
/docs/api/config and pages/s/[slug]/index.pyxl serves /s/api — both are
ordinary pages and both render normally.
What such a page gives up is the client router. It reads the rule off the URL,
where a page is indistinguishable from an endpoint, and resolves the ambiguity
towards safety: a link to an api path is never prefetched on hover — a
prefetch would issue a GET at what may be your endpoint, from a mouse
movement — and a click performs an ordinary navigation rather than a
client-side one. The page loads the way it would with JavaScript disabled. The
same rule governs the .md renditions in AI accessibility: links to
api paths are left pointing at the endpoint.
Private modules inside an api directory
Inside an api directory the same colocation is available, marked the way
Python already marks it: a leading underscore means private. A file or
directory whose name starts with _ is never a route.
pages/api/orders.py --> /api/orders
pages/api/_shared.py --> not a route
pages/api/__init__.py --> not a route
pages/api/_internal/db.py --> not a route (the whole directory is private)A private module is an ordinary Python module — import it from the endpoints beside it exactly as you would any other module in your project:
# pages/api/_shared.py
DEFAULT_LIMIT = 50
def serialise(order):
return {"id": order.id, "total": order.total}# pages/api/orders.py
from starlette.responses import JSONResponse
from pages.api._shared import DEFAULT_LIMIT, serialise
async def endpoint(request):
orders = await fetch_orders(limit=DEFAULT_LIMIT)
return JSONResponse({"orders": [serialise(order) for order in orders]})Only the segments at or below the api directory are read this way. Above it
the path is a URL, where an underscore is just a character:
pages/_admin/api/health.py still serves /_admin/api/health.
Private modules are not routes, so nothing compiles them — pyxle build copies
them into dist/ instead, and they deploy with the endpoints that import them
(what to ship).
Under pyxle dev they still hot-reload. Saving one prints
✅ Reloaded pages/api/_shared.py in 9 ms— "reloaded" rather than "rebuilt", because nothing was compiled: the module is dropped from Python's import cache and every endpoint that imports it is re-imported, so the next request runs your new code. If the endpoint cannot be imported afterwards (the helper has a syntax error, or the name it exports was renamed), the previous route table keeps serving and the terminal says so; fix the file and save again.
The rule applies to .py modules in an api directory, not to pages: a
.pyxl file named with a leading underscore is a normal route.
A route may end in an extension the browser reads as an asset — .js, .css,
.json — and it is still your endpoint. pages/api/embed.js.py serves
/api/embed.js, which is how an embeddable widget is usually shipped:
# pages/api/embed.js.py
from starlette.responses import Response
async def endpoint(request):
return Response(
"console.log('hello from your app');",
media_type="application/javascript; charset=utf-8",
headers={"access-control-allow-origin": "*"},
)from starlette.requests import Request
from starlette.responses import JSONResponse
async def endpoint(request: Request) -> JSONResponse:
user_id = request.path_params["id"]
user = await fetch_user(user_id)
if user is None:
return JSONResponse({"error": "Not found"}, status_code=404)
return JSONResponse({"user": user})Reading request bodies
async def endpoint(request: Request) -> JSONResponse:
# JSON body
body = await request.json()
# Form data
form = await request.form()
# Raw body
raw = await request.body()
return JSONResponse({"received": True})Error responses
Return appropriate HTTP status codes:
async def endpoint(request: Request) -> JSONResponse:
api_key = request.headers.get("x-api-key")
if not api_key:
return JSONResponse({"error": "Missing API key"}, status_code=401)
data = await fetch_data(api_key)
if data is None:
return JSONResponse({"error": "Not found"}, status_code=404)
return JSONResponse({"data": data})API routes vs server actions
| Feature | API routes | Server actions |
|---|---|---|
| File location | pages/api/*.py |
Inside .pyxl files |
| HTTP methods | Any (GET, POST, PUT, etc.) | POST only |
| Response format | Any Starlette Response | JSON dict |
| Called from | Anywhere (curl, fetch, etc.) | <Form> or useAction |
| CSRF protection | On for POST/PUT/PATCH/DELETE¹ | Enabled by default |
| Use case | Public APIs, webhooks, integrations | Form submissions, mutations |
¹ CSRF runs app-wide, so a state-changing API request (POST/PUT/PATCH/DELETE) must carry the double-submit token by default — same as any other route. A public webhook or third-party integration that can't send the token must list its path prefix in csrf.exemptPaths. Safe methods (GET/HEAD/OPTIONS) are never checked.
Next steps
- Add middleware to your routes: Middleware
- Protect routes with CSRF: Security