Adopting brickwork into an existing app (the strangle pattern)
INTEGRATION.md covers a greenfield install. This guide covers
the harder, more common case: strangling an existing UI kit (a hand-built shell
plus a c-/x- component library) onto brickwork cluster by cluster,
keeping the old kit alive until the last screen moves. It is a checklist distilled
from a real console cutover, not a theory; the structure below is reusable for the
next brownfield adopter.
Principle: strangle, do not big-bang
brickwork is stable (3.x) but still releases frequently, and a whole-app commit carries real churn risk: a fast-moving minor could touch a contract you depend on across every screen at once. Migrate incrementally so that at any moment most of the app still runs on the proven old kit and only the migrated cluster carries brickwork risk. Keep rollback trivial at every step.
The sequence
1. Pilot one screen cluster first
Pick one coherent cluster (a settings area, one resource's list+detail+form) and move only that. The pilot's job is to prove the four load-bearing seams end to end on real screens:
- the shell (does your layout survive inside
brickwork/shell/app.html?), - the nav (does your route tree map onto
NavItem, including any parameterised / multi-view sections?), - the forms (does your validation flow map onto the 422 loop, or the no-JS full-page redisplay floor?),
- the JS-bearing body (does your chart / editor mount cleanly inside
{% block content %}with your own asset pipeline coexisting?).
Wire each per INTEGRATION.md. If all four hold on the pilot cluster, the rest is repetition; if one fights you, you have found the real adoption cost before committing the whole app.
2. Migrate cluster by cluster, each its own PR
One cluster per PR keeps every step reviewable and independently revertable. Do not let a half-migrated cluster straddle a release. The old kit and the brickwork shell coexist for the duration: two base templates live side by side, screens extend whichever kit they currently belong to, and you move screens across one cluster at a time. This is expected and fine; plan for months of coexistence, not a weekend.
3. Draw the boundary explicitly
Decide, up front and in writing, what migrates and what does not:
- Migrates: all reusable interface design: chrome, forms, list and detail screens, dashboards, public-site patterns, documentation and editorial layouts, transactional journeys, and the frames and interaction conventions around data visualisation.
- Stays consumer-owned: product-specific semantics, business rules,
permissions, integrations, content operations and any specialist renderer a
project selects. A charting engine may stay app-owned, but it belongs inside
Brickwork's layout, token, state, legend, tooltip and filter contract. A
separately branded or hosted public site is migrated deliberately rather than
accidentally during a console cutover. Note (ADR-055, from v1.2.0):
brickwork now also ships an opt-in
brickwork.marketingsub-app (add"brickwork.marketing"toINSTALLED_APPS) with a marketing shell and nine marketing components (hero, feature grid, pricing tier, pricing table, CTA, testimonial, logo cloud, stat band, FAQ) on the same--bw-*token / brand / accessibility contract. Worked landing/pricing/about pages built on that shell and those components ship as copy-paste examples (brickwork.examples), not importable templates (ADR-056, from v2.0.0): a whole page is the most project-specific thing a consumer owns, so it is copied in and edited, never extended. If a consumer wants its marketing pages on brickwork too (the "one package covers all templating" case), that is now a supported opt-in, migrated on its own schedule, not forced into the console cutover. What brickwork still does not ship is a general-purpose public-website theme engine (no page builder, no tenant-arbitrary content or CSS/JS); the marketing kit is a fixed, curated set of components and shells, not a CMS. The marketing kit is the first slice of a wider templates-catalogue trajectory (layout variants, then themed starter kits, each demand-gated); seeoss/docs/plans/brickwork-templates-catalogue-direction.mdin the umbrella.
Writing the boundary down stops scope creep mid-migration, where "while we are in here" quietly pulls non-goals into the cutover.
If your editorial content comes from a block-based CMS rather than a single
rich-text field, wiring it onto .bw-prose has one extra step: see
INTEGRATION.md section 11
before you migrate the first content-heavy cluster, not after a page ships
with flat paragraphs.
4. Gate the final "delete the old kit" step on a stability signal
The last step, removing the old kit entirely, is the one with no rollback. Gate
it on a stability signal rather than doing it the moment the last screen moves:
a brickwork release you have pinned and validated across the migrated app. Pin to
a major you trust with an open range, >=3.2,<4, so patch and minor releases
land automatically while a major bump (which may carry a clean break, per
brickwork's own no-deprecation-window policy on majors) requires a deliberate
version bump on your side. Until then, keeping the old kit's code in the tree
(even with no screens using it) costs nothing and preserves the escape hatch.
Three wrinkles a real brownfield cutover hits (brickwork#49, brickwork#75)
Multi-host projects (the shell branches per host)
A project served on several hosts (django-hosts: a merchant host, an agency host,
an account host, each branching the shell) must decide per host whether that
host's screens are in the migration. The clean pattern: each host resolves its own
base template, and you migrate host by host as well as cluster by cluster. A host
still on the old shell and a host on the brickwork shell coexist with no shared
state beyond your own context; the per-request brand / theme resolver
(BRICKWORK_THEME_RESOLVER, see INTEGRATION.md section 3) is where a multi-host
app branches brand per host. Do not try to run one shared base template across
hosts mid-migration; let each host cross the line on its own schedule.
Legacy-shell / brickwork-shell asset coexistence
While both shells are live, both asset pipelines are live. The classic pairing is
django-vite hashed bundles (the legacy kit) alongside brickwork's plain
{% static %} artefacts. These coexist cleanly: brickwork never enters your
bundler, and your bundler never needs to touch brickwork's static. Points to hold:
- Load brickwork's stylesheet through the shell (it does this for you) and your
brand override through
head_extra; load your Vite bundles the way you already do. Two independent<link>/<script>sets on the page is correct, not a smell. - Do not route brickwork's
brickwork.cssthrough Vite to "unify" the pipeline; that couples you to brickwork's internal asset layout, which is not a contract. - If you run Alpine yourself (host-owned
Alpine.start()), brickwork registers its interaction components against your instance; keep a single Alpine start, not one per kit.
A second component framework in the content block (brickwork#75)
Some adopters do not strangle a hand-built kit; their in-app component layer is
itself a framework (django-components is the named case: @register(...)
components consumed via {% component "name" %}). The contract, stated
explicitly so the migration boundary can be drawn with confidence:
A second component framework rendering inside {% block content %} (and
inside form bodies and card bodies) is supported and safe, the same way
app-owned charts are. brickwork's components are plain template tags and
{% include %} partials; they claim no ownership of what renders inside the
body slots, and nothing in the shell inspects or interferes with markup another
framework produces there. This is a legitimate END STATE, not just a migration
phase: brickwork owns the shell, nav, forms scaffolding and generic components;
your component framework owns the domain components, mounted in brickwork
slots, indefinitely.
The boundary rule that follows: Brickwork owns reusable interface patterns;
your framework owns product-specific semantics and logic. A calendar grid
that builds availability matrices in get_context_data, a price display doing
currency formatting, or a rating widget can remain on a consumer framework
until a generic pattern is proven. Do not force an abstraction early; mount it
inside Brickwork's layout and interaction contract.
Do:
- Mount framework components inside
{% block content %}, card bodies, and form bodies exactly as you would app-owned markup. - Keep ONE Alpine: the host owns the single
Alpine.start(); brickwork registers against it, and a component framework's JS must not boot a second Alpine (or a second htmx) instance. - Emit the framework's dependency tags (django-components'
{% component_css_dependencies %}/{% component_js_dependencies %}, which inject<link>/<script>from a component registry) through the shell's blocks: stylesheets inhead_extraafter{{ block.super }}, so brickwork's stylesheet and your brand override keep their documented cascade order (a component stylesheet that overrides--bw-*tokens then wins only where it should); scripts inbody_js. Two independent dependency systems on one page is correct, not a smell (the same ruling as the asset-pipeline wrinkle above). - If you run a nonce-based CSP, confirm the framework's injected tags carry your nonce; brickwork's shell blocks are ordinary template blocks, so whatever nonce discipline your templates use passes through unchanged.
Do not:
- Do not rewrite domain components as brickwork tags (the boundary rule above).
- Do not route brickwork's static through the framework's dependency system (the mirror of the "do not route brickwork.css through Vite" rule).
- Do not mount a framework component that renders its own
<html>document or page shell inside a brickwork slot; body slots take fragments. - Do not reach from a framework component into
.bw-*class internals; style your components on your own classes plus the--bw-*tokens.
The brickwork suite keeps this promise executable: the consumer smoke harness
(tests/consumer/) renders a simulated second-framework component, with its
own injected stylesheet and script dependencies, inside the shell's content
block, without depending on django-components itself.
The htmx floor, if you are brownfield on htmx 1.9 (brickwork#48)
brickwork's interaction contracts are built and CI-gated on htmx >= 2.0
only (BR-BW-HTMX-010); htmx 1.9 is out of contract. If your app is on htmx 1.9,
treat the htmx 1 -> 2 upgrade as a prerequisite workstream that lands before
the brickwork cutover, not something to reconcile screen by screen mid-migration.
htmx 2 changed default response handling in ways the 422 swap loop relies on;
running the cutover on 1.9 means wiring htmx:beforeSwap by hand on a path
brickwork does not test. Sequence the htmx upgrade first, then strangle onto
brickwork on a supported floor.
What a worked instance looks like
The console cutover this guide is distilled from ran as an ADR plus a staged plan: one ADR recording the decision and boundary, then a plan enumerating the clusters in migration order with the "delete the old kit" step gated last. That shape (decision + boundary in an ADR, ordered clusters in a plan) is worth copying: it makes the migration auditable and the boundary durable against scope creep.