Theme
GitHub PyPI Discord
Skip to content

The complete frontend stack for Python.

Citry is a free, open source HTML-first component framework for Python web applications. From server-rendered HTML to browser behavior and back to a Python handler, one component holds all of it. No second application, no separate build.

pip install citry
product_card.py
from citry import Component, SlotInput

class ProductCard(Component):
    class Kwargs:
        tags: list[str]
        likes: int = 0
        accent: str = "#175cd3"

    class Slots:
        body: SlotInput
        footer: SlotInput | None = None

    class State(Kwargs):
        pass

    class Events:
        def like(self, state):
            return ProductCard(
                tags=state.tags,
                likes=state.likes + 1,
            )

    def template_data(self, kwargs, slots):
        return {
            "likes": kwargs.likes,
            "tags": kwargs.tags,
        }

    def js_data(self, kwargs, slots):
        return {"likes": kwargs.likes}

    def css_data(self, kwargs, slots):
        return {"accent": kwargs.accent}

    template = """
      <article
        class="card"
        x-data="{ open: false }"
      >
        <c-slot name="body" />

        <c-for each="tag in tags">
          <c-Tag
            c-label="tag"
            $c-props="{ highlight: open }"
            @click="open = !open"
          />
        </c-for>
        <c-empty>
          <p>{{ tr("product-card-no-tags") }}</p>
        </c-empty>

        <button type="button" @c-click="like">
          Like <span x-text="likes">{{ likes }}</span>
        </button>

        <c-slot name="footer">
          No footer yet
        </c-slot>
      </article>
    """

    js = """
      $component(({ els, data }) => {
        const cardEl = els[0];
        animateLikes(cardEl, data.likes);
      });
    """

    css = """
      .card {
        border-left: 3px solid var(--accent);
      }

      .tag--active {
        color: var(--accent);
      }
    """

    messages = """
      product-card-no-tags = No tags yet.
    """

    class Dependencies:
        js = ["https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"]
        css = ["https://unpkg.com/normalize.css@8.0.1/normalize.css"]


html = str(ProductCard(
    tags=["new", "sale"],
    slots={"body": "Aurora Lamp"}
))

Development sponsored by

One file holds the entire component end-to-end.

Inputs, slots, markup, translated messages, server events and state, browser behavior, and styles, all live together. No context switching.

Point at any marked line below to see what it does:

product_card.py
from citry import Component, SlotInputclass ProductCard(Component):    class Kwargs:        tags: list[str]        likes: int = 0        accent: str = "#175cd3"    class Slots:        body: SlotInput        footer: SlotInput | None = None    class State(Kwargs):        pass    class Events:        def like(self, state):            return ProductCard(                tags=state.tags,                likes=state.likes + 1,            )    def template_data(self, kwargs, slots):        return {            "likes": kwargs.likes,            "tags": kwargs.tags,        }    def js_data(self, kwargs, slots):        return {"likes": kwargs.likes}    def css_data(self, kwargs, slots):        return {"accent": kwargs.accent}    template = """      <article        class="card"        x-data="{ open: false }"      >        <c-slot name="body" />        <c-for each="tag in tags">          <c-Tag            c-label="tag"            $c-props="{ highlight: open }"            @click="open = !open"          />        </c-for>        <c-empty>          <p>{{ tr("product-card-no-tags") }}</p>        </c-empty>        <button type="button" @c-click="like">          Like <span x-text="likes">{{ likes }}</span>        </button>        <c-slot name="footer">          No footer yet        </c-slot>      </article>    """    js = """      $component(({ els, data }) => {        const cardEl = els[0];        animateLikes(cardEl, data.likes);      });    """    css = """      .card {        border-left: 3px solid var(--accent);      }      .tag--active {        color: var(--accent);      }    """    messages = """      product-card-no-tags = No tags yet.    """    class Dependencies:        js = ["https://cdn.jsdelivr.net/npm/alpinejs@3.14.1/dist/cdn.min.js"]        css = ["https://unpkg.com/normalize.css@8.0.1/normalize.css"]html = str(ProductCard(    tags=["new", "sale"],    slots={"body": "Aurora Lamp"}))

