Adding Custom Page Parts and Content Blocks in PropertyWebBuilder

PropertyWebBuilder stores reusable UI fragments as database-backed Page Parts that combine Liquid templates with JSON-encoded content blocks, allowing developers to create theme-specific components with locale-aware data fields.

PropertyWebBuilder is a Rails-based CMS for real estate websites that uses a modular architecture to manage content. Adding custom page parts and content blocks enables you to extend themes with reusable components that content editors can customize per locale. This guide walks through the complete implementation based on the source code in the etewiah/property_web_builder repository.

Core Architecture

The system revolves around three primary components that handle persistence, orchestration, and metadata registration.

The PagePart Model

The PagePart model in app/models/pwb/page_part.rb is the ActiveRecord entity that persists the raw template and structured content. It stores editor configuration and JSON-encoded block contents in the database, while implementing a cascading template lookup (lines 65-91). When rendering, the template_content method first checks for a theme-specific file in app/themes/<theme_name>/page_parts/, then falls back to app/views/pwb/page_parts/<key>.liquid.

The PagePartManager Service

Located in app/services/pwb/page_part_manager.rb, this service bridges containers (Websites or Pages) with the Content join model. It handles creation of the page_contents association, seeds initial block data via seed_container_block_content (lines 81-119), and rebuilds final HTML through rebuild_page_content (lines 87-100). The manager ensures tenant isolation by adding website_id to the join model in find_or_create_join_model (lines 43-50), while the PagePart model itself remains tenant-agnostic to support cross-tenant operations.

The PagePartLibrary Registry

app/lib/pwb/page_part_library.rb and app/lib/pwb/page_part_definition.rb define a central registry that describes every available part. This drives the admin UI, validation rules, and the seed-generation process by mapping keys to metadata including category, label, and editable fields.

Step-by-Step Implementation

Creating a custom page part requires progressing through definition, templating, seeding, and population phases.

1. Register the Definition

Add your component to the central registry to make it available in the admin UI:


# app/lib/pwb/page_part_library.rb

DEFINITIONS = {
  # existing entries …

  'my_theme/custom_hero' => {
    category: :heroes,
    label: 'Custom Hero',
    description: 'Theme‑specific hero variant',
    fields: %w[title subtitle background_image]
  }
}

This entry defines the admin organization (category), display label, and the editable fields that will appear in the content editor.

2. Create the Liquid Template

Place your markup in the theme directory:

{# app/themes/my_theme/page_parts/custom_hero.liquid #}

<div class="hero" style="background-image:url({{ page_part["background_image"]["content"] }})">
  <h1 class="hero-title">{{ page_part["title"]["content"] }}</h1>
  <p class="hero-subtitle">{{ page_part["subtitle"]["content"] }}</p>
</div>

The system automatically resolves this file because the filename matches the definition key. If the theme-specific file is missing, it falls back to the default view path.

3. Seed the Database Record

Create a YAML seed file to initialize the database structure:


# db/yml_seeds/page_parts/custom_hero.yml

- page_part_key: "my_theme/custom_hero"
  page_slug: "home"
  theme_name: "my_theme"
  editor_setup:
    editorBlocks:
      - - label: title
          isImage: false
      - - label: subtitle
          isImage: false
      - - label: background_image
          isImage: true
  block_contents: {}
  show_in_editor: true

Execute the seeding task to persist the record:

bundle exec rake pwb:seed_page_parts

Internally, this calls Pwb::PagePart.create_from_seed_yml (lines 55-61 of the model), which creates the database record only if it does not already exist.

4. Populate Content Blocks

Use the service object to fill locale-specific data:


# In a custom Rake task or provisioning script

website = Pwb::Website.find_by(domain: 'example.com')
manager = Pwb::PagePartManager.new('my_theme/custom_hero', website)

# Seed English content

manager.seed_container_block_content('en', {
  'title' => 'Welcome to Our Studio',
  'subtitle' => 'Designing your future',
  'background_image' => 'hero-bg.jpg' 
})

The seed_container_block_content method parses the editor configuration, resolves images (uploading local files or passing through remote URLs), and writes the resulting JSON into the block_contents column. It then triggers rebuild_page_content to generate the final HTML and store it in the associated PageContent join model.

5. Render in Frontend Templates

Insert the component using the Liquid tag:

{% page_part "my_theme/custom_hero" %}

The PagePartTag implementation in app/lib/pwb/liquid_tags/page_part_tag.rb retrieves the correct PagePart for the current website, merges the locale's block contents into the template context, and injects the rendered HTML into the page.

Admin and Frontend Integration

Custom page parts surface in two contexts:

  • Admin Interface: The site_admin/page_parts_controller.rb provides CRUD operations, while Lookbook preview components in spec/components/previews/page_parts/ render visual examples based on the library definitions.
  • Frontend Rendering: Any Liquid template—whether a page layout or another page part—can invoke your component using the page_part tag, which automatically handles locale resolution and HTML caching.

Summary

  • Register new components in PagePartLibrary::DEFINITIONS with category, label, and field specifications
  • Template files live in app/themes/<theme>/page_parts/ with fallback to app/views/pwb/page_parts/
  • Seed database records via YAML files in db/yml_seeds/page_parts/ using PagePart.create_from_seed_yml
  • Populate locale-specific data through PagePartManager#seed_container_block_content, which handles image uploads and JSON serialization
  • Render components using the {% page_part "key" %} Liquid tag, which merges block contents and caches the output

Frequently Asked Questions

How do I add a new editable field to an existing page part?

Update the fields array in PagePartLibrary::DEFINITIONS for your specific key, then modify the corresponding Liquid template to reference the new field via {{ page_part["new_field"]["content"] }}. You may need to re-run content seeding or manually update existing block_contents JSON to include the new key for each locale.

Can page parts be shared across multiple websites?

Yes. The PagePart model is tenant-agnostic and stores the base template and structure globally. However, the actual rendered content is tenant-scoped through the PageContent join model, which includes website_id. This allows the same part definition to hold different content values for different domains while sharing the underlying Liquid markup.

What is the difference between block_contents and the template file?

block_contents is a JSON column storing the actual data values (text, image URLs) for each locale, while the template file is the Liquid markup that determines how those values are rendered into HTML. The PagePartManager service merges these two during the rebuild_page_content phase to produce the final cached HTML stored in the join model.

How do I override a template for a specific theme without modifying the default?

Place your override file in app/themes/<theme_name>/page_parts/<key>.liquid. According to the template_content method in app/models/pwb/page_part.rb (lines 65-91), the system checks for theme-specific files first before falling back to the default location in app/views/pwb/page_parts/, allowing per-theme customization while maintaining a base implementation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →