API Routes
docs/guides/api-routes.md · line 20 of 64
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