JavaScript context#

next.dj ships a Next object to the browser that holds context values marked for serialisation. This page covers how to opt a context value in, how to choose a serializer, and how the conflict policy resolves duplicate keys.

The next object#

The static manager injects a runtime script that defines window.Next before the collected scripts run. Context values opted into serialisation land under window.Next.context.

Opting in#

Pass serialize=True on the @context decorator, or on @component.context in a component. See Context for both decorators. The value appears under window.Next.context.<key>. Keys without the flag stay server-side only.

A value the active serializer cannot encode raises TypeError during rendering, when the collector registers it. The error names the offending key. See Serialization for the browser for the accepted shapes and the common materialisation patterns.

Note

The framework escapes the init payload before it enters the inline <script>, so a serialised value that contains </script> cannot break out of the tag. Serialised values still appear in the page source, so never mark a secret serialize=True.

Serializers#

A serializer turns a Python value into JSON text. It implements the JsContextSerializer protocol, which has one method, dumps.

The framework ships two implementations.

JsonJsContextSerializer.

The process wide default. Encodes through Django DjangoJSONEncoder.

PydanticJsContextSerializer.

Encodes Pydantic models through model_dump. Falls back to DjangoJSONEncoder for plain values. Requires the pydantic package. The class raises ImportError at construction when pydantic is not installed.

Project-wide serializer#

Set NEXT_FRAMEWORK["JS_CONTEXT_SERIALIZER"] to the dotted path of a serializer class.

config/settings.py#
NEXT_FRAMEWORK = {
    "JS_CONTEXT_SERIALIZER": "next.static.PydanticJsContextSerializer",
}

resolve_serializer reads the setting on every call. When the key is absent or set to an empty string the framework uses JsonJsContextSerializer.

System check#

The next.W042 system check validates JS_CONTEXT_SERIALIZER at startup. It warns under any of five conditions.

  • The value is not a string.

  • The dotted path cannot be imported.

  • The resolved attribute is not a class.

  • The class cannot be instantiated.

  • The instance does not implement the JsContextSerializer protocol, a dumps(value) -> str method.

The check is skipped when the key is absent or set to an empty string.

Per-key serializer#

Pass serializer= on a single @context decorator to route one key through a custom serializer. The override applies only to that key.

notes/pages/page.py#
from next import context
from next.static import PydanticJsContextSerializer

@context("featured", serialize=True, serializer=PydanticJsContextSerializer())
def featured() -> object:
    return load_featured_note()

The collector routes the featured key through the supplied serializer and every other key through the project default.

The serializer= parameter takes an already-instantiated object. JS_CONTEXT_SERIALIZER in settings takes a dotted import path instead.

Note

PydanticJsContextSerializer() imports pydantic at instantiation time. Creating an instance at module level raises ImportError on startup when pydantic is not installed. Use the JS_CONTEXT_SERIALIZER setting for a process-wide override that keeps the import lazy.

Writing a serializer#

A serializer is any class with a dumps method.

notes/serializers.py#
import json
from django.core.serializers.json import DjangoJSONEncoder

class CompactSerializer:
    """Serialise context values with sorted keys for stable output."""

    def dumps(self, value: object) -> str:
        return json.dumps(
            value,
            cls=DjangoJSONEncoder,
            separators=(",", ":"),
            sort_keys=True,
        )

Point JS_CONTEXT_SERIALIZER at the dotted path of the class. The framework instantiates it through resolve_serializer.

Key conflict policy#

Two context functions can mark the same key for serialisation. The collector resolves the conflict through a JS context policy.

The framework ships four policies in next.static.collector.

FirstWinsPolicy.

Keeps the first value, ignores later ones.

LastWinsPolicy.

Keeps the last value.

RaiseOnConflictPolicy.

Raises on a duplicate key.

DeepMergePolicy.

Recursively merges nested dicts when both values are dicts. Overwrites the existing scalar with the latest value when either side is not a dict.

Configure the policy through the first static backend OPTIONS.

config/settings.py#
NEXT_FRAMEWORK = {
    "STATIC_BACKENDS": [
        {
            "BACKEND": "next.static.StaticFilesBackend",
            "OPTIONS": {
                "JS_CONTEXT_POLICY": "next.static.collector.DeepMergePolicy",
            },
        }
    ]
}

The configured policy fires anywhere the same key reaches the collector twice, including page-to-component, component-to-component, page-to-layout, and any contributor that calls StaticCollector.add_js_context directly. Two @context decorators on the same page that register the same key resolve last-wins, because the second registration replaces the first in the page registry before JS_CONTEXT_POLICY ever sees the key. Pick distinct keys when both registrations live in the same module. The framework owns the $-prefixed keys of the init payload, $csrf and $dev, and claims them once the policy has already run. A project key of either name is dropped from the collected context on every automatically injected payload, whichever way the project registered it, together with the pre-encoded fragment and the per-key serializer that key recorded. The framework then writes its own value where it has one, $csrf on a payload whose request can mint a CSRF token and $dev on a payload built while DEBUG is on. A render with no value to write leaves the key out of the payload altogether, so a production page carries no $dev key at all and the registered value reaches window.Next.context in no environment. The next.W075 system check reports such a key at manage.py check and names the page.py or component.py that declares it, so the declaring module keeps its value by renaming the key. The check walks the keyed registrations only. A keyless serialize=True provider spreads the keys of the dict it returns at render time, so a collision hidden inside such a dict is invisible to manage.py check and surfaces on the client as a value that never arrives.

A partial render honours the same ownership. Patches.context() refuses $csrf and $dev with ReservedContextKeyError, and the js-context delta of a zone render drops them before it becomes a context patch, so no patch updates either key.

Writing a policy#