Point at a marked line to see what it does.

InputsDeclared inputs

Every input this component accepts, with its type and any default. Passing an unknown name, or leaving out a required one, is reported when the component renders rather than quietly producing a gap in the page.

SlotsOpenings the caller fills

Named places a caller passes markup into. body is required and footer is optional, so the contract covers content as well as data.

StateState that survives a call

Server-side state available across Python event handler calls. Travels between the server and the browser. Inheriting Kwargs makes the State carry the same fields.

EventsPython that runs on interaction

A public method here can be called from the browser using @c-event="like". like reads the current state and renders the updated component, which the browser then displays.

DataUse Python variables in templates, browser behavior, and CSS

template_data prepares template variables, js_data seeds Alpine variables from JSON, and css_data creates CSS variables scoped to this one instance.

Browser stateState that never leaves the page

x-data holds what only the browser cares about. Opening and closing the card needs no server, so it never asks one.

SlotWhere filled content lands

<c-slot> marks the spot the caller's content drops into, inside markup this component still controls.

Control flowA loop, a child, and the empty case

<c-for> repeats a child component, while <c-empty> runs when there are no tags at all. The child component <c-Tag> receives label as Python value, and highlight as Alpine (browser) value through $c-props. You can listen to children's Alpine events with regular @click.

Translated textFirst-class support for i18n and l10n

tr() translates text to this render's locale. Translation keys are defined as Fluent syntax in this same component file.

BindingsAlpine and Python, side by side

@click stays in the browser for instant feedback, while @c-click calls the Python handler set in the value, like.

FallbackWhat shows when nobody fills it

Content between the tags is the fallback for an optional slot, so a caller who skips it still gets something sensible.

ScriptAdvanced setup scoped to this component

Templates use js_data values directly. Add $component when an imperative library or other setup needs this instance's elements and data.

StyleStyles reading Python values

var(--accent) reads the custom property css_data produced, and it is scoped to this instance, so two cards on one page can differ without a second stylesheet.

MessagesWrite translation keys as Fluent syntax

With Fluent by Mozilla, you can define translation keys that can easily handle genders, counts, composition, or even formatting. Export the messages to translate the catalog to other locales.

AssetsThird-party scripts and styles

Libraries this component needs. Citry loads each script only once per page, however many components may use it.

RenderRendering is a function call

Rendering returns ordinary HTML. This makes components easy to integrate with web frameworks, or test with plain Python.

Use with any web server
or standalone.

Citry's server-side events need a route on your application. Two lines of code and you're all set. If you don't need events, you can use Citry without a server.

See the web framework integrations and server events.

main.py
from contextlib import asynccontextmanager

from fastapi import FastAPI

from citry import citry
from citry.contrib.fastapi import mount

@asynccontextmanager
async def lifespan(_app: FastAPI):
    citry.initialize()
    yield

app = FastAPI(lifespan=lifespan)
mount(app, citry)
app.py
from flask import Flask

from citry import citry
from citry.contrib.flask import mount

app = Flask(__name__)
mount(app, citry, prefix="/citry")
citry.initialize()
urls.py
from django.urls import path

from citry import citry
from citry.contrib.django import urlpatterns as citry_urls

urlpatterns = [
    path("", home_view),
    *citry_urls(citry, prefix="/citry"),
]
asgi.py
from citry import citry
from citry.contrib.asgi import asgi_app

citry.initialize()
app = asgi_app(citry)
wsgi.py
from citry import citry
from citry.contrib.wsgi import wsgi_app

citry.initialize()
application = wsgi_app(citry)

Catch mistakes early.

Citry was born out of frustration with Django's silent coerctions and leaky isolations.

