NationBuilder Visual Edit
This guide explains how to add NationBuilder visual editing to an existing theme. Administrators can edit content and manage subpage modules directly on the page.
Last updated: 2026-11-06
#Overview
Visual editing adds controls to existing HTML using JavaScript. Changes are saved through a proxy service that calls the NationBuilder API.
The integration uses three files:
_z_script_visual_edit.jsowns content editing, image uploads, settings, publishing, save, and cancel._z_script_visual_builder.jsowns adding, reordering, and deleting subpage modules._z_visual_edit.scsssupplies shared control styling and the styles needed for editing on the page. Include it in the theme's Sass build when using either script.
The scripts work together or independently. For example, a blog post can use Visual Edit without Visual Builder.
An optional changelog records who changed a page or module, when, and what changed. Start with the editing controls below, then follow the changelog setup when you are ready to add history. Storage and API details are collected at the end of the guide.
To configure them, add data attributes to existing template HTML. Attributes on the outer wrapper identify the page being updated; attributes on individual fields identify the content to edit.
You will need developer access to the Nation to create API tokens, plus a configured API proxy deployed to Heroku.
Heroku Deployment
Before adding the scripts to the theme:
- Set up developer API access for the Nation.
- Deploy the API proxy app to Heroku.
- Enable the routes required by the scripts, using the same admin route token for all routes.
- Use the proxy URL and shared admin route token in the script configuration below.
The proxy deployment process is being updated. This guide assumes you already have a deployed proxy URL and admin route token; a deployment walkthrough will be added when that process is ready.
Existing Module HTML Pre-Check
Review the existing module templates before adding data attributes.
-
Keep empty fields available for editing. Remove conditions such as
{% if headline.size > 0 %}where they prevent an administrator from accessing an editable field. Administrators need to see blank fields to set an initial value. It is also preferrable to use explicit settings tags to change the layout (see point 2) rather than the following{% if request.is_admin? or page.headline.size > 0 %} -
Make layout choices explicit. Use a Settings tag to control layout or create a new module with the new layout. Avoid changing the layout solely because a field is empty, such as hiding the entire image section when no image is attached. If a section needs to be optional, expose that choice in Settings.
-
Use
<img>elements for editable images. The image picker does not support CSS background images. Update modules that usestyle="background-image:…"before adding image editing. Using<img>also allows the theme to usesrcset. -
Store editable images as page attachments. The image picker works with attachments, not page meta images. Use an optional filename prefix to distinguish images in different fields, such as
banner-left-andbanner-right-. The picker uses an existing image's attachment ID when replacing or removing it; the prefix does not replace that ID. -
Check module container clipping. The builder and edit scripts attach to the module wrapper. If the module wrapper is styled to "clip content" (overflow hidden) the plus button might look incorrect. The overflow clipping will need to be removed or moved into a child div.
-
Use one source for each editable value. For example, if a hero eyebrow can come from either an excerpt or a tag, choose one source so the editor has an unambiguous field to update.
-
Keep tag configuration simple. Settings supports a single tag selected from a dropdown, multiple tags selected with checkboxes, and a text value with a fixed tag prefix. More complex formats, such as
source:type:value, may need to be simplified in the module's Liquid before they can be exposed in Settings. -
Check draft visibility. If using the optional publish controls, keep module pages published in NationBuilder and use the
drafttag to mark unfinished modules. Update the parent Liquid to hide draft-tagged modules from visitors while still rendering them for administrators. See Publish State for the wrapper attributes and controls.
Module Page Tags Overview
Two page tags support optional module ordering and draft visibility:
order:Nis used when a builder supports manual ordering. ReplaceNwith the module's position, starting at1. Give every module in an ordered builder one order tag. Duplicate or gapped positive values can initialise safely; the next Add, Move, or Delete action repairs the sequence to match the rendered module order. An unordered builder does not require this tag. See Visual Builder Data Attributes.draftis used by the optional publish controls. A draft module remains a published page in NationBuilder, but the parent Liquid hides it from visitors. Administrators can still see and edit it. Publishing removes the tag; unpublishing adds it. See Publish State.
These tags work alongside the theme's existing module-type and configuration tags, such as module:hero or image-left.
Visual Edit
The visual edit script owns content editing, image uploads, settings, publishing, save, and cancel.
Setup
Add the script to layout.html, inside the administrator check:
{% if request.is_admin? %}
<script src="{{ theme['_z_script_visual_edit.js'] }}"
apiURL="https://nation-api-endpoint.herokuapp.com"
apiToken="admin-route-token"></script>
{% endif %}
Replace apiURL with the Heroku proxy URL and apiToken with the shared admin route token. Only include the script for administrators, and ensure _z_visual_edit.scss is included in the theme's compiled styles.
To record changes and let administrators view their history, add the optional attributes described in Changelog once the editing controls are configured.
Visual Edit Data Attributes
Configure an editable wrapper first, then declare the fields and settings inside it. The examples use page for a full page and child for a subpage module; use the Liquid object for the page that owns the content.
Core Attributes
Add these core attributes to the module's outer element or page content wrapper:
| Attribute | Purpose |
|---|---|
data-edit="true" |
Activates editing on this wrapper (a coloured border and edit button will appear on hover). |
data-page-id="{{ page.id }}" |
Required page ID. The page id is used for V2 API operations. |
data-resource-id="{{ page.basic.id }}" |
Required resource ID. This is the id used by the V1 API. Use page.blog_post.id for a blog post. |
data-page-type="basic" |
Selects the API update route. Set explicitly to basic or blog_post. |
data-admin-edit-url="{{ page.admin_edit_url | escape }}" |
Optional. Adds a control-panel link to Settings, opening in a new tab. |
Inside the wrapper, add data-edit-field="FIELD_NAME" to each editable field. Use the API field name, such as headline, rather than a display label.
data-edit-control chooses the editor:
| Value | Editor |
|---|---|
text |
Plain inline text. This is the default when the attribute is omitted. |
rich_text |
Rich-text editor with formatting controls. |
image_picker |
Image picker for an <img> element. |
Basic pages support headline, excerpt, content, page settings, and image attachments.
Example module HTML with added data attributes:
<div data-edit="true"
data-page-type="basic"
data-resource-id="{{ page.basic.id }}"
data-page-id="{{ page.id }}">
<h2 data-edit-field="headline">{{ page.headline }}</h2>
<div class="content"
data-edit-field="content"
data-edit-control="rich_text">
{{ page.basic.content }}
</div>
</div>
Images
Add data-edit-control="image_picker" to an editable attachment field. The picker lets an editor add an image, choose a crop, replace an existing image, or remove it. Applying a crop or choosing Remove stages the change; the page’s Save action persists it.
| Attribute | Purpose |
|---|---|
data-edit-field="attachment" |
Identifies the editable attachment field. Put it on the image field wrapper. |
data-edit-control="image_picker" |
Enables the image picker on that field. |
data-attachment-id |
Put the existing attachment's ID on its <img> so the picker can replace or remove that attachment. Omit it when there is no existing image to replace. |
data-attachment-prefix |
Optional filename prefix used if different attachments need to be targeted. Added to new uploads unless the filename already starts with it; existing attachments are not renamed. |
data-image-aspect-ratio |
Optional positive width-to-height crop ratio on the field wrapper, such as 16/9 or 1.5. Omit it for a free crop. |
For an existing attachment:
<div data-edit-field="attachment"
data-edit-control="image_picker"
data-attachment-prefix="hero-image-"
data-image-aspect-ratio="16/9">
<img src="{{ image_url }}"
alt=""
data-attachment-id="{{ attachment_id }}">
</div>
For an empty image field, keep the field wrapper but omit the <img> until an attachment exists. The picker accepts JPEG, PNG, and WebP files up to 10 MB (which is the Nationbuilder limit).
Blog posts: supply the parent blog ID using data-blog-id. For example:
<div data-edit="true"
data-page-type="blog_post"
data-resource-id="{{ page.blog_post.id }}"
data-page-id="{{ page.id }}"
data-blog-id="{{ page.parent.blog.id }}">
<h2 data-edit-field="headline">{{ page.headline }}</h2>
<div class="content"
data-edit-field="content_after_flip"
data-edit-control="rich_text">
{{ page.blog_post.content_full }}
</div>
</div>
Settings Dialog
Use Settings for Basic Page values that are not edited directly on the page, such as the slug, page name, title, or tags controlling a module's layout. The Settings button appears after the administrator clicks Edit.
Common Settings
Add these current page values to the editable wrapper to supply the common Basic Page settings when there are no explicit setting declarations. Explicit declarations below take precedence. The slug also helps the image picker with attachment operations; if it is absent, the script resolves the slug from an explicit slug setting or an API response.
| Attribute | Purpose |
|---|---|
data-page-slug="{{ page.slug | escape }}" |
Current page slug; also used for attachment operations when available. |
data-page-name="{{ page.name | escape }}" |
Current page name. |
data-page-title="{{ page.title | escape }}" |
Current page title. |
To set a field's label or control, use an explicit declaration as shown below.
Configured Settings
Declare each setting as an empty element inside the editable wrapper, normally a hidden <div>. Use these declarations for page fields or custom tag settings.
| Attribute | Purpose |
|---|---|
data-edit-field |
Required API field name, for example slug, name, title, excerpt or tags. |
data-edit-control |
Required control: settings.text, settings.select, or settings.multiselect. settings is an alias for settings.text. |
data-edit-value |
Current value, calculated in Liquid. Defaults to blank. |
data-edit-label |
User-facing label. Defaults to the field name. |
data-edit-refresh="true|false" |
Controls whether changing this setting requires saving the entire edit session and refreshing the page. Defaults to false for slug, name, and title, and true for other declared settings. |
When a setting marked for refresh changes, the dialog's Save and refresh action saves the entire edit session and reloads the page so Liquid can render the new configuration. Set data-edit-refresh explicitly to override the defaults. Settings that do not require a refresh are staged with OK, then persisted with the main Save action.
Text settings
Use settings.text for any text field supported by the Basic Page API save path, including headline, excerpt, content, slug, name, and title. Settings accepts any nonblank field name and supplies string values. The save path validates supported field names and enforces array payloads for tags and tags_delete. Adding support for a future text field to the save path does not require a separate Settings allowlist.
Only slug, name, and title have automatic parent-attribute defaults. Other fields require explicit declarations. Explicit declarations override those defaults, and duplicate text declarations for the same field are rejected. Give each API field one editing source per wrapper; avoid declaring it as both inline content and a Settings field.
Text settings can clear a value by saving an empty string, including clearing excerpt to restore a module's headline fallback. Blank changes to slug, name, and title remain ignored. Tags use the dedicated controls described below rather than a plain text API value.
For example:
<div hidden
data-edit-field="slug"
data-edit-control="settings.text"
data-edit-value="{{ page.slug | escape }}"
data-edit-label="Page Slug"></div>
For an excerpt used as image alt text, refresh after saving so Liquid recalculates the displayed value:
<div hidden
data-edit-field="excerpt"
data-edit-control="settings.text"
data-edit-value="{{ page.excerpt | escape }}"
data-edit-label="Image alt text (defaults to headline)"
data-edit-refresh="true"></div>
Single-choice tag settings
Use settings.select with data-edit-field="tags". List the tags this setting controls in data-edit-options, separated by commas. Optional data-edit-option-labels supplies display labels in the same order.
<div hidden
data-edit-field="tags"
data-edit-control="settings.select"
data-edit-value="image-left"
data-edit-label="Image Position"
data-edit-options="image-left,image-right"
data-edit-option-labels="Left,Right"
data-edit-empty-label="None"></div>
data-edit-empty-label adds a blank choice that removes the setting's tag. If the current value is blank and no empty label is configured, the first option is staged as the default and saved when the administrator confirms Save and refresh.
Multiple-choice tag settings
Use settings.multiselect to show checkboxes. Supply the currently selected tags as a comma-separated value:
<div hidden
data-edit-field="tags"
data-edit-control="settings.multiselect"
data-edit-value="border-top,border-left"
data-edit-label="Borders"
data-edit-options="border-top,border-right,border-bottom,border-left"
data-edit-option-labels="Top,Right,Bottom,Left"></div>
Text values stored in tags
Use settings.text with data-edit-field="tags" and data-edit-tag-prefix for a unique prefix:value tag:
<div hidden
data-edit-field="tags"
data-edit-control="settings.text"
data-edit-value="selected-tag:People"
data-edit-label="Selected Tag"
data-edit-tag-prefix="selected-tag:"></div>
data-edit-value contains the complete current tag. The dialog shows only the value after the prefix, such as People, and replaces the previous tag when the value changes.
The tag examples use fixed current values for clarity. In the theme, calculate these values from the page's tags in Liquid and escape them when inserting them into HTML attributes.
Tag settings submit only the additions and removals they own. Do not overlap options or tag prefixes between settings on the same wrapper.
Optional settings sections
After configuring the settings above, you can group them into expandable panels by adding these optional attributes to their declarations. Sections work with all Settings controls.
| Attribute | Purpose |
|---|---|
data-edit-settings-section="Section Name" |
Groups settings with the same name inside the same editable wrapper into one panel, using that name as its heading. Missing or blank names keep the settings in the unnamed group at the top, without a heading. Names are trimmed and case-sensitive. |
data-edit-settings-section-collapsed="true|false" |
Sets the section's initial state. Defaults to false (expanded). If any setting in a named section has true, the entire section starts collapsed, even if other settings omit the attribute or use false. Ignored for unnamed settings. |
Named sections follow the unnamed settings in order of their first declaration in the markup. Settings within a named section also follow declaration order; declarations for the same section do not need to be adjacent. The hardcoded Published toggle, when enabled, remains above the other settings.
For example, group the cards module's source controls into Data Source, initially collapsed, and its layout control into Card Style, initially expanded:
<div hidden
data-edit-field="tags"
data-edit-control="settings.select"
data-edit-label="Data Source Type"
data-edit-value="{{ cards_source_type_tag | escape }}"
data-edit-options="source-type:page,source-type:tag"
data-edit-option-labels="Page,Tag"
data-edit-empty-label="None"
data-edit-settings-section="Data Source"
data-edit-settings-section-collapsed="true"></div>
<div hidden
data-edit-field="tags"
data-edit-control="settings.text"
data-edit-label="Data Source (slug or tag)"
data-edit-tag-prefix="source-value:"
data-edit-value="{{ cards_source_value_tag | escape }}"
data-edit-settings-section="Data Source"></div>
<div hidden
data-edit-field="tags"
data-edit-control="settings.select"
data-edit-label="Layout"
data-edit-value="layout:{{ cards_layout | escape }}"
data-edit-options="layout:grid,layout:carousel"
data-edit-option-labels="Grid,Carousel"
data-edit-settings-section="Card Style"></div>
Panels expand and collapse independently, so several can remain open. Their headings support keyboard activation with Enter or Space, and Tab skips controls in collapsed panels. Collapsing a panel retains entered values and includes those values when confirming the dialog. Expanding or collapsing a panel alone does not create a content change. Each time Settings opens, panels return to their configured initial states; the usual OK, Save, Cancel, and refresh behavior still applies to their fields.
Publish State
Module drafts use a draft tag on a published Basic Page. NationBuilder does not render genuinely unpublished child pages, so the parent Liquid must hide draft-tagged modules from non-administrators while continuing to render them for administrators.
Add data-publish-status="draft|published" to the editable Basic Page wrapper, calculating its value from the current tags:
draft: the page has thedrafttag. A green Publish button appears before Edit on hover and removes only that tag when clicked.published: the page does not have thedrafttag.
Both states add a Published toggle to Settings. Use this toggle to publish or unpublish a module. It stages the change until OK, then persists it with the main Save action; the toggle itself does not require a refresh. The hover Publish button saves immediately. Both controls update data-publish-status only after the API succeeds.
Wrappers without this attribute, or with a value other than draft or published, get no publish controls. The wrapper must also have data-page-type="basic" and both IDs.
For example, after Liquid has inspected child.tags and set publish_status:
<div data-edit="true"
data-page-type="basic"
data-resource-id="{{ child.basic.id }}"
data-page-id="{{ child.id }}"
data-publish-status="{{ publish_status }}">
<!-- Module fields go here. -->
</div>
_z_visual_edit.scss supplies the Draft label and reserves space for it in the shared Visual Edit / Visual Builder controls row.
Changelog
The optional changelog keeps a history of changes made through Visual Edit and Visual Builder. Each entry shows who made the change, when it happened, and a short summary, such as “updated headline” or “added module”. Visual Edit also lets administrators inspect the changed content through History.
History records changes made through the configured scripts. It does not track edits made directly in the NationBuilder control panel or provide a way to restore an earlier version.
Viewing History
Once changelog is enabled:
- Select Edit on a page or module.
- Select History to open its recorded changes, with the newest entry first.
- Expand an entry to see its details, where available. Text changes highlight additions and removals; formatting changes may show the whole affected paragraph or block. Builder actions, such as moving a module, have a summary only.
Times are shown in your local timezone. Opening or closing History preserves any unsaved edits. Close the dialog with Escape or by clicking outside it to return to editing.
If nothing has been recorded yet, History shows an empty-history message. If it cannot load the history, it shows an error with a Retry action instead.
What Gets Recorded
| Action | History entry |
|---|---|
| Save changes to content, settings, or images | One entry describing the changed fields after the complete save succeeds. |
| Save and refresh | The saved changes are recorded before the page reloads. |
| Publish or unpublish a module | A publication summary, combined with other changes when saved together. |
| Add, move, or delete a module with Visual Builder | A short action summary when changelog is enabled on the builder script. |
Cancel, unchanged edit sessions, and failed operations do not create success entries. Moving a module records the module you moved; it does not add entries for neighbouring modules whose order tags are adjusted.
Deleting a module keeps its history in the hidden changelog blog in NationBuilder. The deleted module is no longer available to open History on the page.
If a change saves successfully but its history cannot be recorded, a warning appears. The saved content or module action still takes effect, but the history may be incomplete. See Recording Reliability for the technical limitations.
Enabling the Changelog
- Create a hidden blog in NationBuilder to hold the history. Note its exact slug, for example
changelog. - Ensure the proxy allows the changelog routes for that blog using your admin route token.
- Add
changeLogBlogSlug,authorId, andauthorNameto each script that should record changes. Use the current administrator's Liquid values as shown below.
For Visual Edit, update the existing script include to:
{% if request.is_admin? %}
<script src="{{ theme['_z_script_visual_edit.js'] }}"
apiURL="https://nation-api-endpoint.herokuapp.com"
apiToken="admin-route-token"
changeLogBlogSlug="changelog"
authorId="{{ request.current_user.id | escape }}"
authorName="{{ request.current_user.first_name | escape }} {{ request.current_user.last_name | escape }}"></script>
{% endif %}
| Script attribute | What to supply |
|---|---|
changeLogBlogSlug |
The hidden blog's exact slug. Omit it or leave it blank to disable changelog support for that script. |
authorId |
The current administrator's NationBuilder person ID. If omitted, entries have no author ID. |
authorName |
The current administrator's display name. If omitted or blank, entries show Unknown editor. |
Keep the escape filters in the example. The scripts create the individual history posts automatically when the first change is recorded; you do not need to create those posts yourself.
For Visual Builder, add the same three attributes to its script include, keeping its existing configuration. Use the same blog slug on both scripts to collect their changes in the same page or module history. Configure each script separately: enabling the editor's changelog does not enable the builder's. The builder can record changes on its own; the on-page History dialog requires Visual Edit.
Visual Builder
Visual Builder lets administrators add and delete Basic Page subpage modules, with optional reordering. It can manage the main subpage module area or run inside a module, such as a concertina with its own child items.
- Empty builders show a centred Add control on hover or keyboard focus.
- Populated ordered builders show Add controls at both the top and bottom of every module on hover or keyboard focus, alongside Up, Down, and Delete controls. The top Add control inserts above that module; the bottom control inserts below it.
- Populated unordered builders show a generic Add control with Delete. Up and Down are hidden because the rendered position is owned by the template or NationBuilder.
- Add opens a dialog with the configured module options, preview images, and category filters. Ordered creation inserts at the requested position and repairs existing order tags. Unordered creation adds no
order:Ntag and performs no reordering. Both modes attach the new subpage to its parent and reload the page. - Up and Down repair the ordered sequence, update the required order tags, and move the existing elements without reloading.
- Delete asks for confirmation and deletes the page. Ordered builders also repair the remaining order tags; unordered builders leave them untouched.
Builder controls are hidden while a module is being edited with Visual Edit. Related V1 updates run in order and use best-effort rollback after partial failures.
Ordering modes: ordering is enabled by default. When every rendered module has a positive integer data-module-order, the builder is ordered; duplicate or gapped values are accepted and repaired by the next explicit Add, Move, or Delete action. When no rendered module has the attribute, the builder is valid but unordered. Mixing modules with and without the attribute, or using a blank, zero, negative, or non-numeric value, logs an error and leaves the builder controls uninitialised.
An empty builder has no modules to inspect, so it defaults to ordered and creates its first module with order:1. Set data-module-ordering="false" on the parent container when an empty builder must instead start unordered. This also overrides any existing module order attributes. Existing order tags are left untouched, while new modules are created without one.
Setup
Load the script once per page, for administrators only. For example, place it at the bottom of the builder template, such as z_content.html, after defining modules_config_json as shown below. If several modules contain builders, load the script from a shared template rather than once per module.
The script's modulesConfig attribute is optional when every builder on the page supplies its own non-empty data-module-config. Omit the attribute entirely in that case. A builder with neither source cannot initialise.
{% if request.is_admin? %}
<script src="{{ theme['_z_script_visual_builder.js'] }}"
apiURL="https://nation-api-endpoint.herokuapp.com"
apiToken="admin-route-token"
modulesConfig='{{ modules_config_json | escape }}'></script>
{% endif %}
Use the same proxy URL and admin route token as Visual Edit. Visual Builder also requires _z_visual_edit.scss in the theme's compiled styles.
To record Add, Move, and Delete actions, follow Enabling the Changelog and add the three changelog attributes to this script include.
Module Configuration
Visual Builder needs two kinds of configuration:
- Definitions of the new modules administrators can create.
- Data attributes identifying the existing builder container and modules.
Define the available module types as a JSON array. Each definition appears in the Add dialog:
The owning builder chooses whether new modules are Basic Pages or blog posts; definitions do not set page type.
| Property | Purpose |
|---|---|
name |
Required display name. Also used when naming a new subpage. |
description |
Optional description in the Add dialog. |
tags |
Optional array of tags to apply when creating the module. Use the tags expected by the theme's Liquid. |
image |
Optional preview image URL. |
categories |
Optional array of categories for filtering the Add dialog. |
For example, define this Liquid capture before the script include:
{% capture modules_config_json %}
[
{
"name": "Basic Text",
"description": "Basic text with a heading and content.",
"tags": ["draft", "page_no_robots"],
"image": "https://placehold.co/400",
"categories": ["Basic", "Text"]
},
{
"name": "Hero",
"description": "A hero module with an editable image.",
"tags": ["module:hero", "draft", "page_no_robots"],
"image": "https://placehold.co/400",
"categories": ["Hero", "Header"]
},
{
"name": "Banner",
"description": "A banner module with an editable image.",
"tags": ["layout:banner", "draft", "page_no_robots"],
"image": "https://placehold.co/400",
"categories": ["Banner", "Image"]
},
{
"name": "Concertina",
"description": "An expandable list of child items.",
"tags": ["module_concertina", "draft", "page_no_robots"],
"image": "https://placehold.co/400",
"categories": ["Concertina"]
}
]
{% endcapture %}
These module tags are examples; match them to the existing theme. Include draft when new modules should use the draft visibility system described above. Visual Builder adds and manages order:N automatically for ordered builders. Unordered builders remove any configured order:* value when creating a module.
Visual Builder creates modules using a name without a separate headline, because NationBuilder V1 normalises a Basic Page's headline to its name during creation.
Visual Builder Data Attributes
Parent container
Keep a builder container in the HTML even when it has no modules, so the script can show the first Add control.
| Attribute | Purpose |
|---|---|
data-builder="true" |
Activates Visual Builder on this container. |
data-page-type="blog" |
Optional. Omit for a Basic Page builder; set to blog for blog-post modules. |
data-page-id="{{ page.id }}" |
V2 ID of the parent page. New modules become subpages of this page. |
data-resource-id="{{ page.blog.id }}" |
Required on a blog builder: V1 ID of the parent blog. |
data-page-slug="{{ page.slug }}" |
Optional on a blog builder. Pass it when the blog-post proxy permits published creation by blog slug. |
data-page-name="{{ page.name | escape }}" |
Parent page name, used when naming new subpages. |
data-module-config |
JSON array replacing modulesConfig for this builder. Required when the script has no global definitions. See Using the builder script in a module at the end of this section. |
data-module-ordering="false" |
Optional. Forces this builder to disable ordering, even if existing modules have order attributes. If omitted, ordering defaults to enabled and the rendered module attributes determine the populated builder's mode. |
<div data-builder="true"
data-page-id="{{ page.id }}"
data-page-name="{{ page.name | escape }}">
<!-- Render this builder's subpage modules here. -->
</div>
Individual modules
Add these attributes to each module wrapper inside its builder:
| Attribute | Purpose |
|---|---|
data-module="true" |
Identifies a module that can be moved or deleted. |
data-resource-id="{{ child.basic.id }}" |
V1 resource ID used to update the module. Use child.blog_post.id for a blog post. |
data-page-id="{{ child.id }}" |
V2 page ID of the module. |
data-page-type="blog_post" |
Required for blog-post modules so the builder can check that they belong to a blog builder. Basic modules may omit it. VisualEdit requires an explicit page type for editing either kind. |
data-module-order="N" |
Optional. Positive numeric value from the module's order:N tag when this builder supports ordering. |
For an ordered builder, every existing module must expose the positive numeric value from its order:N tag through data-module-order. For an automatically detected unordered builder, omit data-module-order from every module. Do not mix modules with and without the attribute inside one builder unless the parent explicitly sets data-module-ordering="false".
For a chronological or paginated blog list, set data-module-ordering="false" on its builder, including when the blog is empty.
To make the same module editable, also add data-edit="true", data-page-type="basic", and the field attributes described earlier. For example, this is the first module after Liquid has resolved its order tag to 1:
<div data-module="true"
data-module-order="1"
data-edit="true"
data-page-type="basic"
data-resource-id="{{ child.basic.id }}"
data-page-id="{{ child.id }}">
<h2 data-edit-field="headline">{{ child.headline }}</h2>
<div class="content"
data-edit-field="content"
data-edit-control="rich_text">
{{ child.basic.content }}
</div>
</div>
In an ordered module loop, calculate data-module-order from each child's tag rather than hardcoding it. Add data-publish-status as described above if the module also needs publish controls.
Rendering modules in order
This example scans the parent's children for each order:N tag, then renders the matching module. It assumes the children are Basic Page modules with consecutive order tags starting at order:1. It also applies the optional draft visibility rules.
Use module_page consistently inside this loop: its IDs and fields belong to the module, while page identifies the builder's parent. Replace the simple heading and content markup with the theme's existing module rendering as needed, retaining the wrapper attributes.
<div data-builder="true"
data-page-id="{{ page.id }}"
data-page-name="{{ page.name | escape }}">
{% if page.children_count > 0 %}
{% for order in (1..page.children_count) %}
{% capture order_tag %}order:{{ order }}{% endcapture %}
{% for module_page in page.children %}
{% assign has_order_tag = false %}
{% assign publish_status = 'published' %}
{% for tag in module_page.tags %}
{% if tag.name == order_tag %}
{% assign has_order_tag = true %}
{% endif %}
{% if tag.name == 'draft' %}
{% assign publish_status = 'draft' %}
{% endif %}
{% endfor %}
{% unless has_order_tag %}
{% continue %}
{% endunless %}
{% if request.is_admin? or publish_status == 'published' %}
<div data-module="true"
data-module-order="{{ order }}"
data-edit="true"
data-page-type="basic"
data-resource-id="{{ module_page.basic.id }}"
data-page-id="{{ module_page.id }}"
data-publish-status="{{ publish_status }}">
<h2 data-edit-field="headline">{{ module_page.headline }}</h2>
<div data-edit-field="content"
data-edit-control="rich_text">
{{ module_page.basic.content }}
</div>
</div>
{% endif %}
{% endfor %}
{% endfor %}
{% endif %}
</div>
The container remains in the HTML when there are no children, so administrators can add the first module. A child without a matching order tag will not render in this example; set up the existing modules' order tags before enabling the builder.
Rendering modules without ordering
An unordered builder's Liquid must render its children without filtering for order:N. Otherwise, a newly created untagged module will be saved successfully but will not appear after the page reloads. Set data-module-ordering="false" when an empty builder must create its first module without an order tag:
<div data-builder="true"
data-module-ordering="false"
data-page-id="{{ page.id }}"
data-page-name="{{ page.name | escape }}">
{% for module_page in page.children %}
<div data-module="true"
data-edit="true"
data-page-type="basic"
data-resource-id="{{ module_page.basic.id }}"
data-page-id="{{ module_page.id }}">
<h2 data-edit-field="headline">{{ module_page.headline }}</h2>
<div data-edit-field="content"
data-edit-control="rich_text">
{{ module_page.basic.content }}
</div>
</div>
{% endfor %}
</div>
In this mode, the template or NationBuilder determines the rendered child order. Visual Builder does not add, remove, or repair existing order:N tags.
Using the builder script in a module
For nested content, add a builder container inside the module. For example, a concertina can manage its own child items while the outer builder manages full page modules.
Set data-module-config on the nested container to replace the script's modulesConfig for that builder, or to supply definitions when the script has none. In this example, module_page is the concertina's page, and the Add dialog offers a Concertina Item:
<div data-builder="true"
data-page-id="{{ module_page.id }}"
data-page-name="{{ module_page.name | escape }}"
data-module-config='[{"name":"Concertina Item","tags":["page_no_robots"]}]'>
<!-- Render the concertina's own child items here. -->
</div>
Use the Liquid variable that represents the module in your template so new items become children of the correct page. Each child item needs its own module IDs. Include its order tag and data-module-order when the nested builder is ordered; otherwise use the unordered pattern above. Adapt the relevant rendering example to loop over the module's children, using a separate variable such as item_page for each item.
Match the definition's tags to the concertina's Liquid. Add draft if its child items also use the optional publish system, and apply the same visitor visibility rules. For JSON generated in Liquid, escape the value as in the script configuration example. The script still loads only once per page.
Changelog Blog Structure and Technical Reference
This section explains how the scripts store and retrieve history. Use it when configuring the proxy or troubleshooting changelog behaviour.
Changelog Proxy Requirements
Each script uses its configured apiURL and apiToken. The proxy must allow access to the hidden changelog blog through these routes:
| Route | Used by | Purpose |
|---|---|---|
get-blog-posts |
Visual Edit and Visual Builder | Find the changelog blog and its existing history posts. |
create-blog-post |
Visual Edit and Visual Builder | Create a hidden history post for a page or module's first recorded change. |
update-blog-post |
Visual Edit and Visual Builder | Append new entries to an existing history post. |
get-blog-post-details |
Visual Edit | Read a history post when opening History. |
Creation uses the configured blog slug and status: "hidden"; leave the legacy fixed-blog-ID option unset for this flow. The update field allowlist, if configured, must allow content_after_flip. When supplied, authorId is also used as the author of a newly created history post. Each script trims its own configuration values.
Finding and Loading History
Editor and builder initialization proceed normally. After the page finishes loading, each enabled script uses an idle callback (or deferred timer when unavailable) to start a low-priority GET /get-blog-posts request through the proxy. The proxy must accept that script's configured apiToken for this route and allow access to the hidden changelog blog. Its response must include the resolved top-level blog_id, even when data.results is empty.
The script follows every data.next page through the proxy using nextPageNonce and nextPageToken. It stores the changelog blog ID and a mapping of source identities to changelog post IDs and slugs in memory. Source identities use:
{changeLogBlogSlug}_basic_{page_id}:page_idis the source wrapper'sdata-page-id.{changeLogBlogSlug}_blogpost_{blog_id}_{resource_id}: these are the source wrapper'sdata-blog-idanddata-resource-id.
Other posts in the blog are ignored. The complete mapping is committed only after all pages load successfully. A failed lookup remains an error rather than an empty history, and a later on-demand call can retry. Concurrent callers share the pending load.
When changelog is enabled in VisualEdit, History appears after Edit is selected, including on modules with no Settings controls. The modal waits for the editor's index lookup and then fetches the mapped post using GET /get-blog-post-details. If the mapping is missing, it refreshes the index once and checks again, allowing it to discover history created by the builder since page load. It shows an empty-history message if no post is found after that refresh. Failed lookups show an error and a Retry action; closing remains available while a request is loading.
History displays the HTML returned by NationBuilder directly. Each .log entry with direct-child .log__summary and .log__details blocks is presented as a collapsed, keyboard-accessible disclosure. The viewer creates native details/summary elements at display time; stored entries remain divs and spans. Entries without a details block remain visible without a toggle.
The dialog reverses the top-level entries so the latest appended entry appears first. Summary timestamps are displayed in the viewer's local timezone, including its abbreviation (for example, 29 Oct 2026, 10:50 am NZDT). Hovering over a formatted timestamp shows its original UTC ISO value. Invalid dates are left unchanged. These transformations affect only the dialog; stored timestamps and append order are unchanged.
Entry metadata is stored in a hidden .log__metadata block, with .log__version, .log__event-id, and .log__author-id spans. Each .log__field can contain a hidden .log__key, a visible .log__label, and .log__diff content. .log__added and .log__removed mark additions and removals. These classes are styled only within the History content by _z_visual_edit.scss. Stored metadata uses hidden spans because NationBuilder removes data-* attributes from saved HTML.
Content is fetched again each time the modal opens. Opening, retrying, or closing History preserves unsaved edits and does not create or update any posts. History links open in a new tab. The dialog supports Escape, backdrop dismissal, keyboard focus containment (including visible summaries and links), and returning focus to the History button.
Changelog Save Recording
Selecting Edit captures field and setting values in memory. The separate changelog baseline does not alter Cancel behavior. Rich-text comparison starts with the initialized Tiptap document so initialization alone does not create a change. Tag-setting values are compared as sets. Image entries describe stable URLs/attachment IDs; temporary blob URLs and file data are not stored.
After an entire Save succeeds, the script compares the current values with the starting values and records only changed fields in a single .log entry. Entries include the configured author ID/name, a unique event ID, and a browser-generated UTC timestamp. The post contains readable HTML diffs only: no JSON, hidden before/after snapshots, or complete page snapshots. Text changes use word-level highlighting. Rich-text comparison omits unchanged top-level blocks; matching changed blocks keep their HTML formatting and highlight only added/removed words, leaving surrounding text unhighlighted. Added/deleted blocks, changes to HTML structure or attributes, and comparisons that exceed their time limit use whole-block highlighting. For example, making words bold adds a strong element and can display the complete old and new paragraph. The pinned jsdiff module ([email protected] from esm.sh) is loaded on demand; if unavailable, the changed field uses whole-value highlighting. This affects newly recorded entries; existing stored diffs are retained.
On the first recorded change, the script rechecks the index and lazily creates the associated hidden post with the first entry included. Subsequent saves send only the new entry using append_content: true. The mapping is updated immediately after creation. Settings, successful image changes, Save and refresh, and immediate publication changes are covered. Cancel, failed saves, and unchanged sessions do not append an entry. A later successful retry after a partial save compares against the original edit-session baseline.
New entry summaries describe publication as “published module” or “unpublished module”. Up to five other changed fields are listed by name, using configured field/setting labels where available; larger sets use “updated N fields”. Unnamed tag settings are combined into a count of added/removed tags, such as “updated headline, 5 tags”. Mixed saves can read “published module and updated headline”. Existing stored summaries are retained.
Changelog Builder Recording
VisualBuilder records summary-only entries for successful module creation, movement and deletion. The summaries read “added module”, “moved module up”, “moved module down” or “deleted module”, alongside the configured author and UTC timestamp. Entries retain the same .log metadata and summary structure as editor entries but omit .log__details, so History displays them without an expandable section. No diff library is loaded by the builder.
Creation is recorded after the new module, any sibling order updates, and Basic Page parent linking have all succeeded. Both direct creation and the module picker use the same recording hook, and the attempt finishes before the page reloads. Moves record only the clicked module; sibling order repairs do not generate entries. Deletion records the deleted module's identity while its DOM wrapper is still available and leaves its separate changelog post in place. If no history post exists, deletion can create it with the deletion entry. Failed or rolled-back operations, cancelled deletion and boundary move clicks do not record success entries.
Basic Page history uses the module's V2 page_id; Blog Post history uses its owning blog's V1 ID and the module's V1 resource ID. Newly created modules use the IDs returned by creation. The builder uses the same deterministic post slugs and hidden-post create/append routes as the editor, with no fixed changelog blog ID.
The builder does not call VisualEdit or share its state. VisualEdit's History refresh on a missing mapping is an independent API read. After deletion, retained history remains accessible in the NationBuilder changelog blog; this does not add a deleted-module viewer.
Recording Reliability
A changelog failure never reverses or fails a successful content save or module action. Each logging attempt is limited to 15 seconds; failures appear in the console and a warning. Save and refresh and builder creation still reload after a logging failure. Only their warning messages are carried across the reload in separate session-storage keys; field contents are not stored there. Builder warnings are attached to the document body so they remain visible after a module is deleted.
Writes are queued within each script instance. Across scripts, tabs, or users, the proxy still appends by reading, merging, and writing the post content, without server-side locking. Simultaneous writes can therefore leave history incomplete. Event IDs identify entries, but there is no automatic retry or event-ID deduplication after an uncertain write response.