Get started

Tutorials

1 Apr 2026

Build Dynamic Campaign Landing Pages in Wagtail

A step-by-step guide to building a single Wagtail landing page that routes unlimited campaign URLs, captures attribution, and supports A/B testing with conversion tracking.

  • Chrissy Wainwright

    Chrissy Wainwright

    Six Feet Up

  • Doug Harris

    Doug Harris

    Truth Initiative

Multi-channel campaigns tend to create CMS gravity. Every new channel, audience, or creative spawns another landing page, even when the content is basically identical. Over time, “just one more URL” turns into a pile of duplicates that are hard to update, hard to QA, and easy to break.

In our Wagtail Space talk, One URL to Rule Them All: Dynamic Landing Pages in Wagtail, we showed how Truth Initiative, the nation’s largest nonprofit dedicated to ending nicotine addiction, worked with Six Feet Up, a custom software development consultancy, to simplify EX Program campaign pages. The EX Program helps people quit tobacco through web and SMS.

The solution combined two building blocks:

  1. RoutablePageMixin to route many campaign URLs (like /youtube/ or /facebook/) to one canonical Wagtail page while still capturing attribution
  2. Wagtail A/B Testing ****to test copy and CTAs without creating duplicate pages

What we are building We will build a single CampaignPage that:

  • Serves the same landing page content for multiple URLs based on referrer or other trackable metric. Example URLs we created for our project were:
    • https://join.exprogram.com/``join (the default, with no additional tracking)
    • https://join.exprogram.com``/``join/tiktok
    • https://join.exprogram.comjoin/reddit
  • Captures the last URL segment as a campaign_slug
  • Injects the campaign_slug into a hidden form field for downstream attribution
  • Enables A/B tests that tracks conversions (via form submissions)

Step 1: Understand why RoutablePageMixin is the key

RoutablePageMixin lets a single Wagtail page respond to multiple routes without defining additional view functions in urls.py. Routes are declared directly on the page model, which makes it a great fit for the “one page, many entry points” problem.

Here is a simplified example (from the Wagtail docs) showing how multiple routes can be directed to specific functions within a single model. Each route is defined using the @path decorator:

class EventIndexPage(RoutablePageMixin, Page):
    @path("")  # overrides the default Page serving mechanism
    def current_events(self, request):
        ...

    @path("past/")
    def past_events(self, request):
        ...

    # Multiple routes
    @path("year/<int:year>/")
    @path("year/current/")
    def events_for_year(self, request, year=None):
        ...

    @re_path(r"^year/(\d+)/count/$")
    def count_for_year(self, request, year=None):
        ...

Notice that paths can be dynamically created using variables or regular expressions with @re_path.

For campaign pages, we will use the same concept, but instead of separate view functions we will route multiple URL patterns into one handler.

When this pattern fits Use this approach when multiple campaigns share the same page structure and conversion goal, and you mainly need different entry points and tracking.

When it doesn’t If campaigns require substantially different user journeys, distinct funnels, or truly different content experiences, separate pages may still be the better tool.

Step 2: Install RoutablePageMixin

Add routable_page to INSTALLED_APPS:

# settings.py
INSTALLED_APPS = [
    ...
    "wagtail.contrib.routable_page",
]

Step 3: Create a CampaignPage model that uses RoutablePageMixin

Our CampaignPage will inherit from both RoutablePageMixin and Page.

Important: RoutablePageMixin must appear before Page in the inheritance list.

# models.py
from wagtail.contrib.routable_page.models import RoutablePageMixin, path
from wagtail.models import Page


class CampaignPage(RoutablePageMixin, Page):
    # Add your fields here (CTA text, hero image, etc.)
    # content_panels = Page.content_panels + [...]
    ...

Now add routes connected to a function. We want:

  • The base page to work (In this case, /join/, which is the ID of the page)
  • Any slug to work (e.g. /join/tiktok/, /join/reddit/)
  • The slug to be passed on to the context, so we can access it from the template
# models.py
from wagtail.contrib.routable_page.models import RoutablePageMixin, path
from wagtail.models import Page
    
    
    class CampaignPage(RoutablePageMixin, Page):
        @path("", name="default")  # base page
        @path("<str:campaign_slug>/", name="campaign_slug")
        def serve_campaign(self, request, campaign_slug=None):
            context = self.get_context(
                request,
                campaign_slug=campaign_slug,
            )
            return self.render(request, context_overrides=context)

