Project: E-commerce Storefront
An e-commerce storefront is one of the best ways to exercise the full breadth of Astro: server-rendered product pages for SEO and freshness, a small interactive cart island that ships JavaScript only where it’s needed, type-safe Actions for the checkout flow, and a CMS or commerce backend feeding catalog data. This project shows how to wire those pieces together while keeping the zero-JS-by-default philosophy intact, so your marketing and listing pages stay static-fast and only the cart pays the cost of hydration.
Project shape
The storefront mixes rendering strategies on a per-route basis. Listing and product pages render on the server so inventory and pricing stay current and crawlable, while the cart is a single client island that persists across navigations. Checkout runs through an Astro Action so form logic lives in type-safe server code.
| Concern | Strategy | Why |
|---|---|---|
| Home / category listings | SSR (prerender = false) | Fresh stock, personalized banners |
| Product detail | SSR | Live price, variants, structured data |
| Cart | Client island (client:load) | Interactive, persists in storage |
| Checkout | Astro Action | Type-safe server mutation + validation |
| Static legal / about | Prerendered | No data, ship zero JS |
Enable the server output and add an adapter:
npm create astro@latest storefront -- --template minimal
npx astro add node react
// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [react()],
});
Modeling the catalog
Even when products come from a CMS, define a content collection so types and the loader live in one place. Here we pull from a headless CMS at build/request time using a custom loader, but a Markdown/file collection works identically for a fixed catalog.
// src/content.config.ts
import { defineCollection, z } from 'astro:content';
const products = defineCollection({
loader: async () => {
const res = await fetch('https://cms.example.com/api/products');
const items = await res.json();
return items.map((p: any) => ({ id: p.slug, ...p }));
},
schema: z.object({
title: z.string(),
price: z.number(),
currency: z.string().default('USD'),
image: z.string().url(),
inStock: z.boolean(),
description: z.string(),
}),
});
export const collections = { products };
Server-rendered product page
The product route stays on the server so price and stock are never stale. Astro renders zero client JS here except for the cart island we explicitly opt into.
---
// src/pages/products/[id].astro
import { getCollection, getEntry } from 'astro:content';
import AddToCart from '../../components/AddToCart';
export const prerender = false;
const { id } = Astro.params;
const product = await getEntry('products', id!);
if (!product) return Astro.redirect('/404');
const { title, price, currency, image, inStock, description } = product.data;
const formatted = new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price);
---
<article>
<img src={image} alt={title} width="480" />
<h1>{title}</h1>
<p class="price">{formatted}</p>
<p>{description}</p>
{inStock
? <AddToCart client:load id={product.id} title={title} price={price} />
: <p class="oos">Out of stock</p>}
</article>
<script type="application/ld+json" set:html={JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: title,
offers: { '@type': 'Offer', price, priceCurrency: currency },
})} />
Only the
<AddToCart>button hydrates. The rest of the page is pure HTML, so listing and detail pages render instantly and rank well.
The cart island
The cart is a React island backed by a tiny shared store so the button and the cart drawer stay in sync without prop drilling. Use a framework-agnostic store like nanostores so any island can read it.
// src/stores/cart.ts
import { persistentAtom } from '@nanostores/persistent';
export type Line = { id: string; title: string; price: number; qty: number };
export const cart = persistentAtom<Line[]>('cart', [], {
encode: JSON.stringify,
decode: JSON.parse,
});
export function addItem(line: Omit<Line, 'qty'>) {
const next = [...cart.get()];
const existing = next.find((l) => l.id === line.id);
if (existing) existing.qty++;
else next.push({ ...line, qty: 1 });
cart.set(next);
}
// src/components/AddToCart.tsx
import { addItem } from '../stores/cart';
export default function AddToCart(props: { id: string; title: string; price: number }) {
return <button onClick={() => addItem(props)}>Add to cart</button>;
}
Because the store persists to localStorage, the cart survives full-page navigations between server-rendered routes—no global SPA shell required.
Checkout with Actions
Astro Actions give you a type-safe RPC endpoint with built-in validation. The checkout action validates the cart server-side, re-checks prices against the CMS (never trust client totals), and creates an order.
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:schema';
export const server = {
checkout: defineAction({
accept: 'json',
input: z.object({
items: z.array(z.object({ id: z.string(), qty: z.number().int().positive() })),
email: z.string().email(),
}),
handler: async ({ items, email }) => {
const catalog = await fetch('https://cms.example.com/api/products').then((r) => r.json());
let total = 0;
for (const line of items) {
const p = catalog.find((c: any) => c.slug === line.id);
if (!p?.inStock) throw new Error(`Unavailable: ${line.id}`);
total += p.price * line.qty;
}
const order = await createOrder({ email, total, items });
return { orderId: order.id, total };
},
}),
};
async function createOrder(data: unknown) {
return { id: crypto.randomUUID() };
}
Call it from a checkout island:
import { actions } from 'astro:actions';
const { data, error } = await actions.checkout({ items: cart.get(), email });
if (data) window.location.href = `/orders/${data.orderId}`;
Output:
POST /_actions/checkout 200
{ "orderId": "a1b2c3d4-...", "total": 49.98 }
Best practices
- Keep listing and product pages server-rendered for fresh inventory; reserve prerendering for static content like policies and about pages.
- Hydrate only the cart and checkout—everything else should ship zero JavaScript.
- Re-validate prices and stock inside the Action; never trust totals computed in the browser.
- Use a persistent store (
nanostores) so the cart survives MPA navigations instead of building a SPA shell. - Centralize catalog types in a content collection schema so the CMS shape is validated at the boundary.
- Emit JSON-LD
Productstructured data on detail pages for richer search results. - Set sensible cache headers on SSR responses (e.g. short
s-maxage) to balance freshness against load.