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 initandecomiq 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 --version0.2.6| Command | What it does |
|---|---|
ecomiq login [store] | Signs in and chooses the store in the browser. |
ecomiq logout | Signs out on this machine and in Ecomiq. |
ecomiq init [folder] | Downloads the store's code to a new folder. |
ecomiq dev | Runs the store locally with its real data. |
ecomiq pack [file] | Builds the theme and compresses it into a ZIP. |
ecomiq publish | Builds and uploads a preview. |
ecomiq publish --live | Builds, uploads, and publishes live. |
ecomiq --version | Shows 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-tiendaThe 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-tiendaThe 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 ~/ 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/ 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 devinit 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| File | What it is | You edit it |
|---|---|---|
app/ | Layout, header, footer, and root loader. | Yes |
app/ | Route map. | Yes, with rules |
app/ | Pages: they load data and compose sections. | Yes |
app/ | Sections with editable settings. | Yes |
app/ | shadcn components. | Yes |
app/ | Theme colors, radius, and fonts. | Yes |
public/ | Theme images. | Yes |
package.json | Dependencies. | Only the allowed ones |
vite.config.ts, react-router.config.ts, workers/, wrangler.jsonc | Platform files. | No |
Use ~/ to import from app/, as in ~/.
Test locally
ecomiq dev✓ Tienda mi-tienda
➜ Local: http://localhost:5173/Open http:/. 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/. It exports settings
with the text, images, and products the merchant can change in the Ecomiq
editor.
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>
);
}| Setting | Stores |
|---|---|
setting.text | A text. |
setting.image | An image path, such as /. |
setting.link | A link. Use it with StorefrontLink. |
setting.collection | A collection slug. |
setting.category | A category slug. |
setting.product | A product slug. |
setting.products | A list of product slugs. |
setting.number | A number. |
setting.toggle | Yes 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.
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/ 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:
pickProductsfinds the collection by its slug. -
When you need a collection and something about its products, request it in the same read:
| You need | Use |
|---|---|
| Collections with a cover photo | client.getCollectionCovers({ limit }) and withImages |
| The first collection, or the remaining ones, with their products | client.getCollectionsWithProducts({ limit, products, sort }) |
| Products with their reviews | pickProductsWithReviews(client, source, { limit, sortBy, sortDirection }) |
| Marketplace sellers | client.getSettings() and client.getSellers() together |
The cart uses loadCart and cartAction on /:
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 /:
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>
);
}intent | Fields | What it does |
|---|---|---|
add | variantId or product, quantity, next | Adds the variant. With next=/, goes to checkout. |
buy | variantId or product, quantity | Adds and goes to checkout. |
quick | variantId or product | Adds one unit. |
update | itemId, quantity | Changes the quantity. |
remove | itemId | Removes 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/. The Ecomiq editor
changes these values without touching your components.
: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/. That way the editor can change them too.
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 likebg-[#ff0000]doesn't change when the merchant edits the style. - Design for mobile first and add
md:for desktop. - Save images in
public/, as webp and under 1 MB. Reference them asimages/ /and give themimages/ <file> alt.
Publish a preview
ecomiq publishThe 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 --liveSubiendo…
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 ~/. 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,.envfiles, macOS metadata, and the platform files. - The ZIP must contain
package.json,app/, androot.tsx app/, and follow the theme rules.routes.ts - 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.
| Route | File | SDK |
|---|---|---|
/ | routes/ | store, pickProducts |
/ | routes/ | readCatalog |
/ | routes/ | loadProduct, canonicalUrl, requireCanonical |
/ | routes/ | found, readCatalog |
/ | routes/ | loadPage |
/ | routes/ | loadCart, cartAction |
/ | routes/ | loadCheckout, checkoutAction |
/ | routes/ | loadOrderStatus, orderPaymentAction |
/ | routes/ | loadOrderDraft, orderDraftAction |
/ | routes/ | loadTracking |
/ | routes/ | loadReview, reviewAction |
/ | routes/ | startCustomerLogin, submitCustomerLogin |
/ | routes/ | loadCustomerRegistration, submitCustomerRegistration |
/ | routes/ | completeCustomerLogin |
/ | routes/ | loadAccount |
/ | routes/ | loadAddresses, addressAction |
/ | routes/ | loadProfile, profileAction |
* | routes/ | resolveUrl |
Emails and payment providers link to /, /, /, and
/. Keep those routes.
| Type | Names |
|---|---|
| Hooks | useCheckout, useFacets, useSort, usePagination, useSearchSuggestions, useGeoCascade, useOrderPayment |
| Components | StorefrontLink, StorefrontAnchor, CustomerLoginForm, CustomerRegistrationForm, CustomerCsrfField, PaymentLogo, OrderPaymentContinuation |
| Utilities | money, discountPercent, pickVariant, isVariantAvailable, variantOptions, variantSwatches |
| Client | store(context): getProducts, getProductBySlug, getCollections, getCollectionBySlug, getSettings |
The loader in app/ 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.
| Rule | Detail |
|---|---|
| Dependencies | Only react, react-dom, react-router, @ecomiq/, @ecomiq/, radix-ui, @radix-ui/, @radix-ui/, @radix-ui/, class-variance-authority, clsx, tailwind-merge, tw-animate-css, embla-carousel-react, sonner, vaul, zod, isbot, and the icon packages lucide-react, @phosphor-icons/, @heroicons/, @remixicon/, iconoir-react, @mdi/, and @mdi/. |
| Platform files | Do not edit vite.config.ts, react-router.config.ts, workers/, or wrangler.jsonc: the editor uses its own. |
| Other configuration | Do not add postcss.config.*, tailwind.config.*, babel.config.*, .babelrc, .postcssrc, or vitest.config.* at the root. |
app/ | Only index, route, layout, and prefix from @react-router/, with literal values. |
| CSS | No @plugin or @config. |
| Files | Up to 1 MB each and 2,000 in total. |
| Forms | Every 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.
| Message | What 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
- Storefront API: every store read and write.
- Headless store: the same purchase flow in your own frontend.
- Skills for agents: install the themes skill in your coding agent.
Last updated: