Skip to main content

Pre-Rendering

Shoppex always serves static files. There is no runtime server-side rendering (SSR). Two independent mechanisms turn a bare SPA shell into fully rendered HTML.
MechanismWho owns itWhat it does
Platform Initial-Data InjectionShoppex platform (automatic)Injects store data into index.html after build
Theme Pre-Render Script (SSG)Theme author (opt-in)Renders route-specific HTML files at build time
You can use either one alone, or combine both for the best result.

Mechanism 1: Platform Initial-Data Injection

After bun run build, Shoppex scans dist/index.html for the placeholder <!--initial-data-->. If found, it replaces the comment with a script tag containing the shop’s storefront data.

Add the Placeholder

<!-- dist source: index.html -->
<!doctype html>
<html>
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <div id="root"><!--app-html--></div>
    <!--initial-data-->
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

What Shoppex Injects

At deploy time the platform replaces the placeholder with:
<script>
  window.__SHOPPEX_INITIAL__ = {
    store: { /* Shop object */ },
    products: [ /* Product[] */ ],
    groups: [ /* ProductGroup[] */ ],
  };
</script>
This is fully automatic. The theme only needs the &lt;!--initial-data--&gt; comment in index.html. No build script changes required.
window.__SHOPPEX_INITIAL__ is injected at build/deploy time. If your shop data changes later (for example after a migration/import), the injected HTML can be temporarily stale. Themes should treat initial data as “fast first paint” and still revalidate with shoppex.getStorefront() on the client.

Reading the Injected Data

Your app can read the data before any network request:
const initialData = window.__SHOPPEX_INITIAL__;
// { store, products, groups } or undefined
The reference theme wraps this in an InitialDataContext (see below).

Mechanism 2: Theme-Owned Pre-Render Script (SSG)

For full SEO and instant paint of every route, a theme can generate static HTML files during the build. This is entirely theme-owned — Shoppex does not run the script; you wire it into your build pipeline.

Build Script Setup

In package.json, chain the SSG step after the client and SSR builds:
{
  "scripts": {
    "build:client": "vite build",
    "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist-ssr",
    "build:ssg": "bun run build:client && bun run build:ssr && bun scripts/prerender.mjs",
    "build": "bun run build:ssg"
  }
}
On publish, Shoppex runs bun run build. If you only add build:ssg but keep build as vite build, pre-rendering will run locally but not in production.

How the Pre-Render Script Works

scripts/prerender.mjs does three things:
  1. Fetch data — calls the Shoppex API for store and products.
  2. Render routes — imports dist-ssr/entry-server.js and renders each route to an HTML string.
  3. Write files — replaces <!--app-html--> and <!--initial-data--> in the template and writes the result to dist/.
Routes rendered by the reference theme:
RouteOutput file
/dist/index.html
/cartdist/cart/index.html
/checkoutdist/checkout/index.html
/product/:slugdist/product/<slug>/index.html (one per product)

entry-server.tsx (Reference)

The SSR entry renders the app to a string using a static router:
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom/server';
import { InitialDataProvider } from './context/InitialDataContext';
import { AppShell } from './AppShell';

export function render(url: string, initialData: InitialData) {
  return renderToString(
    <InitialDataProvider value={initialData}>
      <StaticRouter location={url}>
        <AppShell />
      </StaticRouter>
    </InitialDataProvider>
  );
}

Client-Side Hydration

The client entry (main.tsx) detects whether the page was pre-rendered and chooses hydration or fresh rendering:
import { hydrateRoot, createRoot } from 'react-dom/client';

const rootElement = document.getElementById('root')!;
const initialData = window.__SHOPPEX_INITIAL__;
const hasSSRContent = rootElement.firstElementChild !== null;

if (hasSSRContent && initialData) {
  // Page was pre-rendered — hydrate to preserve existing DOM
  hydrateRoot(rootElement, <App initialData={initialData} />);
} else {
  // SPA fallback — render from scratch
  rootElement.innerHTML = '';
  createRoot(rootElement).render(<App initialData={initialData} />);
}
If hydrateRoot is called but the server-rendered HTML does not match the client render, React will log hydration mismatch warnings. Keep server and client rendering deterministic.

InitialDataContext

The reference theme provides the injected (or pre-rendered) data through React context so any component can access it without prop drilling:
interface InitialData {
  store?: Shop | null;
  products?: Product[];
  groups?: ProductGroup[];
}

const InitialDataContext = createContext<InitialData | undefined>(undefined);

export function InitialDataProvider({
  value,
  children,
}: {
  value?: InitialData;
  children: React.ReactNode;
}) {
  return (
    <InitialDataContext.Provider value={value}>
      {children}
    </InitialDataContext.Provider>
  );
}

export function useInitialData(): InitialData | undefined {
  return useContext(InitialDataContext);
}
Components that call useInitialData() get the store and products immediately — no loading spinner needed on first paint.

Framework-Agnostic Notes

  • The <!--initial-data--> placeholder works for any framework. Platform injection does not depend on React.
  • The pre-render script is framework-specific. A Vue or Svelte theme would use the equivalent SSR primitives (renderToString from vue/server-renderer, render from svelte/server, etc.).

Next Steps

Sources

  • Reference pre-render script: themes/default/scripts/prerender.mjs
  • Client entry: themes/default/src/main.tsx
  • SSR entry: themes/default/src/entry-server.tsx
  • InitialDataContext: themes/default/src/context/InitialDataContext.tsx