Next Campaign Page Kit is a tool for building campaign funnels that can be hosted with any of your favorite static site hosting providers such as Netlify or Cloudflare Pages.
Most static site generators are designed around a single site. When you need to manage multiple campaign funnels in one repository, you quickly run into problems: shared layouts bleed across campaigns, assets collide, and a change to one campaign can silently break another.
Next Campaign Page Kit solves this by treating each campaign as a fully isolated unit within a single repository. Every campaign lives in its own subdirectory with its own layouts, assets, and configuration — but they're all built, versioned, and deployed together.
The CLI tools (setup, dev, clone, config, compress) and template filters (campaign_asset, campaign_link, campaign_include) enforce this isolation at every step, so developers can work on one campaign without fear of affecting another.
mkdir my-campaigns && cd my-campaignsnpm init -y
npm install next-campaign-page-kitnpx campaign-initcampaign-init walks you through everything in one flow:
-
Adds CLI scripts (
dev,build,clone,config,compress,migrate, …) to yourpackage.json -
Creates an empty
_data/campaigns.jsonregistry -
Asks which template source to use (the public starter library by default — see Template sources), then fetches that source's template list and shows a picker
-
Asks for your Campaign name (display name) and Campaign slug (directory + URL path, e.g.
/my-campaign/) -
Downloads only the chosen template's
src/<slug>/files into your project -
Merges the template's registry data into your local
_data/campaigns.jsonunder your chosen slug -
Optionally prompts for your Campaign API key and writes it to
assets/config.js -
Optionally offers to set up AI context for your editor or agent — installs the upstream context doc to the right location for your tool:
- Claude Code →
CLAUDE.mdat the project root - OpenAI Codex →
AGENTS.mdat the project root - Cursor →
.cursor/rules/campaign-page-kit.mdc(withalwaysApply: truefrontmatter) - GitHub Copilot →
.github/copilot-instructions.md
If the chosen tool's file already exists, you're asked whether to update it. The written file always carries a sentinel header noting that it was generated by
campaign-initand will be overwritten on re-run unless you pass--keep-ai-context. - Claude Code →
Important
Get your Campaign API key from the Campaigns App in your store. See Campaigns App Guide. You can skip this step during init and run npm run config later.
campaign-init can run with no prompts at all. Pass every value as a flag and add --non-interactive:
npx campaign-init --non-interactive \
--template olympus \
--slug grounding-mat-v2 \
--name "Grounding Mat V2" \
--api-key "$CAMPAIGN_API_KEY" \
--ai-context claudeFor agent-friendly automation, add --json to receive a single structured object on stdout (suppresses all human UI and prompts):
npx campaign-init --json \
--template olympus --slug grounding-mat-v2 --name "Grounding Mat V2" \
--api-key "$CAMPAIGN_API_KEY" \
--ai-context claude| Flag | Purpose |
|---|---|
--source <name> |
Template source to pull from (defaults to public; see Template sources) |
--template <slug> |
Starter template slug (must exist in the source's templates.json) |
--slug <name> |
Local campaign slug (folder under src/, also URL path) |
--name <"display"> |
Display name (defaults to upstream template name) |
--api-key <key> |
Campaign Cart API key, written to assets/config.js |
--non-interactive |
Never prompt; missing required input → exit 5 |
--json |
Machine-readable stdout; suppresses all human UI |
--dry-run |
Resolve plan; no downloads, no writes |
--overwrite |
Replace existing src/<slug>/ and registry entry |
--ai-context <tool> |
Write upstream AI context doc for claude, codex, cursor, copilot, or none (default) |
--keep-ai-context |
Preserve an existing AI context file |
--help, -h |
Show full help |
Exit codes: 0 ok · 2 template not found · 3 target conflict (use --overwrite) · 4 upstream fetch failed · 5 missing required input · 6 invalid input · 7 partial write rolled back · 8 rollback failed.
--ai-context writes the upstream context doc verbatim (with a sentinel header) to wherever the chosen tool auto-loads it: CLAUDE.md, AGENTS.md, .cursor/rules/campaign-page-kit.mdc, or .github/copilot-instructions.md.
By default campaign-init pulls templates from the public campaign-cart-starter-templates repo. That source — named public — is built in; you don't configure it and existing projects need no changes.
To pull from your own templates, add sources to _data/template-sources.json. The file is additive: it lists only your sources, and public is always available alongside them (the name public is reserved). With no file, behaviour is exactly as before.
{
"sources": {
"acme": {
"type": "git",
"label": "Acme private templates",
"url": "git@github.com:AcmeCo/campaign-templates.git",
"ref": "main"
},
"local-dev": {
"type": "local",
"label": "Local working copy",
"path": "../campaign-cart-starter-templates"
}
}
}Source types:
type |
Fields | How templates are read |
|---|---|---|
local |
path (relative to the project, ~ expanded) |
Read straight off disk — ideal for developing templates |
git |
url (SSH), optional ref |
Shallow git clone over SSH using your ambient keys; omit ref to use the repo's default branch, or set a branch/tag to pin |
Important
A source — git repo or local directory — must have a templates.json catalog at its root and a src/<slug>/ tree for each template it lists. Same layout as the public repo.
The layout:
your-templates/
├── templates.json
└── src/
├── olympus/ # one folder per template slug
│ └── …
└── limos/
└── …
templates.json is the catalog the picker reads — an array of entries, one per template:
{
"templates": [
{
"slug": "olympus",
"name": "Olympus",
"description": "One-page checkout funnel",
"priority": 100
},
{
"slug": "limos",
"name": "Limos",
"description": "Three-step checkout",
"priority": 50,
"deprecated": true
}
]
}| Field | Required | Purpose |
|---|---|---|
slug |
✅ | Folder name under src/; the value passed to --template |
name |
✅ | Display label in the picker |
description |
— | Picker hint line |
priority |
— | Higher sorts first (default 0); ties break by name |
deprecated |
— | Prefixes the label with [DEPRECATED] |
hidden |
— | Omits the entry from the picker |
Pick / add / remove — interactively: in the campaign-init picker, the first step selects the source and includes ➕ Add a template source… and 🗑 Remove a template source…. Adding verifies the source actually exposes a usable templates.json (for git, this clones once); removal only ever touches your own entries (the built-in public is never listed). Agents can manage sources by editing _data/template-sources.json directly.
Non-interactive (agents, CI): there is no flag to add a source — --source <name> only selects one that already exists. To add a source headlessly, write the entry into _data/template-sources.json (the additive file shown above), then init with --source. With no --source, the default (public, or the file's default) is used.
npx campaign-init --non-interactive --json \
--source acme --template olympus --slug grounding-mat-v2 \
--name "Grounding Mat V2" --api-key "$CAMPAIGN_API_KEY"npm run devThis will:
- Show a list of available campaigns
- Let you select which campaign to preview
- Start the dev server
- Open your browser to the selected campaign
By default the dev server starts on port 3000 and prompts you to pick a campaign.
| Flag | Purpose |
|---|---|
--campaign <slug>, -c <slug> |
Skip the picker and start this campaign (must exist in _data/campaigns.json) |
--port <n>, -p <n> |
Port to listen on, 1–65535 (defaults to 3000) |
Both flags accept =-syntax (--campaign=my-camp, --port=8080). The first bare positional argument is also accepted as a shortcut: numeric → port, non-numeric → campaign slug. The PORT env var sets the port when no flag is given.
npm run dev # interactive picker, port 3000
npm run dev my-campaign # specific campaign, default port
npm run dev -c my-campaign -p 8080 # specific campaign and port| Command | Description |
|---|---|
npm start |
Interactive menu: dev server, compress, clone, configure |
npm run setup |
Bootstrap a project, install a starter template, and set the API key (alias for campaign-init) |
npm run dev |
Start dev server with interactive campaign picker |
npm run build |
Build all campaigns to _site/ |
npm run clone |
Clone an existing local campaign to a new slug |
npm run config |
Set the API key for an existing local campaign |
npm run compress |
Compress all images in a campaign directory |
npm run compress:preview |
Preview compression savings without modifying files |
npm run migrate |
Migrate campaigns.json from old array format to key-based format |
Build every campaign into the _site directory:
npm run buildThe command exits 1 when any page fails to render, and 0 otherwise. Build warnings (documented below) never change the exit code.
| Flag | Purpose |
|---|---|
--json |
Print the build summary as a JSON document on stdout instead of the human-readable log |
campaign-build --json reports, for every page in the build, which source file was rendered, which URL it resolved to, and which output file it was written to — in a form that CI jobs and scripts can consume directly.
In this mode, stdout carries exactly one JSON document and nothing else. Warnings, errors, and debug lines all go to stderr, so the summary can be piped or redirected without any filtering:
npm run build -- --json # output build summary as json (the -- forwards the flag)
npx campaign-build --json | jq '.pages' # access .pages directly
npx campaign-build --json > build-output.json # output to fileA build of two pages, where presell.html is missing its page_type frontmatter, produces:
{
"built": 2,
"errors": 0,
"warnings": 1,
"skipped": 0,
"ms": 312,
"pages": [
{
"inputFile": "src/my-campaign/checkout.html",
"campaignSlug": "my-campaign",
"url": "/my-campaign/checkout/",
"outputFile": "_site/my-campaign/checkout/index.html",
"status": "built",
"warnings": [],
"errors": []
},
{
"inputFile": "src/my-campaign/presell.html",
"campaignSlug": "my-campaign",
"url": "/my-campaign/presell/",
"outputFile": "_site/my-campaign/presell/index.html",
"status": "built",
"warnings": [
{ "code": "MISSING_FRONTMATTER", "message": "missing required frontmatter: page_type" }
],
"errors": []
}
]
}Top-level fields:
| Field | Description |
|---|---|
built, errors, skipped |
Page counts by outcome. Every page appears in pages with the matching status, so these always sum to pages.length |
warnings |
Total number of warning entries across all pages |
ms |
Build duration in milliseconds |
Per-page fields:
| Field | Description |
|---|---|
inputFile |
The source file the page was built from, relative to the project root |
campaignSlug |
The campaign the page belongs to — its first directory under src/ |
url |
The root-relative URL path the page is served at, e.g. /my-campaign/checkout/. This is the same value templates see as page.url. null when the build failed before the URL could be resolved |
outputFile |
The file the rendered page was written to, relative to the project root. null under the same condition as url |
status |
built — rendered and written. error — rendering failed; see errors. skipped — the slug has no entry in _data/campaigns.json, so the page was not built |
warnings |
Non-fatal findings for this page, each { code, message }. Codes are documented below |
errors |
Why the page failed, each { code, message }. Empty unless status is error |
The same object is returned programmatically by build() in lib/engine/build. The new fields are additive — callers that destructure { built, errors, ms } keep working unchanged.
A build can succeed and still be wrong: a misplaced file builds to a different URL than its folder structure suggests, or a typo'd layout name silently renders the page with no layout at all. The build flags these conditions as warnings. They are printed to stderr in every mode, attached to the affected page in the JSON summary, and never fail the build.
| Code | Meaning |
|---|---|
NESTED_NO_PERMALINK |
The page file sits in a subdirectory but declares no permalink. Routing uses only the campaign slug and the filename, so the intermediate directories are dropped: src/my-campaign/checkout/index.html builds to /my-campaign/ — not /my-campaign/checkout/. Declare a permalink to control the URL |
DUPLICATE_OUTPUT |
Two source files resolve to the same output file. The page built last silently overwrites the other |
LAYOUT_NOT_FOUND |
The layout named in page_layout does not exist in src/<slug>/_layouts/, so the page was rendered without any layout |
MISSING_FRONTMATTER |
The page is missing title or page_type in its frontmatter — both are required (see Page Frontmatter) |
INVALID_PAGE_TYPE |
page_type is not one of product, checkout, upsell, receipt |
NO_CAMPAIGN |
The page's slug has no entry in _data/campaigns.json, so it was not built (status is skipped) |
Clone an existing campaign to create a new one:
npm run cloneyour-project/
├── _data/
│ └── campaigns.json # Campaign registry (contains data for all campaigns)
├── src/
│ └── [campaign-slug]/ # Individual campaign directory
│ ├── _layouts/ # Campaign-specific layouts
│ │ └── base.html # Base layout template
│ ├── _includes/ # Reusable campaign components
│ ├── assets/ # Campaign assets (CSS, images, JS, config)
│ │ ├── css/ # Campaign styles
│ │ ├── images/ # Campaign images
│ │ ├── js/ # Campaign scripts
│ │ └── config.js # SDK configuration
│ ├── presell.html # Presell page (Base URL)
│ ├── checkout.html # Checkout page
│ ├── upsell.html # Upsell page
│ ├── receipt.html # Receipt page
│ └── *.html # Any other page
└── package.json
_data/campaigns.json- Register all campaigns and their configuration data here. Uses a key-based format where each key is the campaign slug (see below). If you have an older project using the array format, runnpm run migrateto convert it.src/[campaign]/_layouts/base.html- Campaign's base layoutsrc/[campaign]/assets/config.js- Campaign Cart SDK configuration
Each campaign page uses YAML frontmatter to configure the page for context.
| Field | Type | Required | Description |
|---|---|---|---|
page_layout |
string | No | Layout file in _layouts/. Defaults to base.html |
title |
string | Yes | Page title for <title> tag |
page_type |
string | Yes | Page type: product, checkout, upsell, receipt |
permalink |
string | No | Custom URL path (e.g., /starter/) |
next_url |
string | No | Next page in the funnel — the universal forward pointer. Set on every page (presell, landing, checkout, upsell). Layouts map it to the SDK's next-success-url meta tag on checkout pages and to next-upsell-accept-url on upsell pages. |
decline_url |
string | No | Override for upsell decline. Only set when the decline path differs from next_url. Defaults to next_url. Maps to the SDK's next-upsell-decline-url meta tag. |
styles |
array | No | Page-specific CSS files (relative paths or external URLs) |
scripts |
array | No | Page-specific JS files (relative paths or external URLs) |
footer |
boolean | No | Show footer on this page |
---
page_layout: base.html
title: Checkout
page_type: checkout
next_url: upsell.html
styles:
- https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css
- css/offer.css
scripts:
- https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js
- js/offer.js
footer: true
---Each page automatically has access to its campaign's data from _data/campaigns.json via the campaign object. This allows you to provide configured context directly to your pages.
You can access any key defined in your campaign's entry in _data/campaigns.json:
<h1>{{ campaign.name }}</h1>
<p>Contact: {{ campaign.support_email }}</p>To add more context across all pages in your campaign, simply add new keys to your campaign in _data/campaigns.json:
{
"starter": {
"name": "Starter Campaign",
"entry_url": "presell.html",
"support_email": "support@example.com",
"custom_headline": "Welcome to our Store!"
}
}Then the context is available to use it in your templates:
<h2>{{ campaign.custom_headline }}</h2>These keys are read by the CLI and have built-in behavior:
| Field | Type | Description |
|---|---|---|
entry_url |
string | Page that npm run dev opens in the browser. Defaults to the campaign root (/<slug>/). Accepts a page name like presell or landing.html — the path is normalized to /<slug>/<page>/. Useful when the campaign has no root page and the funnel starts at a specific entry like a presell or landing page. A warning is shown if the page does not exist under src/<slug>/. |
The environment variable is available in all templates and indicates the current build mode. This is useful for conditionally including analytics scripts, debug tools, or other environment-specific content.
| Command | Default Value |
|---|---|
npm run dev |
development |
npm run build |
production |
Usage:
{% unless environment == "development" %}
<!-- Google Tag Manager -->
<script>...</script>
{% endunless %}Override with CPK_ENV:
Set the CPK_ENV environment variable to override the default value. This is useful for build pipelines like Netlify or GitHub Pages where you may want a custom environment such as staging.
CPK_ENV=staging npm run buildLayouts are automatically resolved to the campaign's _layouts/ directory:
page_layout: base.html→starter/_layouts/base.htmlpage_layout: custom.html→starter/_layouts/custom.html
No layout specified? Defaults to base.html.
Templates use Liquid syntax. Next Campaign Page Kit provides additional custom filters and tags for campaign-relative includes, assets, and links.
Tip
Use campaign template filters to ensure your includes, assets, and links are automatically handled when cloning templates to a fresh new campaign.
Resolves asset paths to the current campaign.
Syntax:
{{ 'filename' | campaign_asset }}Examples:
<!-- Config -->
<script src="{{ 'config.js' | campaign_asset }}"></script>
<!-- Output: /starter/config.js -->
<!-- CSS -->
<link href="{{ 'css/custom.css' | campaign_asset }}" rel="stylesheet">
<!-- Output: /starter/css/custom.css -->
<!-- Images -->
<img src="{{ 'images/logo.png' | campaign_asset }}" alt="Logo">
<!-- Output: /starter/images/logo.png -->Use for: CSS files, JavaScript files, images, config.js, any campaign asset.
Generates clean URLs for inter-page navigation.
Syntax:
{{ 'filename.html' | campaign_link }}Examples:
<!-- Navigation link -->
<a href="{{ 'checkout.html' | campaign_link }}">Checkout</a>
<!-- Output: /starter/checkout/ -->
<!-- Campaign Cart meta tag -->
<meta name="next-success-url" content="{{ next_url | campaign_link }}">
<!-- Output: /starter/upsell/ -->
<!-- Data attribute -->
<button data-next-url="{{ 'upsell.html' | campaign_link }}">Continue</button>
<!-- Output: /starter/upsell/ -->Features:
- Removes
.htmlextension - Adds trailing slash
- Prepends campaign slug
- Handles anchor links (
#section) and absolute URLs
Use for: Page links, navigation URLs, redirect URLs, Campaign Cart SDK meta tags.
Includes a file relative to the current campaign's _includes directory. This is useful for including reusable components that are specific to a campaign.
Syntax:
{% campaign_include 'filename.html' arg=value %}Examples:
<!-- Include a slider component -->
{% campaign_include 'slider.html' images=slider_images %}
<!-- Include with parameters -->
{% campaign_include 'slider.html' images=slider_images show_package_image=true %}Use for: Reusable components within a campaign (e.g., sliders, testimonials).
To connect this campaign to your 29 Next Campaigns App:
- Run
npm run config - Select your campaign
- Enter your API key from the Campaigns App
- Deploy your campaign
For more details, see the Campaigns App documentation.
Most users should start with campaign-init and pick a starter template. If you'd rather start empty, run campaign-init and cancel the template picker (Ctrl+C). The bootstrap step still runs — you'll have CLI scripts and an empty _data/campaigns.json. Then add an entry keyed by slug:
{
"my-campaign": {
"name": "My Campaign",
"description": "My first campaign",
"sdk_version": "0.3.10"
}
}…and create the matching directory tree under src/:
src/
└── my-campaign/
├── _layouts/
│ └── base.html
├── assets/
│ └── config.js
└── presell.html
Then run npm run config to set the API key.
You can use our test cards to create test orders.
Compress all images in a campaign directory in-place. Supports JPEG, PNG, WebP, and GIF. Only overwrites a file if the compressed output is smaller than the original.
npm run compressThis will:
- Show a list of available campaigns
- Let you select which campaign to compress
- Compress all images found anywhere in the campaign directory (
src/[campaign]/) - Print a before/after table with file sizes and total savings
Preview mode — see what would be saved without modifying any files:
npm run compress:previewExample output:
◇ Found 3 images
◇ 2 images ready to compress
--------------------------------------------+----------+----------+----------+-------+---------
File | Before | After | Saved | % | Status
--------------------------------------------+----------+----------+----------+-------+---------
src/my-campaign/assets/images/hero.jpg | 145.3 KB | 88.2 KB | -57.1 KB | 39.3% | preview
src/my-campaign/assets/images/product.png | 80.0 KB | 54.0 KB | -26.0 KB | 32.5% | preview
--------------------------------------------+----------+----------+----------+-------+---------
TOTAL | 225.3 KB | 142.2 KB | -83.1 KB | 36.9% |
--------------------------------------------+----------+----------+----------+-------+---------
[NEXT] DEBUG 1 image already fully compressed, skipped
└ Preview complete — run without --preview to apply changes.
Already-optimized images are skipped and reported in the debug line above the summary.