How it works Examples Docs Pricing Blog WP Blocks Install free

Blog /

Build an Astro WooCommerce storefront without the headless plumbing

If you search “astro woocommerce” you get a parade of headless tutorials: stand up Astro on Vercel, point it at WooCommerce’s Store API over the network, build the auth bridge, build the cart sync, manage two deploys, two domains, two bills. By the time you have a working checkout you have written half a platform.

There is another shape. Build the Astro storefront as a DesignSetGo App, deploy it onto the same WordPress that runs Woo. The Astro routes serve as real HTML pages at real URLs on your store’s domain. The “API” is a same-window bridge call, not a network hop. WooCommerce stays in charge of products, cart, and checkout; your Astro front-end stays in charge of how the store looks and feels.

This post walks you through it end to end with @designsetgo/astro. You’ll scaffold a project, write a custom catalog page that pulls live products through the bridge, drop in two prebuilt components (cart upsell and product Q&A), and deploy with one command. The integration keeps your manifest honest as you go.

What you’re going to ship

A storefront with four URLs, all under /apps/storefront/ on your WooCommerce site:

  • /, the catalog grid, pulled from dsgo.commerce.products.list.
  • /product/:slug, the single product page, with the ProductQa component for AI-grounded product questions.
  • /cart, the visitor’s live Woo cart, with SmartCartUpsell slotted underneath.
  • Handoff to /checkout/ on the Woo site for payment, taxes, shipping, and order confirmation.

Two of those pages use prebuilt components from the package. Two are hand-written Astro pages that talk to the bridge directly. The integration handles permission and abilities merging into your dsgo-app.json no matter which you reach for.

Why this beats headless for Woo specifically

Worth being concrete about. Headless WooCommerce on Vercel or Netlify involves, at minimum:

  1. A Woo install configured to expose the Store API publicly.
  2. CORS on the WP side, allow-listing your front-end origin.
  3. Cookie-or-token cart sync between the headless storefront and the Woo backend (carts are server-side in Woo; you cannot keep them client-only).
  4. A separate deploy pipeline for the front-end.
  5. A custom domain story (Astro on shop.brand.com, Woo on brand.com) or a reverse proxy you maintain.
  6. A separate auth story if you want logged-in features (loyalty, subscriptions, B2B pricing).

Each step has a failure mode, a debugging story, and a recurring cost. None of them ship value to the merchant.

@designsetgo/astro collapses the diagram. Astro and Woo live at the same origin because they are the same origin. Your Astro bundle is served by WordPress as a static asset, the cart bridge call is in-window, the visitor’s Woo session is whatever they already had, the deploy is npx @designsetgo/cli deploy. You skip steps 1 through 6 by not needing them.

Step 1: scaffold the project

npx @designsetgo/cli init storefront
cd storefront
npm install @designsetgo/astro

The starter is already an Astro project. The CLI drops an astro.config.mjs wired to mount the app at /apps/<slug>/, a dsgo-app.json with the routes pre-declared, a Layout.astro, a CLAUDE.md so Claude Code or Cursor can read the manifest and bridge surface in working context, and the typed @designsetgo/app-client already installed.

Add the integration to astro.config.mjs:

import { defineConfig } from 'astro/config';
import dsgoAstro from '@designsetgo/astro/integration';

export default defineConfig({
  base: '/apps/storefront/',
  output: 'static',
  trailingSlash: 'always',
  build: { format: 'directory' },
  integrations: [
    dsgoAstro({
      manifest: './dsgo-app.json',
      mode: process.env.CI ? 'check' : 'write',
    }),
  ],
});

That’s the whole config. On every build, the integration scans your project for import { ... } from '@designsetgo/astro/woo', reads each component’s per-component manifest, and unions the required permissions.readabilities.consumescommerce.providers, and commerce.endpoints into your dsgo-app.json. The mode: 'check' flag in CI fails the build if anything drifted; locally it stays in 'write' mode and updates the file in place.

Step 2: drive the build with Claude Code, Cursor, or Codex

The starter ships two files at the project root that agents read on open: CLAUDE.md (a thin redirect) and AGENTS.md (the actual manual). Codex, Cursor, and Aider all look for AGENTS.md by name; Claude Code reads CLAUDE.md, which @-imports the same file. So whichever agent you point at the project opens it with the full extension manual already in working context.

That manual is dense on purpose. It covers the bridge capability map (every dsgo.* method, which permission it needs, what it returns), the manifest schema, recipes for the moves you’ll make most often (add a static route, add a bridge call, render WP posts with their block styles, call an external API), the dynamic-route template substitution syntax, and the gotchas that bite first-time vibe coders (don’t innerHTML bridge string data, the CSP nonce is per-request, npm run dev won’t run bridge calls because there’s no host to respond, and so on).