In Citry, what you see (in your component) is what you get:

  • Variables NEVER leak to other components.
  • Data passing is ALWAYS explicit contracts.
  • Missing values are ALWAYS error in Citry.

Read about inputs and validation, error boundaries, and testing components.

input.py
card = StatusCard(
    complete=18,
    total=25,
)

# Rendering is where the component's inputs are checked
str(card)

Rejected as the component is called

TypeError
An error occurred while rendering components StatusCard:
StatusCard.Kwargs.__init__() missing 1 required positional argument: 'title'
misspelled.py
card = StatusCard(
    titel="Deploy preview",
    complete=18,
    total=25,
)

str(card)

Rejected, and the name you meant is offered

TypeError
An error occurred while rendering components StatusCard:
StatusCard.Kwargs.__init__() got an unexpected keyword argument 'titel'. Did you mean 'title'?
template.py
class Greeting(Component):
    class Kwargs:
        name: str

    def template_data(self, kwargs, slots):
        return {"name": kwargs.name}

    template = "<p>Hello, {{ naem }}!</p>"

str(Greeting(name="Ada"))

Pointed at the line that asked for it

KeyError
An error occurred while rendering components Greeting:
Error in variable: KeyError: 'naem'

     1 | naem 
         ^^^^

In template of 'Greeting':

     1 | <p>Hello, {{ naem }}!</p>
                   ^^^^^^^^^^
isolation.py
class Child(Component):
    template = "<span>{{ user_name }}</span>"

class Parent(Component):
    def template_data(self, kwargs, slots):
        return {"user_name": "Ada"}

    template = "<div>{{ user_name }}<c-child /></div>"

str(Parent())

A child never inherits the parent's variables

KeyError
An error occurred while rendering components Parent > Child:
Error in variable: KeyError: 'user_name'

     1 | user_name 
         ^^^^^^^^^

In template of 'Child':

     1 | <span>{{ user_name }}</span>
               ^^^^^^^^^^^^^^^
unknown.py
class Page(Component):
    template = """
      <c-StatusCrad title="Deploy preview" />
    """

str(Page())

Named at the tag that asked for it

NotRegistered
An error occurred while rendering components Page:
No component registered as 'statuscrad'.

In template of 'Page':

     1 | 
     2 | <c-StatusCrad title="Deploy preview" />
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
     3 | 
mismatched.py
class Broken(Component):
    template = "<div><span>Deploy preview</div>"

str(Broken())

The parser names the tag it expected to close

SyntaxError
In template Broken:
Parse error:  --> 1:26
  |
1 | <div><span>Deploy preview</div>
  |                          ^----^
  |
  = Mismatched tags: expected closing tag '</span>', found '</div>'
unsafe.py
class Danger(Component):
    template = "<i>{{ __import__('os').system('ls') }}</i>"

str(Danger())

Template expressions cannot reach the interpreter

SecurityError
An error occurred while rendering components Danger:
Error in variable: SecurityError: variable '__import__' is unsafe

     1 | __import__('os').system('ls') 
         ^^^^^^^^^^

In template of 'Danger':

     1 | <i>{{ __import__('os').system('ls') }}</i>
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Your editor understands the whole component.

Citry's VSCode extension connects all parts of the component - Py, HTML, JS, CSS. Surface errors or trace values across the languages. Add completions, diagnostics, and hover hints.

Install the VS Code extension.

Hover or focus any dotted symbol below. Ctrl-click / ⌘-click on a symbol to go to its definition.

editor_invite_panel.pyCitry
from typing import TypedDict
from citry import CitryRender, Component


class InvitePanel(Component):
    template = """
      <section class="invite-panel">
        <h2>{{  }}</h2>

        <template x-for=" in ">
          <c-
            ="member."
            ="<>
              <small =''>
                Available now
              </small>
            </>"
            ="{ : member. }"
          />
        </template>

        <p>{{  }}</p>

        <form
          @submit.prevent="('', { email })"
          :aria-busy="('invite')"
        >
          <input x-model="" type="email" />
          <button :disabled=" || ">
            Invite member
          </button>
          <small x-show="$error('')">
            Could not send invite.
          </small>
        </form>
      </section>
    """

    js = """
      ({
        props: { compact: { type: Boolean } },
        init: ({ , , ,  }) => {
          scope.email = "";
          effect(() => {
            scope.visibleMembers = props.compact
              ? data..slice(0, 3)
              : data.;
          });
        },
      });
    """

    class Kwargs:
        title: str
        members: list[]

    class Events:
        def invite(self, data: ) -> None:
            send_invite(data["email"])

    def template_data(self, kwargs, slots):
        return {"title": kwargs.}

    def js_data(self, kwargs, slots):
        return {
            "members": kwargs.,
            "inviting": False,
        }


class MemberChip(Component):
    class Kwargs:
        name: str
        status: CitryRender

    template = """
      <span class="member-chip">
        {{ name }}
        {{ status }}
      </span>
    """

    js = """
      $component({
        props: { online: { type: Boolean } },
      });
    """


class Member(TypedDict):
    name: str
    online: bool


class InviteIn(TypedDict):
    email: str

Build fast with 60+ ready-made components.

Citry UI gives you accessible, themeable components for layout, forms, actions, navigation, feedback, and data display. Install here.

Citry UI components

Drag a component onto the canvas and watch it arrive ready to use.

Grow without a rewrite.

A product that works starts running into different problems. None of them need a different framework.

Read about caching, extensions, internationalization, CSRF protection, strict CSP, HTML fragments, component libraries, and perf optimizations.

Use cache in two ways: Either cache single Component class, or cache a region in the template.

Connect any backend. Use version to retire old entries on deploy.

product_card.py
class ProductCard(Component):
    class Kwargs:
        product_id: int

    class Slots:
        pass

    # Cache every time this component is called
    class Cache:
        enabled = True
        ttl = 300
        version = 1

    template = """
      <div>
        {# Cache only this region #}
        <c-cache key="expensive">
          <c-ExpensiveUI />
        </c-cache>
      </div>
    """

Extend all components at once with extensions that hook into Citry's machinery.

Hooks cover the render lifecycle, components' JS and CSS scripts, and more. Extensions can be configured globally or per-component. Extensions can also add their own URL endpoints and CLI commands.

timing.py
from citry import Citry, Extension

class TimingExtension(Extension):
    name = "timing"

    def on_component_rendered(self, ctx):
        record(type(ctx.component).__name__)
        return None  # keep the original render

app = Citry(extensions=[TimingExtension])

Citry has first-class support for internationalisation (i18n) and localisation (l10n). Use Fluent messages beside components to define translatable text and its inputs. Put translations in locale catalogs, or install catalogs from third-party packages.

Also handles locale-aware formatting and parsing of numbers, dates, currencies, direction, and more.

account_card.py
from citry import Component
from citry.ext.i18n import make_context

class AccountCard(Component):
    class Kwargs:
        name: str

    template = """
      <h2>{{ tr("account-greeting", name=name) }}</h2>
    """

    messages = """
      # @param {str} $name - User name.
      account-greeting = Welcome, { $name }.
    """

context = make_context(app, locale=request.locale)
AccountCard(name=user.name).render(
    provides={"citry_i18n": context},
)

The web framework of your choice generates and validates the CSRF token:

  • Django's middleware protects Citry routes unchanged
  • Other hosts can plug in their own token check and browser token source.
security.py
class Profile(Component):
    class Events:
        # Optional for hosts with a custom token scheme.
        # Django's CsrfViewMiddleware needs no Citry setup.
        _csrf = check_csrf

        def save(self, data: ProfileIn):
            update_profile(data)

Citry can automatically pass the CSP nonce to all scripts and styles.

Citry has 2 CSP modes:

  • Strict - Uses Alpine CSP build, raises error on incompatible syntax. Use for production.
  • Warning - Uses regular Alpine, prints all incompatibilities, but doesn't block you. Use for development.

