DocsPython frameworks

From Django to Nexy

In Nexy a Django view and its urls.py line become one file under src/routes/, templates stay Jinja2-like, and the ORM, forms and admin are your choice instead of built in.

Each section below shows a Django habit, then the same thing in Nexy.

A view and its URL

blog/views.py
from django.http import JsonResponse  def hello(request):    return JsonResponse({"message": "Hello"})
blog/urls.py
from django.urls import path from . import views urlpatterns = [path("api/hello/", views.hello)]

There is no urls.py: the file path is the URL, the function name is the method, and a returned dict is JSON.

src/routes/api/hello.py
async def GET():    return {"message": "Hello"}

Path converters

blog/urls.py
urlpatterns = [path("api/posts/<int:id>/", views.post_detail)]

The converter becomes a type annotation on a [id] segment:

src/routes/api/posts/[id].py
async def GET(id: int):    return {"id": id}

<path:rest> becomes [...rest].py.

request.GET and request.POST

blog/views.py
def search(request):    q = request.GET.get("q", "")    page = int(request.GET.get("page", 1))    return JsonResponse({"q": q, "page": page})

A query parameter is a function parameter, converted and validated:

src/routes/api/search.py
async def GET(q: str = "", page: int = 1):    return {"q": q, "page": page}

Class-based views

blog/views.py
from django.views import View  class NoteView(View):    def get(self, request, id):        return JsonResponse({"id": id})     def delete(self, request, id):        return JsonResponse({"deleted": id})

One function per method, in the file of the URL:

src/routes/api/notes/[id].py
async def GET(id: int):    return {"id": id}  async def DELETE(id: int):    return {"deleted": id}

Templates and render

blog/views.py
from django.shortcuts import render  def post_list(request):    posts = [{"title": "Hello"}, {"title": "Second"}]    return render(request, "blog/post_list.html", {"posts": posts})
blog/templates/blog/post_list.html
{% extends "base.html" %}{% block content %}<ul>  {% for post in posts %}  <li>{{ post.title }}</li>  {% endfor %}</ul>{% endblock %}

The view and the template are one .nexy file. The tags are Jinja2, close to Django's:

src/routes/blog/index.nexy
---posts = [{"title": "Hello"}, {"title": "Second"}]---<ul>  {% for post in posts %}  <li>{{ post.title }}</li>  {% endfor %}</ul>

base.html and {% block %}

templates/base.html
<!DOCTYPE html><html>  <body>{% block content %}{% endblock %}</body></html>

A layout.nexy wraps every page of its folder and below, without {% extends %} in the pages:

src/routes/layout.nexy
---children: prop[NexyElement]---<!DOCTYPE html><html>  <body>{{ children | safe }}</body></html>

{% include %} and inclusion tags

templates/card.html
<article><h2>{{ title }}</h2>{{ body }}</article>
{% include "card.html" with title="Latest" body="This week" %}

A component is a .nexy file with typed props and a slot for its children:

src/components/Card.nexy
---title: prop[str]---<article>  <h2>{{ title }}</h2>  <Slot /></article>
src/routes/index.nexy
---from "@/components/Card.nexy" import Card---<Card title="Latest">  <p>This week</p></Card>

@ is src/: nx new writes useAliases = {"@": "src"} in nexyconfig.py.

Template filters

Django Jinja2 in .nexy
{{ name|default:"guest" }} {{ name or "guest" }} or {{ name | default("guest") }}
{{ title|upper }} {{ title | upper }}
{{ body|safe }} {{ body | safe }}
{{ items|length }} {{ items | length }}
{% url "post" post.id %} /posts/{{ post.id }}: the URL is the file path

Models and the ORM

blog/models.py
from django.db import models  class Post(models.Model):    title = models.CharField(max_length=200)    published = models.BooleanField(default=False)
blog/views.py
def published(request):    posts = Post.objects.filter(published=True)    return JsonResponse({"titles": [p.title for p in posts]})

Nexy has no ORM of its own. nx new can scaffold SQLModel, SQLAlchemy or Tortoise-ORM; with SQLModel the model and the query read like this:

src/models/post_model.py
from sqlmodel import Field, SQLModel  class Post(SQLModel, table=True):    id: int | None = Field(default=None, primary_key=True)    title: str    published: bool = False
src/routes/api/posts/published.py
from sqlmodel import Session, select from src.configs.db import enginefrom src.models.post_model import Post  async def GET():    with Session(engine) as session:        posts = session.exec(select(Post).where(Post.published)).all()    return {"titles": [post.title for post in posts]}

See Databases.

Migrations

Django Nexy
python manage.py makemigrations nx migrate -m "add posts"
python manage.py migrate nx migrate --upgrade

nx migrate drives Alembic for SQLModel and SQLAlchemy, and Aerich for Tortoise-ORM.

Forms and CSRF

blog/views.py
from django.shortcuts import redirect  def add_note(request):    if request.method == "POST":        NOTES.append(request.POST["title"])        return redirect("notes")    return render(request, "notes.html", {"notes": NOTES})
blog/templates/notes.html
<form method="post">  {% csrf_token %}  <input name="title">  <button type="submit">Add</button></form>

