Skip to content
Online store

Themes

Build, test, and publish the online store as code with React Router and the ecomiq CLI.

You download the code of "Mi tienda", add a new-arrivals section, test it locally with the real products, and publish it. The theme is a React Router project with the Ecomiq SDK, and the ecomiq CLI downloads and publishes it. Only ecomiq publish --live changes what your customers see.

Requirements

  • Node.js 22.22 or later.
  • An Ecomiq account with access to the store. If the theme is for someone else's store, ask the owner to add you as a member.
  • The "View pages and menus" permission for ecomiq init and ecomiq dev.
  • The "Publish online store changes" permission for ecomiq publish.
  • The "Edit pages and menus" permission to import a ZIP.

Install the CLI

npm install -g ecomiq
ecomiq --version
0.2.6
CommandWhat it does
ecomiq login [store]Signs in and chooses the store in the browser.
ecomiq logoutSigns out on this machine and in Ecomiq.
ecomiq init [folder]Downloads the store's code to a new folder.
ecomiq devRuns the store locally with its real data.
ecomiq pack [file]Builds the theme and compresses it into a ZIP.
ecomiq publishBuilds and uploads a preview.
ecomiq publish --liveBuilds, uploads, and publishes live.
ecomiq --versionShows the CLI version.

To skip the global install, npm create ecomiq@latest mi-tienda signs in and downloads the code in one go. Then use npx ecomiq instead of ecomiq.

Sign in

ecomiq login mi-tienda

The browser opens. Sign in, choose "Mi tienda", and click Connect. When the page says "Listo", go back to the terminal. The CLI prints its messages in Spanish.

Si el navegador no se abre, entra a:
  https://auth.ecomiq.pe/connect/authorize?client_id=ecomiq-cli&…
✓ Sesión iniciada · tienda mi-tienda

The slug is optional. If you pass it and choose another store in the browser, the CLI signs you out and asks you to try again.

The session is saved in ~/.config/ecomiq/credentials.json and renews on its own. To switch stores, run ecomiq login again.

Set up the project

A theme is a React Router 8 project in framework mode, with React 19, Tailwind CSS 4, and shadcn components. The data comes from the @ecomiq/storefront SDK; the design is yours.

Download the store's code to a new folder and install its dependencies:

ecomiq init mi-tienda
cd mi-tienda
npm install
✓ /Users/you/mi-tienda · 214 archivos
  Sigue con: cd mi-tienda && npm install && npx ecomiq dev

init downloads the code the store's editor has today and adds the platform files. Without a folder, it uses the store's slug. The folder must be empty or not exist.

app/
  root.tsx               layout and root loader
  routes.ts              route map
  routes/                pages
  components/
    sections/            editable sections
    ui/                  shadcn components
  app.css                theme variables and Tailwind
public/
  images/                theme images