Optionally add verified SHA-384 integrity to the scripts.

csp.py
app = Citry(
    security_csp="strict",
    security_script_integrity="citry",
)

# Generate a new value for every response.
nonce = new_response_nonce()
result = Page().render().serialize_result(csp_nonce=nonce)

response = HTMLResponse(result.html)
response.headers["Content-Security-Policy"] = (
    f"script-src 'self' 'nonce-{nonce}'; "
    f"style-src 'self' 'nonce-{nonce}'"
)

Easily integrate with HTMX. Instead of rendering a full page, render only small HTML to update one region.

Fragments carry their own JS and CSS. Citry loads whatever that region needs. Duplicate assets are never loaded twice.

views.py
card = Card(title="Welcome")

# The browser gets the markup and whatever JS and CSS it still needs
card.render().serialize(deps_strategy="fragment")

Share and publish components across projects as component libraries. Install a library with Citry.register_library()

Ideal for design systems or publishing to registries.

acme_ui/badge.py
from citry import (
    ComponentLibrary,
    LibraryComponent,
    SlotInput,
    citry,
)

# Define library components
class AcmeBadge(LibraryComponent):
    class Kwargs:
        tone: str = "neutral"

    class Slots:
        default: SlotInput | None = None

# Create Library
acme_library = ComponentLibrary(
    name="acme",
    components=[AcmeBadge],
)

# Register library with Citry
citry.register_library(acme_library)

Stop re-rendering what doesn't change. Mark constants and literals with Const. Citry will recognize it and optimize the template graph.

Marking a value in Const is a promise that the value will not change between renders.

dashboard.py
from citry import Const

# The parts that never vary are rendered once and reused
Card(cols=Const(3))

Built in public by people who care about Python and the web.

Citry is the successor to django-components (1.5k stars), distilling years of experience into an elegant and powerful framework.

This project would be nothing without its community. The people below have contributed to Citry or django-components:

GitHub avatar of dylanjcastillo
GitHub avatar of Antoliny0919
GitHub avatar of rbeard0330
GitHub avatar of a3lem
GitHub avatar of dalito
GitHub avatar of hanifbirgani
GitHub avatar of GabDug
GitHub avatar of oliverhaas
GitHub avatar of ryanhiebert
GitHub avatar of VojtechPetru
GitHub avatar of BradleyKirton
GitHub avatar of danjac
GitHub avatar of Real-Gecko
GitHub avatar of simkimsia
GitHub avatar of spapas
GitHub avatar of ekaj2
GitHub avatar of alexandreMartinEcl
GitHub avatar of ldurey
GitHub avatar of telenieko
GitHub avatar of spollard
GitHub avatar of David-Guillot
GitHub avatar of housUnus
GitHub avatar of rafae56038
GitHub avatar of mands
GitHub avatar of hjalves
GitHub avatar of timothyis
GitHub avatar of ar4s
GitHub avatar of imankulov
GitHub avatar of rwblokzijl
GitHub avatar of franciscobmacedo
GitHub avatar of tanssinet
GitHub avatar of TheSteveBurgess
GitHub avatar of KPCOFGS
GitHub avatar of mikucz
GitHub avatar of zachbellay
GitHub avatar of daniboygg
GitHub avatar of batistadasilva
GitHub avatar of Yaso2Go
GitHub avatar of larsent
GitHub avatar of vb8448
GitHub avatar of lhole
GitHub avatar of mlissner
GitHub avatar of bfrangi
GitHub avatar of ralphbibera
GitHub avatar of PavelPancocha
GitHub avatar of KyeRussell
GitHub avatar of joeyjurjens
GitHub avatar of barakharyati

Fund the work

Citry is built in the open with funding from its sponsors. They get the roadmap early, a direct line to the maintainer, and a say in what gets built next.

Discover frontend that brings joy.

pip install citry