Appearance
A Nuxt storefront on a Laravel CMS, without writing the API
You write Nuxt. A client wants a shop with a blog, an admin their editors can use, and pages that rank. The usual answer is a headless CMS and your own app: an API to learn, a client to write, a shape for every kind of page, a render layer, and the cart somewhere else again. By the time the first product page is up, most of the work was glue.
This post builds a Nuxt storefront on kitloom, a Laravel CMS, and the glue is one server route. Every address of the site comes back as JSON with everything its own templates get — the record, the menus, the widgets, the <head>, the catalog, the product, the cart. The site says which template the address is; Nuxt draws it. The theme is called Harvest, it is in autumn colours, and the whole of it is a few hundred lines.
The content is somebody else's job
Before the templates: who fills the shop? With kitloom/ai an agent connects to the site over MCP and works through the same code as the admin, as the user whose key it holds. Hand it the brief:
- "Create the About, Delivery and Contact pages and put them in the header menu."
- "Add sixteen products in three categories with prices, weights and a photo each."
- "Write five posts for the journal, each with a cover and an excerpt."
create_content, product_save, menu_save, upload_media — each module brings its tools, nothing is deleted for good, every change is in the activity log, and revoking the key stops the agent at once. So while you build the storefront, the catalog you build against is real. Run your site from Claude or Cursor shows the setup.
Every page is JSON already
A kitloom site answers any address it serves with JSON when asked for it:
GET https://shop.example.com/product/walnut-halves-250-g/
Accept: application/json
{
"kind": "single",
"template": "single-product",
"content": { "title": "Walnut Halves, 250 g", "content": "<p>…</p>", … },
"product": { "priceFormatted": "$6.40", "onSale": true, "gallery": […], "reviews": {…}, … },
"menus": {…}, "menuLocations": {…}, "widgets": {…},
"head": { "title": "…", "description": "…", "canonical": "…", "openGraph": {…} },
"schema": { "@context": "https://schema.org", … },
"languages": {…}, "cart": { "count": 0, … }
}An archive carries its posts with pagination, the shop page its catalog with the categories, the sort orders and the filters, a post its comments, the front page the data of each shortcode the owner put on it. The same JSON a Vue or React theme on the site itself reads — the headless page lists the parts. So the Nuxt app needs no API client, no types guessed from a REST reference, and no second source of truth for the SEO: the title, the description, the canonical, the Open Graph card and the JSON-LD were computed by the site's SEO module, and the theme puts them into useSeoMeta.
One route, one server route
The theme has one page, app/pages/[...slug].vue, and one server route that reads the site:
ts
// server/api/page.get.ts
export default defineEventHandler(async (event) => {
const path = String(getQuery(event).path ?? '/')
const response = await siteFetch<Page>(event, path) // GET <site><path>, Accept: application/json
setResponseStatus(event, response.status) // a 404 of the site is a 404 of the theme
return response._data
})A global route middleware reads the page once per address with useAsyncData and shares it with the layout, so the header and the footer draw the menus and the widgets the page came with — on the server as on the client, no flash:
ts
// app/middleware/page.global.ts
export default defineNuxtRouteMiddleware(async (to) => {
const request = useRequestFetch()
const { data } = await useAsyncData<Page>(`page:${to.fullPath}`, () =>
request('/api/page', { query: { path: to.fullPath }, ignoreResponseError: true }))
useSharedPage().value = data.value!
})The page picks its template from what the site said — WordPress's template hierarchy, decided by the server, drawn by Nuxt:
ts
export function resolveTemplate(page: Page): Component {
if (page.kind === 'not-found') return NotFound
if (page.product) return SingleProduct
if (page.catalog) return Catalog
if (shortcode(page, 'woocommerce_cart')) return Cart
if (shortcode(page, 'woocommerce_checkout')) return Checkout
if (shortcode(page, 'woocommerce_my_account')) return Account
if (page.template === 'front-page') return FrontPage
if (page.posts) return Archive
return Single
}Links in the site's answers are absolute — to the site. A small composable turns them into the theme's own paths, and HTML written in the admin gets the same treatment, with a click on an inner link navigating without a reload. The French version of the site (/fr/…) works the moment the languages module is on: the page comes back in French, the menus too, and the theme never knew.
The cart is the site's, the session is yours
The cart, the checkout and the account are not a second shop. They are the site's, through its Store API — the same routes WooCommerce's blocks use, wc/store/v1/cart/add-item, update-items, checkout, account/register — and through the site's POST /login and POST /logout. The one thing Nuxt adds is a session: the theme's server keeps the cookies the site gave the visitor in one httpOnly cookie of its own, sends them with every request, and names the site as the Origin of every POST, so the site's forms pass its forgery check as they would from its own pages.
ts
// server/utils/site.ts — every request to the site as the visitor
const response = await $fetch.raw(site + path, {
headers: { Accept: 'application/json', Origin: site, Cookie: jar },
ignoreResponseError: true,
})
// cookies the site set come back into the jarThat is the whole integration. useCart() is one state for the header's count, the buttons and the cart page. The checkout form is not written in the theme either: the site's checkout page hands over its fields with their labels and which are required, the countries it sells and ships to, the states of each, the words a country uses for state and postcode, the payment methods with their descriptions, the terms to accept. A changed address re-prices the cart and the shipping rates come back with it; "Place order" posts the form to the site, which validates it against its own rules and answers either where to go next or a message per field. The receipt is the site's "order received" address, drawn by the theme. The account is the same: sign in, sign up, the orders with their pages, the addresses and the details, each view as the site draws it for that customer.
No CORS, because the browser only ever talks to Nuxt. No API keys, because the visitor is the visitor. No public endpoint opened on the site.
What you get for the templates you write
Eight templates in Vue, plain CSS variables for the palette (cream paper, rust, pumpkin, mustard, moss) with a dark set, @nuxt/fonts for the type, and a site that renders on the server with the right <head> on every address. The site keeps its admin — Filament or Nova, with the screens editors already know — its SEO, its languages, its activity log, and the agent that fills it. The storefront keeps Nuxt.
The theme is on GitHub: kitloom/harvest-theme. Point NUXT_PUBLIC_SITE_URL at a kitloom site, run npm run dev, and it draws it; the README says the three things to switch on in the admin for guests to order.
Try it
A sandbox is a site of your own for three days, with demo content and the admin, no sign-up: Pantry, the nuts-and-dried-fruit shop the theme was built against. Put its address into the theme's .env and the storefront is yours to change.
kitloom is a paid product — one price per site, or unlimited — and the documentation is open to everyone.