A custom policy implements the JsContextPolicy protocol from next.static.collector. The protocol has one method, merge(existing, key, value), which returns the updated context dict.

notes/policies.py#
from typing import Any

class NamespacePolicy:
    """Group conflicting keys under a per-key list."""

    def merge(self, existing: dict[str, Any], key: str, value: Any) -> dict[str, Any]:
        current = existing.get(key)
        if current is None:
            existing[key] = value
        elif isinstance(current, list):
            current.append(value)
        else:
            existing[key] = [current, value]
        return existing

Point JS_CONTEXT_POLICY in the first static backend OPTIONS at the dotted path of the class.

Reading on the client#

Register the key server-side with serialize=True.

notes/pages/page.py#
from next import context
from notes.models import Note

@context("note_count", serialize=True)
def note_count() -> int:
    return Note.objects.count()

Co-located JS and inline scripts then read the value under window.Next.context.

notes/pages/_components/note_card/component.js#
document.addEventListener("DOMContentLoaded", () => {
  const count = window.Next.context.note_count ?? 0;
  console.log(`There are ${count} notes.`);
});

The runtime script defines window.Next before the collected scripts run. The runtime script is always the first tag in the scripts slot, ahead of every co-located, module-list, and {% use_script %} asset, so any of those may safely read window.Next.

Client event API#

The Next object exposes an event API alongside window.Next.context. Code that needs the context the moment it lands subscribes through Next.on rather than reading window.Next.context at an arbitrary time.

Next.on(event, listener) registers a listener and returns an unsubscribe function. The function removes that listener when called. The listener receives the event payload as its only argument.

Two context events reach the bus. The "context-updated" event fires whenever the framework loads a new context. Its payload is an object with two fields, where context is the whole merged store and changed lists only the keys of the delta that arrived. The initial seed lists every seeded key in changed. A partial zone render ships a js-context delta in its patch envelope, see How a partial request flows. The runtime merges the delta into window.Next.context and fires context-updated again with only the delta keys in changed. The "ready" event fires once the first context is loaded, and its listener receives the context object itself. A ready listener registered after that point receives an immediate replay with the current context. The partial runtime fires further partial:* events on the same bus, see Partial rendering reference.

notes/pages/_components/note_card/component.js#
const unsubscribe = window.Next.on("ready", (context) => {
  const count = context.note_count ?? 0;
  console.log(`There are ${count} notes.`);
});

window.Next.on("context-updated", ({ context, changed }) => {
  if (changed.includes("note_count")) {
    render(context.note_count);
  }
});

Next.use(plugin) calls plugin with the Next object and returns whatever the plugin returns. A plugin is any function that takes the Next object, so it can subscribe to events or read the context and expose its own helper.

notes/static/counter.js#
const counter = window.Next.use((next) => ({
  value: () => next.context.note_count ?? 0,
}));

Runtime script options#

The setting NEXT_FRAMEWORK["NEXT_JS_OPTIONS"] is a dict that configures the runtime script builder. The builder controls how and where the Next script is injected through a ScriptInjectionPolicy. An absent or empty NEXT_JS_OPTIONS uses the AUTO policy and the default tag templates.

Policy

Effect

When to use

AUTO (default)

Injects the preload hint, the <script> tag, and the Next._init call into every rendered page.

Pages that read window.Next.context or use co-located JS.

DISABLED

Skips injection entirely. window.Next is not defined.

Pages that serve raw data or HTML fragments and have no client-side JS that reads window.Next.

MANUAL

Skips automatic injection in the static manager, the same as DISABLED. The script builder stays available for custom emission.

Pages where you control placement of the script tags in a layout template.

Note

Under MANUAL the static manager skips both the preload hint and the Next._init wrap, exactly like DISABLED. To inject window.Next yourself, resolve the runtime URL with staticfiles_storage.url(NEXT_JS_STATIC_PATH) from next.static.scripts, then bind one builder = NextScriptBuilder.from_options(url, NEXT_JS_OPTIONS). Emit builder.preload_link(), builder.script_tag(), and builder.init_script(js_context) from a custom template tag or middleware. A payload built this way carries no framework $csrf or $dev entry, because the static manager both claims and writes those keys only under AUTO. The manual payload is exactly the mapping the caller passes, so the caller owns what any $-prefixed key of it means.

Set the policy through the NEXT_JS_OPTIONS dict.

config/settings.py#
NEXT_FRAMEWORK = {
    "NEXT_JS_OPTIONS": {"policy": "disabled"},
}

Accepted string values for policy are "auto", "disabled", and "manual".

Warning

When policy is "disabled", window.Next is not defined. Any co-located JavaScript or inline script that reads window.Next.context will fail at runtime. Review every component.js and inline script before switching away from AUTO.

Runtime script templates#

The NEXT_JS_OPTIONS dict also accepts preload_template, script_tag_template, and init_template keys. Each is an HTML string with a single placeholder. The preload_template and script_tag_template use the {url} placeholder. The init_template uses the {payload} placeholder, which receives the serialized JS context. Use them to add attributes such as nonce, async, or crossorigin without writing a custom backend.

config/settings.py, adding a crossorigin attribute#
NEXT_FRAMEWORK = {
    "NEXT_JS_OPTIONS": {
        "script_tag_template": '<script src="{url}" crossorigin="anonymous"></script>',
    }
}

A template carries only its own placeholder, {url} or {payload}, and no other substitution is supported. The templates are formatted with Python str.format, not Django templates. A literal { or } inside the template body collides with the formatter and must be doubled to {{ or }} to survive str.format. For per-request values such as CSP nonces, use a custom static backend instead.

See also#

See also

Context for the @context decorator. Static backends for the JS_CONTEXT_POLICY option. Override the JS context serializer for a recipe. Static reference for the serializer API.