How it works

  • Visiting /join/ calls serve_campaign() with campaign_slug=None
  • Visiting /join/tiktok/ calls it with campaign_slug="tiktok"

Step 4: Inject the slug into your form for tracking

For Truth Initiative’s use case, the primary conversion was a form submission. The simplest way to carry attribution through the system is to add the campaign slug to a hidden field.

In the template:

<input type="hidden" name="extreferformtype" value="{{ campaign_slug }}">

If a user visits /instagram/, this becomes:

<input type="hidden" name="extreferformtype" value="instagram">

That value can then be passed to backend systems, analytics pipelines, or third-party services.

Step 5: Add Wagtail A/B testing (optional, but helpful)

Once we have one canonical landing page, you can run experiments without duplicating content.

Install the A/B testing app

# settings.py
INSTALLED_APPS = [
    ...
    "wagtail_ab_testing",
]

Configure URLs

# urls.py
from django.urls import include, path
from wagtail_ab_testing import urls as ab_testing_urls

urlpatterns = [
    ...
    path("abtesting/", include(ab_testing_urls)),
]

Add the tracking script tag to the base template

# base.html
{# Insert this at the top of the template #}
{% load wagtail_ab_testing_tags %}
...
{# Insert this where you would normally insert a <script> tag #}
{% wagtail_ab_testing_script %}

Step 6: If you use Jinja templates, wire it up explicitly

The A/B testing tags are built for Django’s default templating language. Our project used Jinja, so we had to do some extra work to expose the tracking helper via jinja.py. Skip this step if you are not using Jinja.

# jinja.py
from wagtail_ab_testing.templatetags.wagtail_ab_testing_tags import wagtail_ab_testing_script

env.globals.update({
    ...
    "ab_testing_script": wagtail_ab_testing_script,
})

Then in the base.html, add this instead of what was done in Step 5:

{% if ab_testing_script(get_context())['track'] %}
  <script id="abtesting-tracking-params" type="application/json">
    {{ ab_testing_script(get_context())['tracking_parameters']|tojson|safe }}
  </script>
  <script src="{{ ex_static('wagtail_ab_testing/js/tracker.js') }}" defer async></script>
{% endif %}

This produces the same outcome as the Django template tag, but in a Jinja-compatible way.

Step 7: Track conversions with a custom A/B testing goal

In the project, we tracked form submissions as conversions. Wagtail A/B testing supports custom goal events by registering event types via hooks.

Create a custom event:

# wagtail_hooks.py
from wagtail import hooks
from wagtail_ab_testing.events import BaseEvent
from custom_product.models import CampaignPage


class SubmitMobileRegFormEvent(BaseEvent):
    # For A/B Testing: a custom goal for tracking conversions
    name = "Submit Mobile Reg Form"
    requires_page = False

    def get_page_types(self):
        # Limit the models this goal is available for
        return [CampaignPage,]


@hooks.register("register_ab_testing_event_types")
def register_submit_form_event_type():
    return {
        "submit-mobile-reg-form": SubmitMobileRegFormEvent,
    }

Then trigger the conversion in the form’s JavaScript when the user submits the form:

if (window.wagtailAbTesting) {
  $(".mobile-signup-form").on("submit", function (event) {
    wagtailAbTesting.triggerEvent("submit-mobile-reg-form");
  });
}

Now you can define an A/B test on your CampaignPage and set the goal to “Submit Mobile Reg Form.”

Why this matters This keeps experimentation tied to the canonical page instead of splitting results across duplicates. You test copy changes and CTAs while preserving consistent design and maintainable content.

What to watch for in production

A few implementation notes from the talk that are worth considering before you ship:

  • URL hygiene: decide whether you allow any slug (/anything/) or want to constrain to a set of known slugs. Open-ended slugs reduce operational overhead but may need guardrails for analytics consistency.
  • Caching and CDNs: if you are using a caching proxy or CDN, make sure your A/B test behavior is compatible with caching rules. You may need to bypass caching for specific paths.
  • Editor workflow: keep the content model intentionally constrained so the landing page stays consistent across channels and does not become a “design your own page” system.

Watch the full talk

The talk includes a demo for settings up an A/B test and shows how the pieces behave end to end.