An action is the function the form posts to. Its parameters are the form fields, and the visitor is sent back to the page with a 303:

src/actions/notes.py
from nexy.decorators import Action NOTES: list[str] = []  @Actionasync def add_note(title: str) -> None:    NOTES.append(title)
src/routes/notes.nexy
---from src.actions.notes import NOTES---<form method="post" action="{{ __actions_path['add_note'] }}">  <input name="title">  <button type="submit">Add</button></form><ul>  {% for note in NOTES %}  <li>{{ note }}</li>  {% endfor %}</ul>

There is no {% csrf_token %}: see Actions for what protects the action path, and what you still check yourself.

Django forms and DRF serializers

blog/serializers.py
from rest_framework import serializers  class PostSerializer(serializers.Serializer):    title = serializers.CharField(max_length=200)    published = serializers.BooleanField(default=False)

A Pydantic model validates the body, and an invalid one answers 422 with the field that failed:

src/routes/api/posts/index.py
from pydantic import BaseModel, Field  class NewPost(BaseModel):    title: str = Field(max_length=200)    published: bool = False  async def POST(post: NewPost):    return post

@login_required

blog/views.py
from django.contrib.auth.decorators import login_required  @login_requireddef dashboard(request):    return render(request, "dashboard.html")

A dependencies.py guards a whole folder:

src/routes/dashboard/dependencies.py
from nexy import unauthorizedfrom nexy.http import Request  def require_login(request: Request) -> None:    if not request.session.get("user"):        unauthorized()  dependencies = [require_login]
src/routes/dashboard/index.nexy
---from nexy import useSession user = useSession()["user"]---<h1>Welcome back, {{ user }}</h1>

Sessions are switched on in nexyconfig.py:

nexyconfig.py
from nexy.core.models import NexyConfigModel  class NexyConfig(NexyConfigModel):    useAliases = {"@": "src"}    useSession = {"secret_key": "change-me"}

Middleware

blog/middleware.py
class AppHeaderMiddleware:    def __init__(self, get_response):        self.get_response = get_response     def __call__(self, request):        response = self.get_response(request)        response["X-App"] = "demo"        return response
settings.py
MIDDLEWARE = [..., "blog.middleware.AppHeaderMiddleware"]

A middleware.py wraps the routes of its folder; the one at the root wraps them all:

src/routes/middleware.py
from nexy.http import Request  async def middleware(request: Request, call_next):    response = await call_next(request)    response.headers["x-app"] = "demo"    return response

Http404 and 404.html

blog/views.py
from django.shortcuts import get_object_or_404  def post_detail(request, slug):    post = get_object_or_404(Post, slug=slug)    return render(request, "post.html", {"post": post})

notFound() stops the page, and notfound.nexy at the routes root plays the role of 404.html:

src/routes/posts/[slug].nexy
---from nexy import notFound POSTS = {"hello": "Hello, world"} slug: prop[str]if slug not in POSTS:    notFound()---<h1>{{ POSTS[slug] }}</h1>
src/routes/notfound.nexy
<h1>Not found</h1><p><a href="/">Back home</a></p>

cache_page

blog/views.py
from django.views.decorators.cache import cache_page  @cache_page(60)def post_list(request): ...

One line in the frontmatter caches the page per URL and renders it again in the background:

src/routes/blog/archive.nexy
---revalidate = 60---<h1>Archive</h1>

settings.py

Django setting nexyconfig.py
SECRET_KEY + SessionMiddleware useSession = {"secret_key": ...}
ALLOWED_HOSTS useTrustedHost = {"allowed_hosts": [...]}
SECURE_SSL_REDIRECT useHTTPSRedirect = True
CORS_ALLOWED_ORIGINS (django-cors-headers) useCORS = {"allow_origins": [...]}
MIDDLEWARE useMiddlewares = [...], or middleware.py files
DATABASES DATABASE_URL in .env, read by src/configs/db.py
STATIC_URL, STATICFILES_DIRS public/, served at /

Apps

A Django project is split into apps. In Nexy, a folder of src/routes/ is the unit for URLs, and src/models/ and src/services/ hold the code they share. For controllers and modules with dependency injection, the modular router is the closest match.

The admin

Nexy has no admin site. Mount one of the FastAPI admin packages, such as SQLAdmin, or build the pages you need from .nexy files and actions.

Tests

blog/tests.py
from django.test import TestCase  class HelloTests(TestCase):    def test_hello(self):        self.assertEqual(self.client.get("/api/hello/").json(), {"message": "Hello"})
tests/test_hello.py
from fastapi.testclient import TestClient from nexy.app import app  def test_hello():    assert TestClient(app).get("/api/hello").json() == {"message": "Hello"}

Running

Django Nexy
django-admin startproject nx new
python manage.py runserver nx dev
gunicorn project.wsgi nx build, then nx start
python manage.py test nx test
python manage.py collectstatic nx build

Next steps

Edit on GitHub

Enter opens the result, Esc closes.