Blog /
Build a pricing page with WP-backed plan data
Pricing pages are the quintessential “developer wrote it once, marketing wants to change it daily” surface. The CTA copy needs tweaking. The plan names need adjusting. The footnotes need updates whenever legal weighs in. If the page is in code, every change is a git commit and a deploy. If the page is in WordPress, it ends up in some shortcode-driven theme template that nobody can read or, worse, in a page builder that turns the page source into 30,000 characters of div soup.
The inline-mode plus dynamic-route combination splits the difference. The page is a real WordPress URL; Google indexes it, the theme can wrap it, your CTA bug fixes are commits. But the plan rows themselves come from a pricing-plan custom post type the editor can edit in wp-admin without touching code.
This post starts from examples/pricing-page, which is the static version, and walks through the WP-backed extension on top.
The starter manifest
{
"manifest_version": 1,
"id": "pricing",
"name": "Pricing",
"description": "Inline-mode example: an interactive plan comparison rendered as a real WP page (theme-wrapped, indexable).",
"version": "0.1.2",
"entry": "index.html",
"isolation": "inline",
"display": { "modes": ["page"], "default": "page" },
"routes": [
{
"path": "/",
"file": "index.html",
"title": "Pricing",
"description": "Per-site licensing for DesignSetGo Apps."
}
],
"theme": { "wrap": "none" },
"permissions": { "read": [], "write": [] },
"runtime": {
"sandbox": "strict",
"csp": {
"script_src": ["self"],
"style_src": ["self"],
"img_src": ["self", "data:"],
"connect_src": ["self"]
}
}
}
The pieces that matter:
isolation: "inline". The bundle is server-rendered into the page response. View source at the URL: real HTML. Crawlers index it. Social previews unfurl. The OpenGraph tags work.routes. One route, served at/. The route’stitleanddescriptionbecome the rendered page’s<title>and meta description. Crawlable and shareable.theme.wrap: "none". The pricing page is its own visual world; the theme’s header and footer would clutter it. For most inline pages, you’d leavewrapdefaulted (auto, which inherits the theme); pricing-page-specific design tends to want full control.permissions.read: []. The example bundle as published is fully static, with no bridge calls. The next section adds dynamic plan loading via the bridge, which requirespermissions.read: ["posts"].- The custom CSP. Inline mode supports per-app CSP overrides. The example is locked to
self-only sources because it has no third-party scripts; if you brought in an external font from Google Fonts, you’d add the origin tostyle_src. Don’t loosen the CSP unless you need to; the strictest workable policy is the right starting point.
The static version
Out of the box, the example is a hand-coded HTML pricing page with the plan rows hardcoded. That’s fine for the smallest case. View source confirms the page is real HTML; Google indexes it; the theme wrap is whatever you set it to.
<section class="plans">
<article class="plan">
<h2>Personal</h2>
<div class="price">$99<span>/site/year</span></div>
<ul class="features">
<li>Up to 5 apps per site</li>
<li>Iframe and inline modes</li>
<li>HTML upload importer</li>
</ul>
<a class="cta" href="/checkout/personal/">Choose Personal</a>
</article>
<!-- Plus and Agency similarly -->
</section>
When the marketing team wants to tweak a number, they hand you a comment, you change the file, you redeploy. Fine for a small startup. Painful at any scale. Painful, in particular, the third time it happens in one week.
The dynamic version
Replace the hardcoded plan rows with a custom post type. In your theme or a small companion plugin, register pricing-plan with custom meta fields for price, features, and CTA URL:
add_action('init', function () {
register_post_type('pricing-plan', [
'public' => false,
'show_ui' => true,
'show_in_rest' => true,
'rest_base' => 'pricing-plan',
'menu_icon' => 'dashicons-money',
'supports' => ['title', 'editor', 'page-attributes'],
'labels' => ['name' => 'Pricing plans', 'singular_name' => 'Pricing plan'],
]);
register_post_meta('pricing-plan', 'price', [
'type' => 'number',
'single' => true,
'show_in_rest' => true,
]);
register_post_meta('pricing-plan', 'features', [
'type' => 'array',
'single' => true,
'show_in_rest' => [
'schema' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
],
]);
register_post_meta('pricing-plan', 'cta_url', [
'type' => 'string',
'single' => true,
'show_in_rest' => true,
]);
});
show_in_rest: true is the magic line; without it, the bridge can’t see the post type or its meta fields. show_ui: true means the editor sees a “Pricing plans” menu in wp-admin and can edit them through the normal editor UI.
A subtle bit worth knowing: the DSGo bridge’s dsgo.posts.list returns the bridge-flattened post shape — id, slug, title (plain string), excerpt, content, date, link, etc. — but it does not return CPT meta in v1. To pull the price, features, and cta_url meta fields into the app, you have a few options:
- Add a small companion plugin that registers a custom REST route (e.g.,
GET /wp-json/your-agency/v1/pricing-plans) returning the plans with their meta included, and call that withruntime.external_originsset to your own site origin and a fetch in the bundle. Most “I need this CPT meta” cases end up here. - Ship the plans as a JSON file inside the bundle (
data/plans.json) and update the bundle when prices change. Simpler; doesn’t need extra PHP. Fine if marketing redeploys the bundle when they edit prices. - Wait for v1.x to expose meta in the bridge — it’s a likely additive change, but not in v1 today.
The trade isn’t dramatic. For a pricing page that changes a few times a year, the JSON-file path is cleanest. For an editor who needs to change plans without redeploying, the companion-plugin path is the path to invest in.
Then update the manifest to add the bridge permission (only needed if you fetch via the bridge; not needed for the JSON-file path which is just static asset reading):
"permissions": { "read": ["posts"], "write": [] }
For the JSON-file path, your index.html reads the bundled data:
<section id="plans" class="plans"></section>
<script type="module">
const res = await fetch(import.meta.env.BASE_URL + 'data/plans.json');
const plans = await res.json();
const section = document.getElementById('plans');
for (const plan of plans) {
const article = document.createElement('article');
article.className = 'plan';
article.innerHTML = `
<h2>${plan.title}</h2>
<div class="price">${plan.price}<span>/site/year</span></div>
<ul class="features">${plan.features.map((f) => `<li>${f}</li>`).join('')}</ul>
<a class="cta" href="${plan.cta_url}">Choose ${plan.title}</a>
`;
section.appendChild(article);
}
</script>
(For data you control end-to-end, innerHTML with template literals is fine. If you ever pulled plan names from user-submitted content, you’d want textContent to avoid HTML interpretation. The trust model is your call.)
Server-side rendering: where the substitution engine stops
The substitution engine that powers :slug-style dynamic routes is built for one-entry-per-URL pages (a post detail, a product page), not for list-rendering. There’s no {{#each}} loop syntax in v1 — the engine only does single-token replacement ({{title.rendered}}, {{{content.rendered}}}) into a template keyed by the matched route param. See Dynamic routes deep dive for the full mechanics.
For a pricing-page index, that means the bridge-call approach above is the v1 path: render the static page-level signals (<title>, <meta>, headline) directly in the HTML, then fill in the plans client-side via dsgo.posts.list. Most crawlers (Google’s main bot in particular) execute the JavaScript and index the result, so SEO still works.
If you needed every plan row in the pre-JS HTML for a non-JS-executing crawler, the v1 answer is to either generate the rows at build time (a simple Astro getStaticPaths pulling from the WP REST API into a <ul> literal) or to write a custom dataset resolver that returns a single composite entry containing the rendered HTML of all plans, then substitute that via {{{plans_html}}} in your template. Both work; the first is simpler for a small site, the second scales without redeploying.
Why this is the right shape
A small comparison table for a pricing page:
| Approach | Editor can change copy | Indexable | Theme wrap | Deploys per change |
|---|---|---|---|---|
| Hardcoded React on Vercel | No | Yes (with hydration) | No | One per change |
| WP page with shortcodes | Yes-ish | Yes | Yes | Zero |
| WP page with a page builder | Yes | Yes | Yes | Zero, but the markup becomes unreadable |
| DSGo App, inline, WP-backed | Yes (via CPT) | Yes | Optional | Zero for copy, one for layout |
The structural advantage: the editor edits a CPT in wp-admin (a familiar WordPress surface), the developer edits the HTML/JS template (a familiar developer surface), and neither one steps on the other’s work. Marketing changes a price; the page updates. The developer changes the layout; the price stays. Neither change touches the other’s territory.
Deploy
git clone https://github.com/DesignSetGo/dsgo-apps.git
cd dsgo-apps/examples/pricing-page
npx designsetgo apps deploy --build
Visit https://yoursite.com/apps/pricing/. Real HTML, real <title>, real meta tags, all the OpenGraph signals a marketing team would care about. Run a Lighthouse pass; you should see good scores across the board because there’s no client-side framework rehydration, just direct HTML.
Where to go next
- The iframe-vs-inline post for when to pick which.
- The dynamic routes post for the full dataset-injection mechanics.
- The Astro on WordPress post for the larger version of this pattern (whole-site root mount, multi-page Astro app).
- The next post in this calendar covers dropping a Claude Artifact onto your site, which is the casual import path most non-developers will reach for first.