Skip to content

A Next.js storefront on a Laravel CMS, with React Server Components ​

You write Next.js. A client wants a shop with a blog, reviews, an admin their editors can use, and pages that rank. The usual stack is a headless CMS for the words, a commerce API for the shop, an auth provider for the account, and your app in the middle translating between three shapes of data. Server Components made the rendering easy; the glue is still most of the work.

This post builds a Next.js storefront on kitloom, a Laravel CMS, where the glue is one function. Every address of the site answers with JSON that already says which template it is and carries everything the page needs — the record, the menus, the widgets, the <head>, the catalog, the product, the visitor's cart. A Server Component reads it and draws it. The theme is called Mesa, it is in desert colours — sand, terracotta, the sun, a dusk-blue sky for the dark mode — and it is the sibling of Harvest, the same storefront in Nuxt.

The catalog is not your job ​

Before the templates: who fills the shop? With kitloom/ai an AI agent connects to the site over MCP and works through the same code as the admin, as the user whose key it holds:

  • "Add sixteen products in three categories with prices, weights and a photo each."
  • "Write the About, Delivery and Contact pages and put them in the header menu."
  • "Answer this week's comments and approve the ones waiting."

product_save, create_content, menu_save, comments_reply — every module brings its tools, nothing is deleted for good, every change lands in the activity log, and revoking the key stops the agent at once. So the store you build against is a real one from the first day. Run your site from Claude or Cursor shows the setup.

One route, read on the server ​

The whole site is one page, app/[[...slug]]/page.tsx. It reads the address from the site with the visitor's cookies and lets the site decide what the address is:

tsx
export default async function SitePage(props: Props) {
  const { path, page } = await load(props)        // GET <site><path>, Accept: application/json

  if (!page) throw new Error('The site could not be read.')
  if (page.kind === 'not-found') notFound()

  const Template = resolveTemplate(page)           // front-page, single, archive, catalog, product…

  return (
    <Shell page={page} path={path}>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: jsonLd(page.schema) }} />
      <Template page={page} path={path} />
    </Shell>
  )
}

resolveTemplate is WordPress's template hierarchy decided by the server: a page with a product part is the product template, one with a catalog the catalog, the shop's cart, checkout and account pages are known by their shortcodes, and the rest goes by the template the site sent — front-page, single, archive, search. The JSON is the same one a Vue or React theme on the site itself reads; the headless page lists its parts.

The read sits under React's cache(), so generateMetadata and the page share one request to the site:

ts
export const getPage = cache(async (path: string) => (await siteFetch<Page>(path)).data)

export async function generateMetadata(props: Props): Promise<Metadata> {
  return metadataOf((await load(props)).page)      // the site's SEO head, on the theme's origin
}

The title, the description, the robots, the canonical, the hreflang alternates and the Open Graph card come from the site's SEO module; the theme only moves the addresses onto its own domain. There is no second source of truth for SEO to keep in step.

A 404 that is a 404 ​

An address the site does not have comes back with "kind": "not-found". The page calls notFound(), and Next answers with a real 404 status. The not-found boundary gets no route params, though, and it has to draw the site's header and footer too. So proxy.ts — Next 16's name for middleware — hands the address to the render as a request header, and app/not-found.tsx reads the page from the same cache:

ts
export function proxy(request: NextRequest) {
  const forwarded = new Headers(request.headers)
  forwarded.set('x-theme-path', request.nextUrl.pathname + request.nextUrl.search)
  return NextResponse.next({ request: { headers: forwarded } })
}

The same proxy adds the trailing slash to the site's addresses, as WordPress does — /shop goes to /shop/ — while the theme's own routes under /api keep theirs without one.

The cart is the site's, the session is yours ​

The cart, the checkout, the account, the comments and the reviews are not rebuilt in the theme. They are the site's, through its Store API — cart/add-item, update-items, checkout, account/register — and its sign-in and comment routes. What Next adds is the visitor's session. A Server Component may read cookies but not set them, so every change goes through a Route Handler, which keeps the cookies the site gave the visitor in one httpOnly cookie of the theme and names the site as the Origin, so the site's forgery check passes as for its own forms:

ts
// app/api/cart/add/route.ts
export async function POST(request: Request) {
  const { product_id, quantity } = await request.json()
  return answer(await siteFetch('/wp-json/wc/store/v1/cart/add-item', {
    method: 'POST', keep: true, body: { product_id, quantity },
  }))
}

The button is a Client Component that calls the route and then asks the server to draw the page again:

tsx
const add = async () => {
  const result = await api('/api/cart/add', { product_id: product.id, quantity })
  notify(result.message)
  if (result.ok) router.refresh()                  // the header's count is the site's again
}

No cart store in the browser, nothing to keep in sync: after every change the header, the cart page and the checkout are drawn from what the site says now. The checkout form itself comes from the site's checkout page — its fields with their labels and which are required, the countries it sells and ships to, the states of each, the delivery rates and the payment methods. "Place order" answers either where to go next or a message per field; the receipt and every view of the account are pages of the site, drawn by the theme. A comment or a review carries the site's spam protection, the honeypot and Cloudflare Turnstile, and lands held or published, as the site decides.

Three things Next 16 changes ​

The Next.js in this theme is not the one in most tutorials, and the package says so: it ships its documentation in node_modules/next/dist/docs and asks you to read it first. Three of its changes shaped the theme.

  • Middleware is Proxy. Same idea, new file name, and skipTrailingSlashRedirect lets it decide where the trailing slash goes.
  • Cache Components are off. With them Next keeps the last pages alive under React's <Activity> instead of unmounting them. For a page that belongs to the visitor — their cart, signed in or not — a page kept from before is the bug, not the feature. Every page here is dynamic and drawn fresh.
  • The header lives in the page, not the layout. A layout stays from one page to the next. The menus change with the language, the cart count with every order, the account icon with signing in, so the frame is drawn with the page it belongs to.

One contract, two frameworks ​

Mesa answers the same routes of its own as Harvest does: /api/page, /api/cart/add, /api/checkout, /api/account/login, /api/comments. So one script checks both. npm run regression walks what a visitor does over HTTP — every kind of page drawn on the server with its head and links, a guest's cart and order, an account signed up, out and in, an order as the customer and the account after it, a comment, a reply and a review — and it passes the same 42 checks on Nuxt and on Next.js. The site never knew which one it was talking to.

The theme is on GitHub: kitloom/mesa-theme. Point NEXT_PUBLIC_SITE_URL at a kitloom site, run npm run dev, and it draws it. The README says what 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 both themes were 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.

Last updated:

kitloom — modules and themes for Laravel.
Contact · Pricing · Services · License · Refunds · Privacy · Community · YouTube · [email protected]