Skip to content

Patterns

Common patterns and recipes for dj-layouts.

Dual-mode views (standalone + panel)

A view decorated with @layout is automatically a no-op when called as a panel (request.layout_role == "panel"). This means the same view can serve as both the main content view for its own URL and as a panel in another layout — without any extra code.

# myapp/views.py
from dj_layouts import layout, panel_only

@layout("myapp.DefaultLayout")
def article_detail(request, pk):
    """
    When called directly: renders inside DefaultLayout (full page).
    When called as a panel: returns just the article HTML partial.
    """
    article = get_object_or_404(Article, pk=pk)
    return render(request, "myapp/article_detail.html", {"article": article})

# myapp/layouts.py
class HomepageLayout(Layout):
    template = "myapp/homepage.html"
    # Reuse the article_detail view as a "featured article" panel:
    featured = Panel("myapp:article_detail", context={"pk": 1})

When article_detail is called as a panel, @layout detects layout_role == "panel" and skips layout wrapping, returning only the article_detail.html partial. HTMX requests can also target this URL directly to get the partial without the layout.


Pinning a panel to a specific object

Use Panel.context to fix a panel to a specific database object without creating a dedicated URL:

class HomepageLayout(Layout):
    template = "myapp/homepage.html"

    # Always show the staff picks article (pk=42), regardless of the URL
    staff_pick = Panel("myapp:article_detail", context={"pk": 42})
    # Latest announcement (pk determined at class-definition time — use callables for dynamic)
    announcement = Panel("myapp:announcement_detail", context={"pk": 1})

!!! warning "Panel.context is config-time data" The context= dict is evaluated when your layouts.py loads — not per-request. Don't use request.GET or other runtime data here. For dynamic panel selection, use a callable source.


Dynamic panel selection with a callable

When the panel source depends on the request, use a callable:

def pick_sidebar(request, **ctx):
    if request.user.is_authenticated:
        from myapp.views import user_sidebar
        return user_sidebar(request, **ctx)
    from myapp.views import public_sidebar
    return public_sidebar(request, **ctx)

class DefaultLayout(Layout):
    template = "myapp/layout.html"
    sidebar = Panel(pick_sidebar)

The callable receives the panel's cloned request and any context= kwargs. It can return an HttpResponse or a plain str.


Conditional panels (None)

Suppress a panel entirely by setting it to None. The template fallback content renders instead:

class DefaultLayout(Layout):
    template = "myapp/layout.html"
    ads = Panel(None)      # always suppressed
    sidebar = Panel("myapp:sidebar")

# Per-view: suppress sidebar for the landing page
@layout("myapp.DefaultLayout", panels={"sidebar": None})
def landing(request):
    ...

In the layout template:

{% panel "ads" %}{# fallback — no ads configured #}{% endpanel %}
{% panel "sidebar" %}<p>No sidebar.</p>{% endpanel %}

Static panels (literal HTML)

For completely static panel content, use a literal string source:

class DefaultLayout(Layout):
    template = "myapp/layout.html"
    # Literal HTML — no view is called
    cookie_banner = Panel('<div class="cookie-banner">We use cookies.</div>')
    separator = Panel("<hr>")

Use literal= when the string contains : (to avoid URL name auto-detection):

Panel(literal='<a href="https://example.com">Visit us</a>')

Combining multiple panels into one slot

Use a list source to compose several panels into a single slot:

class DefaultLayout(Layout):
    template = "myapp/layout.html"
    # Render widget_a, then a divider, then widget_b — all in one slot
    widgets = Panel(["myapp:widget_a", "<hr class='divider'>", "myapp:widget_b"])

    # With a join separator
    notifications = Panel(
        ["myapp:system_alerts", "myapp:user_notifications"],
        join="\n"
    )

HTMX-ready views

Since every view is already a partial (it returns only its own HTML), dj-layouts views are naturally HTMX-compatible. The @layout decorator wraps the full page only when called directly:

@layout("myapp.DefaultLayout")
def article_detail(request, pk):
    article = get_object_or_404(Article, pk=pk)
    return render(request, "myapp/article_detail.html", {"article": article})

HTMX can hx-get="/articles/5/" to get just the partial — the @layout no-op-when-panel-role behaviour extends to direct HTMX requests too (since layout_role is not set on direct requests, HTMX gets the full layout).

For truly partial-only HTMX responses, detect the HX-Request header yourself and return an appropriate response:

@layout("myapp.DefaultLayout")
def article_detail(request, pk):
    article = get_object_or_404(Article, pk=pk)
    if request.headers.get("HX-Request"):
        # Return just the article fragment for HTMX updates
        return render(request, "myapp/article_detail_fragment.html", {"article": article})
    return render(request, "myapp/article_detail.html", {"article": article})

!!! note "Partial detection is planned" Automatic partial detection (detecting HTMX requests and returning partials without the layout) is a planned feature. For now, detect HTMX headers manually.


Sharing data across panels via layout context

Layout context is the right place for data that multiple panels need:

class DefaultLayout(Layout):
    template = "myapp/layout.html"
    layout_context_defaults = {"site_name": "My App"}

    def get_layout_context(self, request):
        if request.user.is_authenticated:
            return {
                "cart_count": request.user.cart.item_count(),
                "unread_messages": request.user.messages.unread().count(),
            }
        return {}

Panel views read the context from request.layout_context:

@panel_only
def header(request):
    cart = request.layout_context.get("cart_count", 0)
    msgs = request.layout_context.get("unread_messages", 0)
    return render(request, "myapp/header.html", {"cart": cart, "msgs": msgs})

See Layout Context for the full merge order.


Per-view layout context (page title, active nav)

The content view can write to request.layout_context to pass page-specific data to the layout template:

@layout("myapp.DefaultLayout")
def products_list(request):
    request.layout_context["page_title"] = "Products"
    request.layout_context["active_nav"] = "products"
    products = Product.objects.all()
    return render(request, "myapp/products.html", {"products": products})

The layout template then uses these directly:

<title>{{ page_title }} — {{ site_name }}</title>
<nav>
  <a class="{% if active_nav == 'products' %}active{% endif %}" href="/products/">Products</a>
</nav>

Pagination within a panel

Panels receive the full cloned request, including query parameters. This means pagination just works:

# URL: /articles/?page=2
# Panel: Panel("myapp:article_list")

@panel_only
def article_list(request):
    page = request.GET.get("page", 1)
    paginator = Paginator(Article.objects.all(), 10)
    return render(request, "myapp/article_list.html", {
        "page_obj": paginator.get_page(page)
    })

Since panel requests are cloned from the original request, request.GET is available with all query parameters intact.


Panel views that return strings

Panel callables and views can return plain strings instead of HttpResponse:

def simple_greeting(request, **ctx):
    return f"<p>Hello, {request.user.first_name}!</p>"

class DefaultLayout(Layout):
    template = "myapp/layout.html"
    greeting = Panel(simple_greeting)

Separate layout for unauthenticated users

Use a different layout class for different states:

@layout("myapp.AuthLayout")
def dashboard(request):
    if not request.user.is_authenticated:
        return redirect("/login/")
    return render(request, "myapp/dashboard.html", {})

# Or dynamically:
def dashboard(request):
    layout_name = "myapp.AuthLayout" if request.user.is_authenticated else "myapp.PublicLayout"
    return render_with_layout(request, layout_name, "myapp/dashboard.html")