Blog /
Dynamic routes: one HTML file, every post on your site
The Astro-on-WordPress post and the pricing-page tutorial both touched briefly on dynamic routes. This post is the deeper look. Dynamic routes are the difference between “I have to redeploy every time someone publishes a post” and “the WordPress site is the source of truth and the bundle is the template.”
This is the post developers reach for after their second or third DSGo App, when they want to do something more interesting than render a single page. It’s also the post where the difference between DSGo and a static-site generator stops being a question of taste and starts being a question of capability: dynamic routes are the thing static-site generators can fake but can’t really do.
What dynamic routes do
In a static-bundle world, every URL needs a corresponding file. A site with 500 blog posts needs 500 HTML files, one per post. The build pipeline has to render all 500 every time anything changes. The bundle gets large; the deploy gets slow; the build pipeline gets fragile. Astro’s content collections, Next.js’s getStaticProps, Jekyll’s collections: every static-site generator deals with this by rebuilding the world on change.
Dynamic routes change the model. You ship one template. The plugin substitutes data into the template at request time, based on a manifest declaration that says “this template is parameterized by something.” When the visitor hits /apps/site/posts/hello-world/, the plugin:
- Sees the route pattern matches
/posts/:slug. - Looks up the post with slug
hello-worldin the WordPress database. - Renders the template with the post’s data substituted in.
- Returns the rendered HTML.
One template, every post. The bundle stays small. The build pipeline doesn’t need to know about the post catalog. The crawler sees real HTML. Marketing publishes a post; the URL exists three seconds later.
The manifest
{
"routes": [
{ "path": "/", "file": "index.html", "title": "Home" },
{
"path": "/posts/:slug",
"file": "post/index.html",
"dataset": {
"source": "wp:posts",
"id_field": "slug"
}
}
]
}
The dataset field is what activates dynamic routing on a route. source is which WP data the plugin queries; id_field is which field the URL parameter (:slug) maps to.
A route without dataset is a static route; the file is served as-is from the bundle. A route with dataset is dynamic; the file is treated as a template, and the plugin substitutes the matching dataset entry’s fields into it before serving.
Built-in dataset sources
Four sources ship in v1:
wp:posts. Posts (the defaultpostpost type).id_fieldcan beslugorid. Returns the same shape as/wp/v2/posts.wp:pages. Pages. Same options.wp:cpt:<slug>. Any custom post type.wp:cpt:eventfor aneventCPT,wp:cpt:propertyfor apropertyCPT, etc. The CPT must be registered withshow_in_rest: truefor the dataset source to see it.wc:products. WooCommerce products. The bridge resolves through Woo’s REST or Abilities surface.
Each source produces a structured object the template can interpolate. For wp:posts, the available fields are roughly what dsgo.posts.get() would return: title, excerpt, content, date, author, plus all custom-field metadata. The full shape is documented per source in the bridge reference.
Template substitution
Two ways to bring the data into the template.
Server-side substitution (the simpler path):
<article>
<h1>{{title.rendered}}</h1>
<time>{{date_iso}}</time>
<div class="content">{{{content.rendered}}}</div>
</article>
The plugin parses the template, finds {{...}} tokens, and substitutes the matching field from the dataset. The output is real HTML at the URL. The crawler sees title, date, and full content without running any JavaScript. SEO works without ceremony.
A few things to know about substitution:
- Two brace counts, two behaviors.
{{field}}is HTML-escaped (<becomes<).{{{field}}}is raw — the value is inserted as-is, with the inline-mode sanitizer running afterward to strip disallowed tags/attributes. Use{{{...}}}for fields that legitimately contain HTML (likecontent.rendered); use{{...}}for plain text fields where you’d want any markup escaped. - Dot notation walks nested fields. Live
wp:*rows exposetitle.rendered,content.rendered,excerpt.rendered,permalink,slug,id,date_iso,featured_media_url, etc. (Full list in the manifest reference.) The substitution path is dot-separated and matches[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*. <script>and<style>blocks are skipped. Substitution does not run inside script or style content; apps that need dataset values in client-side JS read them fromdsgo.context.routeParams(or fetch them via the bridge).- Missing fields render as empty strings. Not as the literal
{{...}}. Defensive of malformed datasets.
Client-side bridge calls (the more powerful path):
<article>
<h1 id="title"></h1>
<time id="date"></time>
<div id="content"></div>
</article>
<script type="module">
import { dsgo } from '@designsetgo/app-client';
await dsgo.ready;
const { slug } = dsgo.context.routeParams;
const { items } = await dsgo.posts.list({ slug, per_page: 1 });
const post = items[0];
document.getElementById('title').textContent = post.title;
document.getElementById('date').textContent = new Date(post.date).toLocaleDateString();
document.getElementById('content').innerHTML = post.content;
</script>
dsgo.context.routeParams is a property (not a method), and the bridge’s posts shape flattens title.rendered to title for app code. (Server-side dataset substitution keeps the REST envelope; bridge calls don’t.)
This route doesn’t use server-side substitution at all; the manifest’s dataset declaration just lets the plugin know what URL patterns to honor. The bridge call at view time fetches the data and renders it.
The hybrid: use server-side substitution for SEO-critical fields (<title>, the meta description, the headline <h1>) and bridge calls for everything else. The crawler gets the page-level signals; the visitor gets the dynamic content.
What v1 doesn’t do: server-side loops
Worth being explicit: the substitution engine in v1 supports {{field}} (HTML-escaped) and {{{field}}} (raw) plus dot-path navigation like {{title.rendered}}. There’s no {{#each}} or {{#if}} syntax. Dynamic routes produce one rendered page per URL keyed by the path’s :param; there’s no built-in “render this template with the whole dataset injected as an array” mode.
If you want an index page that lists every post, you write it client-side: a static posts.html route with a <script> that calls dsgo.posts.list() and builds the list in the DOM. The route file itself stays simple, the page loads instantly, and the list fills in once the bridge resolves. Crawlers that execute JS (Google’s main bot does) still index the resulting page; if your audience includes crawlers that don’t, paginate the list server-side by giving each page-worth of items its own route and dataset.
The constraint is deliberate — Mustache subsets with loops grow fast, and the team would rather you reach for the bridge for list-shaped UI than maintain a templating dialect.
Custom dataset resolvers
The four built-in sources cover most cases. For everything else, register a custom resolver via WP filter:
add_filter('dsgo_apps_dataset_resolver', function ($resolvers) {
$resolvers['my-app:reviews'] = function ($id, $context) {
// Look up the review by ID from wherever (custom table, third-party API,
// computed from other WP data).
return [
'title' => 'Review of ' . $id,
'rating' => 5,
'body' => 'This is great.',
// …
];
};
return $resolvers;
});
In your manifest:
{
"path": "/reviews/:product",
"file": "review/index.html",
"dataset": { "source": "my-app:reviews", "id_field": "product" }
}
When the visitor hits /reviews/dishwasher, the plugin calls your resolver with id = "dishwasher" and uses the returned array for substitutions. The resolver can query a custom database table, hit a third-party API (within the manifest’s allowlisted origins), or compose data from multiple WP sources.
This is the integration seam for “the site has a custom data source we want to expose at routes.” Booking systems, custom directories, internal tools, anything where the data isn’t quite a WP post type but acts like one. Resolvers compose: you can register multiple, and the manifest’s source field picks which one to invoke per route.
Where this is the right call
A short table of when dynamic routes are worth using vs alternatives:
| Use case | Best approach |
|---|---|
| Marketing site with 5 hand-written pages | Static routes; no dataset |
| Blog with hundreds of posts | Dynamic route, wp:posts, server-side substitution |
| Documentation site backed by a CPT | Dynamic route, wp:cpt:doc |
Event listing site with an event CPT | Dynamic route, wp:cpt:event, list route for the index |
| Product catalog with WooCommerce | Dynamic route, wc:products |
| Custom directory with a third-party API | Dynamic route, custom resolver |
| Real-time data (stock prices, weather) | Bridge call only; no dataset substitution |
The rule of thumb: if the data is in WordPress (or WooCommerce, or a custom store), use dataset substitution. If the data is real-time and external, use bridge calls. The two approaches compose; you can have both in the same template.
What dynamic routes are NOT for
A few things people sometimes try that are out of scope:
- Authenticated content. The dataset substitution happens server-side at the page level and doesn’t run capability checks; if you need different content for different users, do that with a bridge call after the page loads, not with substitution. Server-side substitution is for content where the user identity doesn’t change what’s rendered.
- Computed routes that aren’t keyed by a slug or ID. The route param has to map to a single dataset entry. If you want “all events in March,” that’s a list route with a query parameter (
/events/?month=march), not a dynamic route per month. - Complex template logic. The substitution syntax is
{{field}}, dot notation, and{{#each}}. There’s no{{#if x > 5}}. If the template needs branching, do it client-side with JavaScript on the rendered page.
Performance notes
A few performance details that show up at scale:
- Server-side substitution is fast. The plugin caches the parsed template per route; the substitution itself is string replacement plus a tiny tokenizer. A typical request adds a few milliseconds to the WP page response time.
- Dataset queries hit the same caches WP uses. A
wp:postsquery goes through the same object cache and database as the rest of WordPress. If your site is doing well on cache hits already, dataset routes do too. - List routes can be heavy. Rendering 100 items in a
{{#each}}is fine; rendering 10,000 is not. Use pagination or split into multiple lists. The substitution engine doesn’t stream; it builds the response in memory. - Custom resolvers are as fast as you make them. A resolver that queries a slow third-party API on every request will be slow. Wrap with
wp_cache_*or transients for repeat queries.
Where to go next
- The Astro on WordPress post for the integration with Astro’s content collections.
- The pricing-page tutorial for a concrete dataset-injection example.
- The dynamic routes design spec for the design rationale.