Replace the product card
Draw your own card in every product grid by registering it, then overriding product:card, in app/router.tsx.
Goal: every product grid, slider and listing in the store draws your own product card instead of the engine's.
Mechanism: the component registry, a table that pairs a name with a component. Before it draws a card, the engine's ProductCard looks up the name product:card. When a theme has overridden that name, the engine renders the theme's component instead, handing it exactly the props it received (product, layout, index…). You change one file of your own and one line group in app/router.tsx; no engine page is copied.
1. Try it
The same product from the demo store, drawn both ways. Nothing here touches the registry: both cards are rendered directly so you can compare them.
Loading products from the demo store…
And the registry calls themselves, on a playground name: switch to “override only” to watch the override get ignored.
import { registry } from '@salla.sa/twilight-theme-engine';
import { MyProductCard } from './components/MyProductCard';
// app/router.tsx: at module scope, before createRouter()
registry.register('product:card', MyProductCard);
registry.override('product:card', MyProductCard);
// Where the engine renders <ProductCard product={…} /> (grids, sliders, listings),
// MyProductCard receives the same props, but only after both calls.
2. Write the card
A card is a component that receives ProductCardProps. Use the engine's building blocks (Link, Image, useMoney, t()) so links, images, prices and languages behave like the rest of the store.
import { memo } from 'react';
import { Image, Link } from '@salla.sa/twilight-theme-engine/components/common';
import type { ProductCardProps } from '@salla.sa/twilight-theme-engine/components/product';
import { useMoney } from '@salla.sa/twilight-theme-engine/hooks/useMoney';
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';
/**
* The theme's own product card. Registered under `product:card`, it is drawn
* wherever the engine draws a card, with the same props.
*
* It is a full replacement: rendering the engine's ProductCard in here would
* render this card again, forever.
*/
export const MyProductCard = memo(function MyProductCard({
product,
className = '',
imagePriority = false,
}: ProductCardProps) {
const { format } = useMoney();
const { t } = useTranslation();
const onSale = product.is_on_sale && product.sale_price > 0;
return (
<article className={`flex flex-col gap-2 rounded-md border border-gray-200 p-3 ${className}`}>
<Link to={product.url} aria-label={product.name}>
<Image
src={product.image?.url}
alt={product.image?.alt || product.name}
width={400}
height={400}
aspectRatio="1/1"
className="h-full w-full rounded"
priority={imagePriority}
/>
</Link>
<h3 className="text-sm font-bold leading-6">
<Link to={product.url}>{product.name}</Link>
</h3>
<p className="flex flex-wrap items-baseline gap-2">
<span className="font-bold text-primary">
{format(onSale ? product.sale_price : product.price)}
</span>
{onSale && <del className="text-sm text-gray-500">{format(product.regular_price)}</del>}
</p>
{!product.is_available && (
<span className="text-xs text-red-700">
{t('pages.products.out_of_stock', 'Out of stock')}
</span>
)}
</article>
);
});
3. Register, then override
import { registry } from '@salla.sa/twilight-theme-engine';
import { createRouter } from '@salla.sa/twilight-theme-engine/tanstack';
import { routeTree } from './routeTree.gen';
import { MyProductCard } from './components/product/MyProductCard';
// Both calls, in this order, at module scope (before any card can render):
registry.register('product:card', MyProductCard); // 1. something must already be under the name
registry.override('product:card', MyProductCard); // 2. now the name counts as overridden
export function getRouter() {
return createRouter(routeTree);
}
In engine terms
ProductCard (src/components/product/ProductCard.tsx) runs registry.getOriginal('product:card') !== null once per mount, inside useMemo(…, []), and renders registry.resolve('product:card') only when that is true. registry.override(name, component) stores the previous entry as original only when the name already had one; on an empty name it just calls register, which stores no original (src/components/ComponentRegistry.ts). app/router.tsx is evaluated on the server and in the browser before getRouter() runs, so both renders draw the same card and hydration matches.
Traps
Go deeper: registry, ProductCard, the concept Overriding an engine component, and the lesson Swapping a component: the registry. To add something next to the card rather than replace it, a hook slot is simpler.