What that buys you: from the first prompt, the agent is not guessing. It knows that commerce.products.list needs permissions.read: ["commerce"] plus a commerce block. It knows the slug query param exists. It knows to call await dsgo.ready first. It knows to prefer textContent + createElement over innerHTML when rendering anything that came back from the bridge.

A typical sequence for this storefront, prompts you would actually paste:

"Add a catalog page at src/pages/index.astro. Pull products from
dsgo.commerce.products.list with per_page: 24, render as a grid of
cards with image, name, and a formatted price. Link each card to
/apps/storefront/product/<slug>/."

"Add a product page at src/pages/product.astro. Read the slug from
dsgo.context.routeParams.slug, fetch with
dsgo.commerce.products.list({ slug, per_page: 1 }), render the
product, add an Add-to-cart button that calls cart.addItem and
redirects to /cart/. Drop in the ProductQa component below."

"Add a cart page at src/pages/cart.astro. Render dsgo.commerce.cart.get
items with a remove button keyed by line.key. Add a checkout button
that calls dsgo.commerce.checkout.openHostedPage. Drop in
SmartCartUpsell underneath."

Three prompts, three pages, one storefront. Notice what you did not prompt for: manifest updates, permission declarations, abilities allow-lists. That’s the integration’s job. As long as the agent imports ProductQa and SmartCartUpsell cleanly, the next npm run build unions the components’ permissions into your dsgo-app.json automatically.

This is the part that matters specifically for vibe coding. Agents are good at writing the code; they are not always good at remembering to thread a manifest change through every relevant key. The integration removes that as a class of bug. You can let Claude Code iterate freely on imports; the manifest stays in sync, and CI catches anything that drifted (see Step 8 below).

If the agent ever invents a dsgo.* method that doesn’t exist (it happens, less often than you’d expect, but it happens), point it back: "Re-read AGENTS.md and tell me which dsgo.* method actually does X." That snaps it back to the documented surface fast.

Step 3: declare the routes

Open dsgo-app.json and set the routes you’ll publish:

{
  "manifest_version": 1,
  "id": "storefront",
  "name": "Storefront",
  "version": "0.1.0",
  "isolation": "iframe",
  "display": { "modes": ["page"], "default": "page" },
  "permissions": { "read": ["commerce"], "write": [] },
  "runtime": { "sandbox": "strict", "external_origins": [] },
  "routes": [
    { "path": "/",              "file": "index.html",         "title": "Shop" },
    { "path": "/product/:slug", "file": "product/index.html", "title": "Product",
      "dataset": { "source": "wc:products", "id_field": "slug" } },
    { "path": "/cart",          "file": "cart/index.html",    "title": "Cart" }
  ],
  "commerce": {
    "providers": ["woocommerce"],
    "endpoints": ["products", "cart", "checkout"]
  }
}

Three things worth pointing at:

  • /product/:slug is a dynamic route. Astro emits one product/index.html template; the plugin substitutes the matched product’s data into it at request time. One file in your bundle, every product on the site. Dynamic routes are a Pro feature; without it you’d statically pre-render one HTML file per product at build time, which is fine until your catalog grows.
  • permissions.read: ["commerce"] is the only scope you have to declare by hand. The integration will add more when you import components that need them (the ProductQa component pulls in ai and user, for example).
  • commerce.endpoints tells the install dialog which Woo surfaces the app reaches: products, cart, and checkout. The site owner sees those three named in the install bucket, not a vague “this app uses commerce” line. (See the permission buckets post for why this matters.)

Step 4: write the catalog page

Create src/pages/index.astro:

---
import Layout from '../layouts/Layout.astro';
---
<Layout title="Shop">
  <h1>Shop</h1>
  <ul id="grid" class="grid"></ul>
</Layout>

<script>
  import { dsgo } from '@designsetgo/app-client';

  await dsgo.ready;
  const { items } = await dsgo.commerce.products.list({ per_page: 24 });

  const grid = document.getElementById('grid');
  grid.innerHTML = items.map((p) => `
    <li class="card">
      <a href="${import.meta.env.BASE_URL}product/${p.slug}/">
        <img src="${p.images?.[0]?.src ?? ''}" alt="${p.images?.[0]?.alt ?? ''}" />
        <h2>${escape(p.name)}</h2>
        <p class="price">${formatPrice(p.prices?.price_range?.min_amount ?? p.prices?.price)}</p>
      </a>
    </li>
  `).join('');

  function formatPrice(price) {
    if (!price) return '';
    const amount = typeof price === 'string' ? price : price.amount;
    const currency = (typeof price === 'object' && price.currency) || 'USD';
    const minor = Number((typeof price === 'object' && price.minor_unit) ?? 2);
    const major = Number(amount) / Math.pow(10, minor);
    return new Intl.NumberFormat(undefined, {
      style: 'currency', currency, minimumFractionDigits: minor, maximumFractionDigits: minor,
    }).format(major);
  }

  function escape(s) {
    return String(s).replace(/[<&>]/g, (c) =>
      c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;');
  }
</script>

That’s the catalog. One bridge call, one render loop, two helpers. The Store API returns prices in minor units ({ amount: "9999", currency: "USD", minor_unit: 2 } is $99.99); divide by 10^minor_unit once you parse to a number, then format with Intl.NumberFormat. Skip that step and yen amounts will look like dollars and Kuwaiti dinar will look like nonsense.

Step 5: drop the product page in with ProductQa

Dynamic routes expose the matched URL parameter as dsgo.context.routeParams.<name>. Read the slug, fetch the product through the commerce bridge, render it, and drop a ProductQa component below:

Create src/pages/product.astro (which renders to dist/product/index.html):

---
import Layout from '../layouts/Layout.astro';
import { ProductQa } from '@designsetgo/astro/woo';
---
<Layout title="Product">
  <article id="product"></article>

  <section class="qa">
    <h2>Ask about this product</h2>
    <ProductQa />
  </section>
</Layout>

<script>
  import { dsgo } from '@designsetgo/app-client';

  await dsgo.ready;
  const slug = dsgo.context.routeParams.slug;
  const { items } = await dsgo.commerce.products.list({ slug, per_page: 1 });
  const product = items[0];

  const root = document.getElementById('product');
  if (!product) {
    root.innerHTML = '<p>Product not found.</p>';
  } else {
    root.innerHTML = `
      <img src="${product.images?.[0]?.src ?? ''}" alt="${product.images?.[0]?.alt ?? ''}" />
      <h1>${escape(product.name)}</h1>
      <div class="description">${product.description}</div>
      <button id="add" data-id="${product.id}">Add to cart</button>
    `;
    document.getElementById('add').addEventListener('click', async (e) => {
      await dsgo.commerce.cart.addItem({
        id: Number(e.currentTarget.dataset.id),
        quantity: 1,
      });
      window.location.href = `${import.meta.env.BASE_URL}cart/`;
    });
  }

  function escape(s) {
    return String(s).replace(/[<&>]/g, (c) =>
      c === '<' ? '&lt;' : c === '>' ? '&gt;' : '&amp;');
  }
</script>

(If you want the catalog page to be statically renderable for crawlers without JS, use the manifest’s {{name}} / {{{description}}} template substitution to bake the matched product’s fields into the HTML at request time; see the dynamic routes section of MANIFEST.md for the full token set. The runtime fetch above is the simpler path for an interactive page.)

Now run a build and look at dsgo-app.json. The integration noticed the ProductQa import and added the permissions it needs:

   "permissions": { "read": ["commerce"], "write": [] },
+  "permissions": { "read": ["commerce", "ai", "user"], "write": [] },

That’s the merge behavior. You did not have to remember that ProductQa reads the current user (for per-customer chat history) or calls AI (for grounded answers). The component declared its own per-component manifest; the integration unioned it into yours.

ProductQa is one of the two AI components in v0.1.0. It answers visitor questions using only the product description and any admin-curated facts the merchant added in wp-admin. No hallucinated specs. The AI call routes through the site’s WP 7.0 AI Connector, so the merchant configures Anthropic or OpenAI or Google credentials once at Settings → Connectors and every DSGo App on the site inherits them. Your bundle never sees a key.

Step 6: build the cart page with SmartCartUpsell

Create src/pages/cart.astro:

---
import Layout from '../layouts/Layout.astro';
import { SmartCartUpsell } from '@designsetgo/astro/woo';
---
<Layout title="Cart">
  <h1>Your cart</h1>
  <ul id="lines"></ul>
  <p id="total"></p>
  <button id="checkout">Continue to checkout</button>

  <section class="upsell">
    <SmartCartUpsell count={4} heading="You might also like" />
  </section>
</Layout>

<script>
  import { dsgo } from '@designsetgo/app-client';

  await dsgo.ready;

  async function render() {
    const cart = await dsgo.commerce.cart.get();
    const lines = document.getElementById('lines');
    lines.innerHTML = cart.items.map((line) => `
      <li>
        <span>${escape(line.name)}</span>
        <span>${line.quantity} &times; ${formatPrice(line.prices.price)}</span>
        <button data-key="${line.key}" class="remove">Remove</button>
      </li>
    `).join('');
    document.getElementById('total').textContent =
      `Total: ${formatPrice(cart.totals.total_price)}`;

    for (const btn of lines.querySelectorAll('.remove')) {
      btn.addEventListener('click', async () => {
        await dsgo.commerce.cart.removeItem({ key: btn.dataset.key });
        await render();
      });
    }
  }

  document.getElementById('checkout').addEventListener('click', async () => {
    await dsgo.commerce.checkout.openHostedPage();
  });

  await render();

  function formatPrice(price) { /* same as catalog */ }
  function escape(s) { /* same as catalog */ }
</script>

The cart line key (not the product id) is what you pass to removeItem and updateItem. The same product can sit in the cart twice with different variations, so the per-line key is the unit of truth.

SmartCartUpsell picks 3 to 4 products from the visitor’s current-cart categories and renders them as add-to-cart cards. The component bundles its own scoped CSS, its own boot script keyed to a data-dsgo-component slug, and its own mount logic; you do not wire anything by hand. The integration sees the import and unions commerce and abilities into your manifest. Since you already had commerce, only abilities gets added on this build.

checkout.openHostedPage() triggers a top-window navigation to Woo’s hosted checkout with the cart preserved. In iframe mode the bridge handles the top-level navigation through a dsgo:nav-top message to the parent; in inline mode it navigates the current window directly. Payment, fraud screening, taxes, shipping, order confirmation, post-purchase emails: all Woo. None of it your problem.

Step 7: deploy

npm run build
npx @designsetgo/cli deploy --site https://your-shop.com --build

The first deploy will prompt for an application password from your Woo site (generate one in Users → Profile → Application Passwords). The CLI stores credentials in ~/.designsetgo/credentials.json; subsequent deploys to the same site skip the prompt.

Open https://your-shop.com/apps/storefront/ and the catalog renders. Click into a product, the dynamic route resolves at request time and the page comes back. Add to cart, click through to /cart/, hit checkout, and you’re on Woo’s own checkout page with the cart pre-loaded. View source: real HTML, real <title>, real meta tags, real internal links. Crawlable. Linkable. Shareable.

Step 8: the CI gate

Push a branch with the project so far. In GitHub Actions (or any CI):

- run: npm ci
- run: npm run build
  env:
    CI: 'true'

With mode: 'check' active in CI, the build fails non-zero with a printed diff if the manifest on disk is stale. So when Claude Code (or Cursor, or you) adds a new component import on a feature branch, the build forces you to commit the manifest update too. No more “wait, why is the install dialog asking for AI access now?” surprises in production.

To update the manifest locally: run npm run build once without CI=1, commit the changed dsgo-app.json, push, CI passes.

Step 9: make the storefront the whole site (optional)

Everything above lives at /apps/storefront/. That’s a great default for adding a storefront alongside an existing Woo site, an existing blog, an existing About page. If you want the Astro app to be the site, set:

"mount": { "mode": "root" }

Now the app serves at / directly. No /apps/ prefix. Real WP pages, posts, archives, and feeds still win (root mode adds, it does not shadow), so the merchant keeps publishing in wp-admin without breaking your storefront. Your //product/:slug/, and /cart/ routes own the URLs the WP front-end would otherwise serve.

This is the configuration where DSGo Apps stops being “host an Astro storefront on WordPress” and starts being “WordPress is the headless commerce engine for your Astro site, on the same domain, with no rewrite layer to maintain.”

What you did not write

A short tally for the post-mortem you would otherwise be writing six months in:

  • No CORS. Same origin.
  • No cart sync layer. The cart is Woo’s cart, fetched through an in-window bridge call.
  • No auth bridge. The visitor’s Woo session is whatever they already had with WordPress.
  • No checkout reimplementation. You handed off to checkout/ on the Woo side and let it do the regulated, PCI-scoped, frequently-updated work it already does well.
  • No API keys in your bundle. AI components route through the site’s WP 7.0 Connector. Storefront ships without a single credential.
  • No separate deploy pipeline. npx @designsetgo/cli deploy --build. One command, idempotent re-runs, GitHub Action available if you want it on push to main.
  • No headless-vs-monolith debate at the team retro. Astro for the front-end. Woo for the commerce engine. Same domain. Nothing to glue.

Where to go next

Go build the storefront the headless Vercel post made you stop wanting to build.