package.json
FileWhat it isYou edit it
app/root.tsxLayout, header, footer, and root loader.Yes
app/routes.tsRoute map.Yes, with rules
app/routes/*Pages: they load data and compose sections.Yes
app/components/sections/*Sections with editable settings.Yes
app/components/ui/*shadcn components.Yes
app/app.cssTheme colors, radius, and fonts.Yes
public/images/*Theme images.Yes
package.jsonDependencies.Only the allowed ones
vite.config.ts, react-router.config.ts, workers/app.ts, wrangler.jsoncPlatform files.No

Use ~/ to import from app/, as in ~/components/ui/button.

Test locally

ecomiq dev
✓ Tienda mi-tienda
  ➜  Local:   http://localhost:5173/

Open http://localhost:5173. You see your theme with the store's products, prices, and stock. When you save a file, the page updates.

ecomiq dev saves the store connection in .dev.vars. Do not commit it to your repository. Extra arguments go to react-router dev, for example ecomiq dev --port 3000.

The data is real

A checkout completed locally creates a real order in the store.

Create a section

A section is a component in app/components/sections/. It exports settings with the text, images, and products the merchant can change in the Ecomiq editor.

app/components/sections/new-arrivals.tsx
import type { ProductSummary } from "@ecomiq/core";
import { money, setting, StorefrontLink } from "@ecomiq/storefront";
import { Link } from "react-router";

export const settings = {
  arrivals: {
    title: setting.text("New arrivals", "Title"),
    collection: setting.collection("new-arrivals", "Collection"),
    limit: setting.number(8, "Quantity"),
    link: setting.link("/collections/new-arrivals", "Link"),
  },
};

type Props = { products: ProductSummary[]; currency: string; language: string };

export function NewArrivals({ products, currency, language }: Props) {
  if (products.length === 0) return null;
  return (
    <section className="mx-auto max-w-7xl px-4 py-16 md:px-8">
      <div className="flex items-end justify-between gap-4">
        <h2 className="font-heading text-3xl">{settings.arrivals.title}</h2>
        <StorefrontLink
          to={settings.arrivals.link}
          className="text-sm underline"
        >
          View all
        </StorefrontLink>
      </div>
      <ul className="mt-8 grid grid-cols-2 gap-6 md:grid-cols-4">
        {products.map((product) => (
          <li key={product.id} className="flex flex-col gap-2">
            <img
              src={product.imageUrl}
              alt={product.name}
              className="aspect-[4/5] w-full rounded-lg bg-muted object-cover"
            />
            <Link to={product.url} className="font-medium">
              {product.name}
            </Link>
            <p className="text-sm text-muted-foreground">
              {money(product.price, currency, language)}
            </p>
          </li>
        ))}
      </ul>
    </section>
  );
}
SettingStores
setting.textA text.
setting.imageAn image path, such as /images/hero.webp.
setting.linkA link. Use it with StorefrontLink.
setting.collectionA collection slug.
setting.categoryA category slug.
setting.productA product slug.
setting.productsA list of product slugs.
setting.numberA number.
setting.toggleYes or no.
  • Group settings by section: settings.<group>.<field>.
  • The first argument is the initial value and must be a literal. The second is the label the merchant sees.
  • The editor saves the merchant's changes in that same file.
  • If the section has no required props, the merchant can add it to any page from the editor.

Load the data

Each route loads its data in the loader. store(context) returns the store client. Prices, stock, and products always come from it.

app/routes/home.tsx
import { pickProducts, store } from "@ecomiq/storefront";
import type { Route } from "./+types/home";
import {
  NewArrivals,
  settings as newArrivals,
} from "~/components/sections/new-arrivals";

export async function loader({ context }: Route.LoaderArgs) {
  const client = store(context);
  const { arrivals } = newArrivals;
  const [shop, products] = await Promise.all([
    client.getSettings(),
    pickProducts(client, {
      collection: arrivals.collection,
      limit: arrivals.limit,
    }),
  ]);
  return {
    products,
    currency: shop.store.currency,
    language: shop.store.language,
  };
}

export default function Home({ loaderData }: Route.ComponentProps) {
  return <NewArrivals {...loaderData} />;
}

The loader reads the section settings. If the merchant changes the collection, the page shows other products. pickProducts accepts collection, category, products, limit, and sort.

Request data together

Every read that waits for another one adds a round trip to the API. Starting with @ecomiq/storefront 3.3.0, the SDK sends the reads a loader starts at the same time in a single query, like getSettings and pickProducts in the example.

  • Combine reads that don't depend on each other with Promise.all.

  • If the section has a fallback, request both lists together and choose afterwards:

    const [chosen, bestSelling] = await Promise.all([
      pickProducts(client, { collection: arrivals.collection, limit: 8 }),
      pickProducts(client, { limit: 8, sort: "best_selling" }),
    ]);
    const products = chosen.length > 0 ? chosen : bestSelling;
  • To get a collection's products, don't read the collection list first: pickProducts finds the collection by its slug.

  • When you need a collection and something about its products, request it in the same read:

You needUse
Collections with a cover photoclient.getCollectionCovers({ limit }) and withImages
The first collection, or the remaining ones, with their productsclient.getCollectionsWithProducts({ limit, products, sort })
Products with their reviewspickProductsWithReviews(client, source, { limit, sortBy, sortDirection })
Marketplace sellersclient.getSettings() and client.getSellers() together

The cart uses loadCart and cartAction on /cart:

app/routes/cart.tsx
import { cartAction, loadCart } from "@ecomiq/storefront";
import type { Route } from "./+types/cart";

export async function loader({ request, context }: Route.LoaderArgs) {
  return loadCart(request, context);
}

export async function action({ request, context }: Route.ActionArgs) {
  return cartAction(request, context);
}

Add to the cart with a form that posts to /cart:

app/components/add-to-cart.tsx
import { CustomerCsrfField } from "@ecomiq/storefront";
import { useFetcher } from "react-router";
import { Button } from "~/components/ui/button";

export function AddToCart({ variantId }: { variantId: string }) {
  const fetcher = useFetcher();
  return (
    <fetcher.Form method="post" action="/cart" className="flex gap-3">
      <CustomerCsrfField />
      <input type="hidden" name="intent" value="add" />
      <input type="hidden" name="variantId" value={variantId} />
      <input
        type="number"
        name="quantity"
        defaultValue={1}
        min={1}
        className="w-20 rounded-md border px-3"
      />
      <Button type="submit" disabled={fetcher.state !== "idle"}>
        Add to cart
      </Button>
    </fetcher.Form>
  );
}
intentFieldsWhat it does
addvariantId or product, quantity, nextAdds the variant. With next=/checkout, goes to checkout.
buyvariantId or product, quantityAdds and goes to checkout.
quickvariantId or productAdds one unit.
updateitemId, quantityChanges the quantity.
removeitemIdRemoves the item.

product is the slug: the SDK picks the first available variant. The maximum quantity is 999. On failure, the action returns ok: false and a message to display.

Define the style

Colors, radius, and fonts live in :root of app/app.css. The Ecomiq editor changes these values without touching your components.

app/app.css
:root {
  --radius: 0.25rem;
  --background: #f6f6f5;
  --foreground: #1c1c1b;
  --card: #ffffff;
  --card-foreground: #1c1c1b;
  --popover: #ffffff;
  --popover-foreground: #1c1c1b;
  --primary: #1f1f1e;
  --primary-foreground: #fafafa;
  --secondary: #e8e8e6;
  --secondary-foreground: #1c1c1b;
  --muted: #efefed;
  --muted-foreground: #6b6b69;
  --accent: #e4e4e2;
  --accent-foreground: #1c1c1b;
  --destructive: #b3432f;
  --border: #e0e0dd;
  --input: #d4d4d1;
  --ring: #1f1f1e;
  --heading: "Playfair Display", ui-serif, Georgia, serif;
  --body: "Outfit", ui-sans-serif, system-ui, sans-serif;
}

The @theme inline block in the same file turns them into classes: bg-primary, text-muted-foreground, border-border, rounded-lg, and font-heading.

Load the --heading and --body fonts from Google Fonts in the links of app/root.tsx. That way the editor can change them too.

app/root.tsx
export const links: Route.LinksFunction = () => [
  { rel: "preconnect", href: "https://fonts.googleapis.com" },
  {
    rel: "preconnect",
    href: "https://fonts.gstatic.com",
    crossOrigin: "anonymous",
  },
  {
    rel: "stylesheet",
    href: "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..800&family=Outfit:wght@400..800&display=swap",
  },
];
  • Use the theme variable classes, such as bg-primary. A fixed color like bg-[#ff0000] doesn't change when the merchant edits the style.
  • Design for mobile first and add md: for desktop.
  • Save images in public/images/, as webp and under 1 MB. Reference them as /images/<file> and give them alt.

Publish a preview

ecomiq publish

The CLI builds the theme on your machine, uploads the build and the code, and waits until the version is ready. If the build fails, nothing is uploaded.

Subiendo…
Desplegando…
✓ Vista previa lista: https://mi-tienda.ecomiq.app/?preview=q7m2vk4xh5c3n6p3r2t4w5y7za
  • When you open the link, your browser sees that version for one hour.
  • Your customers keep seeing the live version.
  • The preview uses the store's real data.
  • To go back to the live version, open the store with ?preview=off.

publish does not upload node_modules, build, .dev.vars, .env files, or hidden files at the root, such as .git or .npmrc; of those it only uploads .gitignore. It does not upload the build's .map files either. The build can be up to 64 MB compressed and the code up to 32 MB.

Make it live

ecomiq publish --live
Subiendo…
Desplegando…
✓ En vivo en mi-tienda

--live builds and uploads again. To make a preview you already uploaded live, or to go back to an earlier version, open Online store → Customize store → History → Published and choose Make live.

The editor has its own draft

Publishing with the CLI does not change the Ecomiq editor's draft. If someone publishes from the editor, it replaces your version. To have the editor start from your code, choose Restore code on that version.

Import the theme as a ZIP

To install the theme in a store without publishing it with the CLI, package it:

ecomiq pack
✓ /Users/you/themes/mi-tienda.zip · 131 archivos
  Impórtalo en Tienda online → Personalizar tienda → Tema → Cambiar → Importar ZIP.

pack builds the theme and, if the build passes, saves <folder>.zip next to the project folder. To choose another destination, pass it as an argument: ecomiq pack ~/Desktop/my-theme.zip. It does not need a session. The ZIP leaves out the same files as publish.

In Ecomiq, open Online store → Customize store → Theme → Change, click Import ZIP, choose the file, and confirm. The editor replaces the store's code with the ZIP's, and you can undo it from History. Your customers keep seeing the live version until you publish.

  • A ZIP of the theme folder compressed by hand also works. The editor ignores node_modules, build, .dev.vars, .env files, macOS metadata, and the platform files.
  • The ZIP must contain package.json, app/root.tsx, and app/routes.ts, and follow the theme rules.
  • An imported theme is your own code: it does not receive the gallery theme's updates.

Routes and SDK

Each theme route calls the SDK. You can change the design of any page; keep its loader and action.

RouteFileSDK
/routes/home.tsxstore, pickProducts
/productsroutes/products.tsxreadCatalog
/products/:handleroutes/product.tsxloadProduct, canonicalUrl, requireCanonical
/collections/:handleroutes/collection.tsxfound, readCatalog
/pages/:handleroutes/page.tsxloadPage
/cartroutes/cart.tsxloadCart, cartAction
/checkoutroutes/checkout.tsxloadCheckout, checkoutAction
/orders/:tokenroutes/order-status.tsxloadOrderStatus, orderPaymentAction
/drafts/:tokenroutes/draft.tsxloadOrderDraft, orderDraftAction
/track/:code?routes/track.tsxloadTracking
/review/:tokenroutes/review.tsxloadReview, reviewAction
/account/loginroutes/account/login.tsxstartCustomerLogin, submitCustomerLogin
/account/registerroutes/account/register.tsxloadCustomerRegistration, submitCustomerRegistration
/account/callbackroutes/account/callback.tscompleteCustomerLogin
/accountroutes/account/layout.tsxloadAccount
/account/addressesroutes/account/addresses.tsxloadAddresses, addressAction
/account/profileroutes/account/profile.tsxloadProfile, profileAction
*routes/resolve.tsxresolveUrl

Emails and payment providers link to /orders, /drafts, /track, and /review. Keep those routes.

TypeNames
HooksuseCheckout, useFacets, useSort, usePagination, useSearchSuggestions, useGeoCascade, useOrderPayment
ComponentsStorefrontLink, StorefrontAnchor, CustomerLoginForm, CustomerRegistrationForm, CustomerCsrfField, PaymentLogo, OrderPaymentContinuation
Utilitiesmoney, discountPercent, pickVariant, isVariantAvailable, variantOptions, variantSwatches
Clientstore(context): getProducts, getProductBySlug, getCollections, getCollectionBySlug, getSettings

The loader in app/root.tsx returns storefrontLinks and customerCsrfToken. StorefrontLink and CustomerCsrfField read them from there.

Theme rules

The Ecomiq editor opens and publishes your code with these rules. If you break them, ecomiq publish may still work, but the editor will not be able to open your code.

RuleDetail
DependenciesOnly react, react-dom, react-router, @ecomiq/storefront, @ecomiq/core, radix-ui, @radix-ui/react-dialog, @radix-ui/react-label, @radix-ui/react-slot, class-variance-authority, clsx, tailwind-merge, tw-animate-css, embla-carousel-react, sonner, vaul, zod, isbot, and the icon packages lucide-react, @phosphor-icons/react, @heroicons/react, @remixicon/react, iconoir-react, @mdi/react, and @mdi/js.
Platform filesDo not edit vite.config.ts, react-router.config.ts, workers/app.ts, or wrangler.jsonc: the editor uses its own.
Other configurationDo not add postcss.config.*, tailwind.config.*, babel.config.*, .babelrc, .postcssrc, or vitest.config.* at the root.
app/routes.tsOnly index, route, layout, and prefix from @react-router/dev/routes, with literal values.
CSSNo @plugin or @config.
FilesUp to 1 MB each and 2,000 in total.
FormsEvery post form includes <CustomerCsrfField />. Without it, the SDK rejects submissions from a signed-in customer.

Common errors

The CLI shows errors with ✗ and exits with code 1. Messages are in Spanish.

MessageWhat to do
No has iniciado sesión. Ejecuta `ecomiq login`.Sign in.
La sesión expiró. Ejecuta `ecomiq login`.Sign in again.
Ejecuta este comando dentro de tu proyecto (falta react-router.config.ts).Go to the theme folder.
La carpeta … no está vacía.Use a new folder with ecomiq init.
La tienda … todavía no tiene tema. Elige uno en Ecomiq.Choose a theme in Online store → Customize store.
Instala las dependencias del proyecto (npm install).Run npm install.
Autorizaste la tienda …, no ….Sign in again and choose the right store.
No tienes permiso para esta operación.Ask for the missing permission. See Requirements.
build terminó con código 1.Fix the build error shown above.
El build excede el tamaño permitido.Reduce images and dependencies.
El despliegue tardó demasiado. Revisa su estado en Ecomiq.Check History → Published in the editor.

Next steps

Last updated:

On this page