The fullstack Python meta-framework. FastAPI on the server, Vite on the client, one file per page. Startup in under a second, HMR in under 100 ms.
A route is a file. Put .nexy under src/routes/ and you get a page: Python for the logic, Jinja2
for the markup, an optional React, Vue, Svelte, Solid or Preact component for interactivity. Put
.py there and you get an API handler: the function names are the HTTP methods.
The server is FastAPI and Starlette. The client is Vite. There is no second process, no second origin, no glue project between the frontend and the backend.
| You get | Instead of |
|---|---|
| Sub-second startup | 10-30 s waiting for Next.js, Rails, Django |
| One file = server logic, markup, interactive island | Separate API, UI and build-tooling projects |
| Zero-config Vite integration | Hand-wiring Webpack, Vite or Parcel |
| Any supported client framework, or none | Being locked into React or Vue |
| SSR, ISR and parallel SSG built in | SSG bolted on afterwards |
| Requirement | Why |
|---|---|
| Python 3.12+ | requires-python in pyproject.toml |
| uv | runs and manages Nexy |
| Node.js 18+ | only for React, Vue, Svelte, Solid or Preact |
uvx nexy new my-app
cd my-app
uvx nexy devOpen http://localhost:3000. uvx downloads Nexy and runs it, so nothing is installed globally.
Install the CLI once with uv tool install nexy to drop the prefix and use nx dev (or
nexy dev).
new asks four questions: file-based router or modular, web or API, client framework or none, ORM or
none. The answers decide what the scaffold writes.
Change a file, the page reloads. nx dev compiles the routes you touched and proxies Vite through
its own port, so the browser only ever talks to 3000.
my-app/
├─ src/
│ ├─ routes/ file-based routes (FBR)
│ │ ├─ index.nexy → /
│ │ ├─ layout.nexy shared layout, not a route
│ │ └─ api/hello.py → /api/hello
│ ├─ apps/ modular router, decorators (optional, next to routes/)
│ ├─ components/ imported components (a convention, not enforced)
│ └─ globale.css global styles (created by `nexy new`)
├─ public/ served at /
├─ nexyconfig.py project configuration
├─ pyproject.toml
├─ package.json only with a client framework
├─ vite.config.ts only with a client framework
└─ __nexy__/ build output - generated, never edited
The URL is the file path relative to src/routes/, minus the extension.
| File | URL |
|---|---|
index.nexy |
/ |
about.nexy |
/about |
blog/index.nexy |
/blog |
blog/[slug].nexy |
/blog/{slug} |
docs/[...path].nexy |
/docs/{path:path} |
(marketing)/pricing.nexy |
/pricing |
api/users.py |
/api/users |
index maps to its directory, not to /index. A directory in parentheses is a group: it organises
files without appearing in the URL.
These names are not routes: layout.nexy, loading.nexy, errors.nexy, notfound.nexy,
dependencies.py, __init__.py. Files and folders starting with _ or . are ignored, which is
the place for partials and drafts.
One file per URL. blog.nexy next to blog/index.nexy registers two routes for the same path; pick
one.
One segment, [slug], becomes a page property:
---
slug: prop[str]
---
<h1>{{ slug }}</h1>
Several segments, [...path], arrive as a single string with the slashes included. In a .nexy
page only int and float annotations are converted; every other segment arrives as a string.
layout.nexy wraps every page in its directory and in its children. The page is passed as
children:
---
children: prop[str]
---
<header>...</header>
<main>{{ children }}</main>
<footer>...</footer>
With a client framework configured, layout.tsx, layout.jsx, layout.vue and layout.svelte
work too.
notfound.nexy answers 404 and errors.nexy answers 500, each scoped to its directory. Both
must exist at the routes root to be installed at all; a copy in a subdirectory refines the page for
that prefix. The 500 handler catches every unhandled exception, API handlers included, and answers
with HTML - return a JSONResponse when the caller expects JSON.
dependencies.py follows the same hierarchy: a dependency declared there applies to that directory
and its children.
loading.nexy is reserved but has no runtime effect yet.
| Mode | How | Behaviour |
|---|---|---|
| SSR | default | rendered per request by Jinja2, or by the framework at build time for framework routes |
| ISR | revalidate = 60 in the frontmatter |
in-process, per-path cache; sends Cache-Control: public, s-maxage=60, stale-while-revalidate=60 |
| CSR | useCSR = True in nexyconfig.py |
first load returns HTML, then the client router navigates; .nexy routes are disabled |
| SSG | nx build |
framework routes pre-rendered to HTML by a per-file esbuild pipeline, one file per worker |
CSR requires a client framework, and is incompatible with a modular useRouter. Only .tsx,
.jsx, .vue and .svelte routes are navigable client-side; .md can use a CSR shell.
A .py file under src/routes/ is an API handler. The file path is the URL, and each function
named after an HTTP method becomes an endpoint.
# src/routes/api/items.py → /api/items
from fastapi import Request
from pydantic import BaseModel
class Item(BaseModel):
name: str
async def GET(request: Request):
return {"items": []}
async def POST(request: Request, body: Item):
return {"created": body.name}Supported names: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.
| Parameter source | How it arrives |
|---|---|
Path segment [id] |
function argument id, converted to its annotation |
| Query string | page: int = 1 |
| JSON body | Pydantic model argument |
| FastAPI primitives | Depends, Header, Cookie, Form, UploadFile - unchanged |
FastAPI validates the annotation and answers 422 on a mismatch. Return a dict, a list or a
BaseModel for JSON; return a response object for a custom status or content type.
Every handler appears in the generated OpenAPI schema, served at /openapi.json and rendered by
Scalar at /docs. Disable both with useDocs = False.
Define SOCKET in a .py route file to register a WebSocket at the same path:
# src/routes/ws/chat.py
from fastapi import WebSocket
async def SOCKET(websocket: WebSocket):
await websocket.accept()
await websocket.send_text("hello")
await websocket.close()Files are the default, not the only option. Classes and decorators produce an APIRouter that is
mounted with useRouter:
# src/apps/app_controller.py
from nexy.decorators import Controller
from nexy.core.types import JsonResponse
@Controller(prefix="/users")
class UserController:
def get(self) -> JsonResponse:
return {"users": []}# src/apps/app_module.py
from nexy.decorators import Module
from .app_controller import UserController
@Module(prefix="/api")
class AppModule:
controllers = [UserController]# nexyconfig.py
class NexyConfig(NexyConfigModel):
useRouter = AppModuleMethods are matched by name, case-insensitively; one handler per HTTP method, duplicates raise at
startup. @Injectable() classes are resolved into a controller's __init__; register them in
providers to resolve them eagerly at startup. Scope.SINGLETON, Scope.REQUEST and
Scope.TRANSIENT are available for their lifetime, and Container.resolve(Service) builds one
yourself. @UseGuard() and @Middleware() add FastAPI dependencies; @UseRoute() is OpenAPI
metadata only.
File-based routing keeps working when a modular router is mounted: src/routes/ is scanned whenever
it exists.
A view renders the page body. Pick one format per file.
| Format | File | Server render | Client framework |
|---|---|---|---|
.nexy |
page | Jinja2 + Python logic, streamed | optional |
.md |
pure Markdown | cmarkgfm at request time, GFM, layout-wrapped | optional via layout |
.mdx |
Markdown with components | framework SSR | React or Preact |
.tsx, .jsx |
component | framework SSR / SSG | React or Preact |
.vue |
SFC | framework SSR / SSG | Vue |
.svelte |
SFC | framework SSR / SSG | Svelte |
Python on top, HTML in the middle, CSS at the bottom.
---
from nexy import usePathname
slug: prop[str] = "untitled"
pathname = usePathname()
---
<article class="post">
<h1>{{ slug }}</h1>
<p>You are on {{ pathname }}</p>
</article>
<style>
.post { max-width: 60ch; }
</style>
Everything between the --- lines is Python. Imports are resolved when the component is compiled;
the rest runs on every request, so it can read request state. prop[type] declares a property and
= value gives a default; a property without a default must be passed by the parent.
The body is Jinja2 with the frontmatter variables and the properties in scope. Autoescaping is
off: {{ value }} is inserted as written. Escape untrusted values with {{ value | e }}, and
wrap JavaScript containing {{ or {% in {% raw %}...{% endraw %}.
Declare props before using them: an undeclared variable renders as an empty string. Keep side effects inside functions - the frontmatter runs again on every request.
Import a .nexy component and use it as a tag; children go through <slot />:
---
from "@components/card.nexy" import Card
---
<Card title="Hello">
<p>Child content</p>
</Card>
Aliases come from useAliases. The common setup maps @ to src/, so @components/card.nexy
means src/components/card.nexy.
Import a client component by path; the compiler mounts it and passes the attributes as props:
---
from "@/components/Counter.tsx" import Counter
---
<Counter start="0" />
<style> blocks are emitted with the page that declares them and are not scoped. Prefix your
classes.
A .md file is pure content: rendered with cmarkgfm, heading IDs added, a table of contents
derived, then wrapped in the nearest layout.*. Frontmatter there is not Python logic; use .nexy
when you need server logic. Locale variants such as about.fr.md are supported.
.mdx is compiled by the framework pipeline and needs React or Preact. The framework is detected
from imports; if both are imported, React wins.
# nexyconfig.py
from nexy.core.models import NexyConfigModel
from nexy.frontend import react
class NexyConfig(NexyConfigModel):
useFF = react() # vue(), svelte(), solid(), preact()With a framework enabled, its extensions become active routes, and nx new sets it up with Vite and
Tailwind. useVite defaults to True.
A framework route is a normal page, not a client-only island: the HTML exists before the bundle runs, so the first paint does not wait for JavaScript.
They return HTML strings and are used two ways: as a Jinja call or as an HTML-style tag. The compiler turns the tag into the call, so both lines below are equivalent.
{{ Audio("/theme.mp3", controls=True) }}
<Audio src="/theme.mp3" controls=True />
Tags are self-closing, attributes are keyword arguments, and True / False / numbers stay
unquoted (controls alone would become an empty string - write controls=True).
| Helper | Signature |
|---|---|
Image |
Image(src, alt="", width=None, height=None, loading="lazy", class_name=None, **attrs) |
Link |
Link(href, children="", class_name=None, prefetch=True, **attrs) |
Audio |
Audio(src, controls=True, autoplay=False, loop=False, muted=False, preload="metadata", class_name=None, sources=None, **attrs) |
Video |
Video(src, width=None, height=None, ...) |
Import |
Import(path, framework, symbol), same as importing a component in Python |
Link renders <a data-nexy-link> and is handled by the client router; prefetch=True warms the
target on hover or touch. sources adds <source> tags with the MIME type derived from each
extension.
Template().render(path, context) renders a template file and Template().invalidate(path) drops
it from the cache. Vite() returns the client tags for the current mode; Nexy calls it for every
page.
Form and Script are exported placeholders with no behaviour yet.
Hooks read the current request from inside a .nexy page. They only work while a request is being
handled; calling one elsewhere raises RuntimeError.
| Hook | Returns |
|---|---|
usePathname() |
request path, e.g. "/blog/post-1" |
useSearchParams() |
query string as dict[str, str], first value per key |
useQuery() |
dynamic path parameters, e.g. {"slug": "post-1"} |
useRouter() |
{"path", "base_url", "url_for"} |
useCookies() |
all cookies as dict[str, str] |
useSession() |
the session dict, {} when sessions are not enabled |
useToc(depth_range="2-6") |
table of contents HTML, "" when there is none |
useViews(path, context=None) |
renders another view and returns an HTMLResponse |
---
from nexy import useCookies, usePathname, useSearchParams
pathname = usePathname()
page = useSearchParams().get("page", "1")
theme = useCookies().get("theme", "light")
---
<p>{{ pathname }} - page {{ page }} - {{ theme }} theme</p>
useSession() needs useSession = {...} in nexyconfig.py. useRouter() here is the request hook,
not the config field.
Configuration is a class in nexyconfig.py at the project root. Nexy loads it from the directory it
runs in; a project without the file uses the defaults.
from nexy.core.models import NexyConfigModel
from nexy.frontend import react
class NexyConfig(NexyConfigModel):
useFF = react()
usePort = 3000
useHost = "0.0.0.0"
useTitle = "My App"
useAliases = {"@": "src/"}| Field | Default | Meaning |
|---|---|---|
usePort |
3000 |
port for dev and start |
useHost |
"0.0.0.0" |
bind address |
useSslKeyfile, useSslCertfile |
None |
serve HTTPS directly |
useCSR |
False |
client-side routing; disables .nexy routes |
useRouter |
None |
an APIRouter or module mounted next to the file-based routes |
excludeDirs |
["templates", "extension", "docs"] |
directories skipped when scanning routes |
useAliases |
None |
import aliases, e.g. {"@": "src/"} |
useFF |
None |
react(), vue(), svelte(), solid() or preact() |
useVite |
True |
build and serve client bundles with Vite |
useMarkdownExtensions |
[] |
extra extensions for .md and .mdx |
useDocs |
True |
serve the OpenAPI schema and the Scalar page |
useDocsUrl |
"/docs" |
path of that page |
useTocDepth, useTocTitle, useTocAuto |
"2-6", "Table of Contents", True |
table of contents |
useMiddlewares, useCORS, useGZip, useTrustedHost, useSession, useAuth |
[], None |
middleware |
useHTTPSRedirect |
False |
redirect HTTP to HTTPS |
Reserved-name extensions are configurable too: useLayoutExtensions = ["md"] makes layout.md
reserved exactly like layout.nexy. The same exists for notfound, errors and loading.
Three fields are declared but read by nothing: useViteDevUrl, useLocaleDetection, useRTL.
useRedocsUrl is read but no route is mounted for it. Setting any of them has no effect.
class NexyConfig(NexyConfigModel):
useLocales = ["en", "fr"]
useDefaultLocale = "en"
useLocaleCookieName = "nexy-locale"
useLocalesDir = "locales" # runtime locale files
useLocalesSourceDir = "src/locales" # source for `nx translate`For every request, the locale is resolved in this order: the URL prefix /fr/... (stripped from the
path before routing), the nexy-locale cookie, Accept-Language, then useDefaultLocale.
from nexy import current_locale, t, trans, useLocale
trans("hero.title", "Hello") # dotted key, default, current locale
t("hero.title", "Hello") # alias
current_locale.get() # "fr"
useLocale() # {"locale", "is_rtl", "available", "default", "trans"}trans is available in every template:
<h1>{{ trans("hero.title", "Hello") }}</h1>A missing key falls back to the default, then to the key itself.
useLocalesDir holds one file per locale, with nested objects matching the dotted keys
(locales/fr.json, locales/en.json); a <locale>.py exporting a translations dict works too.
@i18n() classes give you class attributes that translate at access time, and nx translate
scans them under useLocalesSourceDir, translates them and writes generated packs to
__nexy__/i18n/:
from nexy.modules.i18n import i18n
@i18n()
class Home:
title = "Hello"nx translate --source en --target frThe command uses an online translator and needs network access.
@Action turns a function into a POST endpoint with a generated path, so a page can call server
code without declaring a route file.
# src/actions/user.py
from nexy.decorators import Action
@Action
async def create_user(name: str) -> dict:
return {"name": name}At startup Nexy imports every module under src/, registers each action, and mounts it at
POST /_nx_<hash>. Parameters and body models follow the function signature, like any FastAPI
route.
Inside a .nexy template an action is exposed three ways:
| Variable | Content |
|---|---|
create_user |
the function itself, callable in the template |
__actions["create_user"] |
the same function, by name |
__actions_path["create_user"] |
the generated URL |
<form method="post" action="{{ __actions_path['create_user'] }}">
<input name="name">
<button type="submit">Create</button>
</form>
The path is deterministic - a hash of the function's module and name. It keeps the endpoint out of casual discovery but it is not a secret; do not rely on it for authorization. No TypeScript client is generated by the current build.
The binary is nx, also installed as nexy. Without a global install, prefix with uvx nexy.
| Command | Alias | What it does |
|---|---|---|
nx dev |
nx d |
development server: compile on change, Vite, HMR |
nx start |
nx s |
production server on the compiled output |
nx build |
nx b |
production build |
nx new |
nx n |
scaffold a project |
nx init |
nx i |
add Nexy to an existing project |
nx migrate |
nx m |
migrate from another framework |
nx check |
nx c |
type-check: mypy on Python, tsc on TypeScript |
nx format |
nx f |
format Python, JS/TS and .nexy files |
nx test |
- | run pytest; --client adds vitest |
nx translate |
nx t |
generate locale files |
Common flags: --port and --host for dev and start (defaults from usePort and useHost);
--check for build (runs ruff and mypy after the build) and for format (report instead of
rewrite); --template / -t for new; --client and --no-server for test.
nx test runs pytest. Extra arguments are forwarded to it:
nx test # pytest
nx test -k create_user # forwarded to pytest
nx test --client # pytest, then vitestpytest runs on tests/ when that directory exists, otherwise on the project root. If pytest is not
installed, the run is skipped with a warning. vitest runs with npx vitest run, and only when
vitest.config.ts exists or vitest appears in package.json. There is no built-in end-to-end
runner: drive the app with TestClient for server behaviour, and add Playwright or Cypress yourself
for the browser.
A server test uses the ASGI app directly:
# tests/test_pages.py
from fastapi.testclient import TestClient
from nexy.app import app
def test_home_renders():
client = TestClient(app)
response = client.get("/")
assert response.status_code == 200For per-test isolation, build a fresh application instead of reusing the module-level one:
from fastapi.testclient import TestClient
from nexy.modules.routers.app import AppServer
with TestClient(AppServer().run()) as client:
assert client.get("/api/hello").json() == {"message": "Hello"}AppServer().run() reads nexyconfig.py from the working directory and assembles routes,
middleware and locale state. Test API handlers with client.get / client.post; test actions by
posting to __actions_path["create_user"], which the rendered page exposes.
nx build
nx start --host 0.0.0.0 --port 8000nx build writes everything to __nexy__/: the compiled Python routes, the client bundle under
__nexy__/client/, and the pre-rendered HTML for framework routes. nx start runs Uvicorn on that
output - Python only, no Node process and no Vite process at runtime.
Deploy by copying the project with __nexy__/, or build inside the image. The build stage needs
Node and Python; the runtime stage needs Python only.
FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project
COPY . .
RUN uv sync --frozen && uv run nx build
FROM python:3.13-slim
WORKDIR /app
COPY --from=build /app /app
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 8000
CMD ["nx", "start", "--host", "0.0.0.0", "--port", "8000"]Static hosting is a deployment choice, not a build mode: there is no --static flag. Put static
files in public/; they are served from / in development and production. Behind a reverse proxy,
remember to forward WebSocket upgrade requests.
- A
.nexyfile is compiled to a Python module in__nexy__/, mounted through a virtual file system, and rendered with Jinja2 at request time. - Framework routes are pre-rendered to HTML at build time by a per-file esbuild pipeline; the worker pool runs one file per worker, sized to the CPU count. A route whose server render fails is reported as client-only instead of failing the build.
- In development, the watcher recompiles what changed and restarts Uvicorn cleanly - no importlib hacks, no stale modules. Vite runs on an internal port and Nexy proxies it through the app origin.
- In production, one Uvicorn process serves the routes, the HTML and the assets from one origin.
Internals, request lifecycle and compiler pipeline: ARCHITECTURE.md.
| Feature | Nexy | Next.js | Django + HTMX | Remix |
|---|---|---|---|---|
| Language | Python + any JS framework | JS/TS only | Python + HTMX | JS/TS only |
| Server process | Uvicorn + FastAPI, one process | Node | Python (ASGI/WSGI) | Node |
| Startup | < 1 s | 10-30 s | 3-8 s | 8-15 s |
| HMR | < 100 ms | ~500 ms | N/A | ~300 ms |
| File format | .nexy (Python + Jinja2 + UI) |
.tsx / .jsx |
.py + .html |
.tsx |
| SSR | Built-in (esbuild) | Built-in (React) | Manual | Built-in (React) |
| SSG | Parallel worker pool | next export |
Third-party | Via Vite |
| Routing | File-based + modular | File-based | Manual | File-based |
| Framework | SSR | SSG | HMR |
|---|---|---|---|
| React | ✓ | ✓ | ✓ |
| Vue | ✓ | ✓ | ✓ |
| Svelte | ✓ | ✓ | ✓ |
| Solid | ✓ | ✓ | ✓ |
| Preact | ✓ | ✓ | ✓ |
| None (vanilla) | ✓ | - | ✓ |
This README covers the whole framework. The pages below go deeper on one topic each, in plain Markdown, and are written from the code.
| I want | Read |
|---|---|
| to create a project and run it | Getting started |
| the files and folders | Project structure |
| the URL of a file | Routing |
| dynamic segments, layouts, error pages | Pages |
| the current path, query, cookies, session | Hooks |
| to write a page | .nexy files |
| Markdown or MDX | Markdown |
| built-in HTML helpers | Components |
| SSR, CSR, ISR | SSR, CSR, ISR |
| a WebSocket | WebSocket |
| controllers and modules | Modular routing |
| to add an endpoint | API |
| a form posted from a page | Actions |
| translations | i18n |
| a config field | Config |
| the client build and Vite | Frontend |
| every command and flag | CLI |
| to test a project | Testing |
| build output and Docker | Deploy |
| rules for an AI agent | Agents |
llms.txt is the same map in one file, for tools that read plain text.
Contributions are welcome - seasoned engineers and first-time PR authors alike.
- Good first issues
- CONTRIBUTING.md - setup, quality gates, pull requests
- ARCHITECTURE.md - internals
- Discussions and Discord
- Issues - bugs and feature requests
Security issues: see SECURITY.md. Do not open a public issue for a vulnerability.
MIT - free forever, built in the open.
