Plenty of Shopify apps will sell you a product configurator. Most of them do the basics in 200 lines of code, plus a $39/month subscription. If you want full control and you’re comfortable in Liquid + vanilla JS, here’s how to build one yourself.
We’ll build an engraving configurator: customer picks a font, types text, sees a preview, and the engraving details flow through to the order. Same pattern works for jewellery customisation, custom packaging, monogramming, etc.
The data model
We have three pieces of customer-supplied data:
- Engraving text — string, max 20 chars
- Font — choice from
Serif | Sans | Script - Position —
Front | Back
These flow to the order as line item properties — the Shopify-native way to attach metadata to a cart item.
The Liquid section
In your PDP template, add a new section engraving-config.liquid:
<div class="engraving" data-engraving>
<label>
Engraving text
<input type="text" name="properties[Engraving]" maxlength="20" />
</label>
<fieldset>
<legend>Font</legend>
<label><input type="radio" name="properties[Font]" value="Serif" checked /> Serif</label>
<label><input type="radio" name="properties[Font]" value="Sans" /> Sans</label>
<label><input type="radio" name="properties[Font]" value="Script" /> Script</label>
</fieldset>
<fieldset>
<legend>Position</legend>
<label><input type="radio" name="properties[Position]" value="Front" checked /> Front</label>
<label><input type="radio" name="properties[Position]" value="Back" /> Back</label>
</fieldset>
<div class="engraving__preview" data-engraving-preview>
<span data-preview-text></span>
</div>
</div>
The name="properties[X]" syntax is the magic — Shopify reads anything inside properties[*] and attaches it to the cart line item.
The preview
Vanilla JS, no dependencies:
(() => {
const root = document.querySelector('[data-engraving]');
if (!root) return;
const text = root.querySelector('[data-preview-text]');
const input = root.querySelector('input[name="properties[Engraving]"]');
const fontRadios = root.querySelectorAll('input[name="properties[Font]"]');
const sync = () => {
text.textContent = input.value || 'Your text';
const font = root.querySelector('input[name="properties[Font]"]:checked').value;
text.dataset.font = font.toLowerCase();
};
input.addEventListener('input', sync);
fontRadios.forEach(r => r.addEventListener('change', sync));
sync();
})();
Style the preview based on data-font:
.engraving__preview [data-preview-text][data-font="serif"] { font-family: "Fraunces", serif; }
.engraving__preview [data-preview-text][data-font="sans"] { font-family: "Inter", sans-serif; }
.engraving__preview [data-preview-text][data-font="script"] { font-family: "Caveat", cursive; }
Conditional pricing (optional)
If engraving costs $15 extra, you have two options:
Option A — separate “engraving” line item product. When the customer adds the main product, your JS also adds an “Engraving” product to the cart at $15. Pros: simple, works on any plan. Cons: shows as two line items.
Option B — Cart Function (Plus only). A Cart Transform Function detects the engraving property and bumps the line item price.
For most stores, Option A is fine. Customers see “Necklace ($120) + Engraving ($15)” and that’s actually clearer.
Validating before add-to-cart
Don’t trust the form. Validate on the JS side too:
form.addEventListener('submit', (e) => {
const txt = input.value.trim();
if (txt.length > 20) {
e.preventDefault();
alert('Engraving must be 20 characters or fewer.');
}
});
Server-side validation (e.g. via a Shopify Function or webhook) is overkill for engraving. For high-stakes configurators (e.g. monogram colour matching), you’d validate at the order webhook level too.
Showing the configuration in the cart, checkout, and order
Line item properties show automatically:
- In the cart drawer (most themes render
item.propertiesautomatically) - In the checkout summary
- On the order confirmation email
- On the merchant’s order detail page
If your theme doesn’t render properties in the cart, this is the snippet:
{% for property in item.properties %}
{% unless property.last == blank %}
<li>{{ property.first }}: {{ property.last }}</li>
{% endunless %}
{% endfor %}
The unless property.last == blank is important — Shopify creates empty properties for some apps; you don’t want them showing.
When this approach breaks
- Hundreds of options. If your configurator has dozens of variables (think: custom furniture), Liquid + JS gets unwieldy. Consider a real configurator app or a custom React/Hydrogen approach.
- 3D / WebGL preview. If you need to render a real 3D visualisation, you’re past Liquid territory.
- Complex pricing. If engraving cost varies by length, font, position — you want server-side logic, either a Cart Function or a real app.
For 90% of “let the customer customise this product” use cases, this 100-line approach beats every $39/month app.