DEV Community

Cover image for I Built 3 Shopify Cart Features Without a Single App – Just Liquid & JS
Joshua
Joshua

Posted on • Originally published at hafenpixel.de

I Built 3 Shopify Cart Features Without a Single App – Just Liquid & JS

Three Shopify features that usually require three separate apps – a progress bar, an auto-add free gift, and cart recommendations – can be built with custom Liquid and JavaScript in a single solution. No monthly fees, no extra HTTP requests, no performance hit.

I'm a freelance developer from Hamburg, and I recently did exactly this for a Shopify store called Bodenständig. They sell garden supplies for home growers – seeds, fertilizer, edible plants. The store was running Monk for the progress bar and gift logic. The cart recommendations didn't exist before – I built all three features from scratch as a single custom solution, replacing the app in the process. It worked, but it loaded its own script bundle, injected its own CSS, and made extra API calls on every page load. On mobile – where most of their customers shop – it was noticeably slow.

Here's how I replaced it all with code that lives directly in the Dawn theme.

The Problem With Shopify Apps

Every Shopify app loads its own JavaScript, its own CSS, and often makes additional external requests – on every single page view. Three apps for cart features can add hundreds of milliseconds to your page load.

Apps run as an external layer on top of your theme. They have to be generic because they need to work across thousands of different stores. That makes them inherently heavier and less optimized than a custom solution.

And they charge monthly. In this case, about €50/month. That's €600/year. Over three years, €1,800. For code that doesn't belong to the store owner and disappears the moment you unsubscribe.

Feature 1: Progress Bar

The progress bar shows customers in real-time how close they are to two thresholds: free seed packet at €30, free shipping at €60.

The initial state is calculated server-side in Liquid, so the bar renders correctly the instant the cart drawer opens – no flicker, no loading state. JavaScript takes over for subsequent updates.

{% raw %}
{%- assign cart_total_without_gift = 0 -%}
{%- for item in cart.items -%}
  {%- assign is_gift_item = false -%}
  {%- for prop in item.properties -%}
    {%- if prop.first == '_gift' and prop.last == 'true' -%}
      {%- assign is_gift_item = true -%}
    {%- endif -%}
  {%- endfor -%}
  {%- unless is_gift_item -%}
    {%- assign cart_total_without_gift = cart_total_without_gift | plus: item.final_line_price -%}
  {%- endunless -%}
{%- endfor -%}
{%- assign progress_pct = cart_total_without_gift | times: 100 | divided_by: 6000 -%}
{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Important detail: the progress bar calculates the cart value without the gift product. Otherwise, adding the free item would affect the bar – a common bug in app-based solutions.

The text updates dynamically based on the current total:

  • Under €30: "Noch €X für ein gratis Saatgut-Tütchen" (€X until a free seed packet)
  • €30–€60: "🎁 Gratis Saatgut gesichert! Noch €X bis gratis Versand" (Free seeds secured! €X until free shipping)
  • €60+: "✅ Gratis Versand & gratis Saatgut gesichert!" (Free shipping & free seeds secured!)

The bar animates smoothly via a CSS transition on the width property. Two markers at the 30€ and 60€ positions turn green when reached.

Feature 2: Auto-Add/Remove Free Gift

When the cart value hits €30, a free seed packet is automatically added. When it drops below €30, it's automatically removed. The customer doesn't have to do anything.

The gift is a regular Shopify product priced at €0 with status "unlisted" – it doesn't appear in the store but is accessible via its variant ID through the API. When adding it, a hidden property _gift: true is attached. The underscore prefix is key: Shopify hides properties starting with _ from the checkout.

await fetch('/cart/add.js', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    items: [{
      id: GIFT_VARIANT_ID,
      quantity: 1,
      properties: { '_gift': 'true' }
    }]
  })
});
Enter fullscreen mode Exit fullscreen mode

This _gift property identifies the gift item everywhere – when calculating the cart total without the gift, when rendering it differently in the cart (green background, "🎁 Geschenk" badge instead of a price), and when removing it.

The gift item looks different from regular products: green background, no price (shows "Kostenlos" instead), no quantity selector, no remove button – just a 🎁 emoji. The customer immediately sees: this is a gift, it belongs there, they can't modify it.

The hardest part was avoiding race conditions. When a customer rapidly adds or removes products, AJAX calls can overlap. The solution:

  • An isUpdating flag that blocks concurrent operations
  • Rate limiting: minimum 3 seconds between gift checks
  • A debounce timer on cart events (800ms)
  • A delay after cart operations before checking gift eligibility (1,500ms)
let isUpdating = false;
let lastGiftCheck = 0;

async function checkGiftEligibility() {
  if (isUpdating) return;

  const timeSinceLastCheck = Date.now() - lastGiftCheck;
  if (timeSinceLastCheck < 3000) return;

  lastGiftCheck = Date.now();

  const response = await fetch('/cart.js');
  const cart = await response.json();
  const totalWithoutGift = getCartTotalWithoutGift(cart);
  const hasGift = !!findGiftItem(cart);

  if (totalWithoutGift >= GIFT_THRESHOLD && !hasGift) {
    await addGift();
  } else if (totalWithoutGift < GIFT_THRESHOLD && hasGift) {
    await removeGift(cart);
  }
}
Enter fullscreen mode Exit fullscreen mode

Without these safeguards, rapid clicking could add the gift twice or remove it during a brief intermediate state.

Feature 3: Cart Recommendations

The cart drawer shows product recommendations below the cart items – powered by Shopify's own Product Recommendations API, not an external app.

const url = `/recommendations/products?section_id=cart-recommendations&product_id=${productId}&limit=10&intent=complementary`;
const response = await fetch(url);
Enter fullscreen mode Exit fullscreen mode

The recommendations are rendered as a separate Shopify Section (cart-recommendations). This means they use Shopify's template engine and don't need an external API. No extra JavaScript bundle, no monthly fee, no third-party requests.

The system iterates through all products in the cart and collects recommendations with the complementary intent from Shopify's Search & Discovery algorithm. Products already in the cart are filtered out.

When a customer clicks "hinzufügen" (add), the product is added via AJAX with a hidden property _source: cart_recommendation – this lets the store owner track in their orders which products came from recommendations.

A MutationObserver on the cart items container detects DOM changes by the Dawn theme and triggers a recommendation reload. Parallel requests are prevented with a loading flag that has a safety reset after 10 seconds in case something gets stuck.

How All Three Features Work Together

This is the key advantage over three separate apps: all features share a single JavaScript block and react to the same cart events.

The flow on any cart change:

  1. Customer adds a product or changes quantity
  2. Dawn fires a cart:updated event
  3. A central handler waits 800ms (debounce), then fetches the current cart via /cart.js
  4. Progress bar updates with the new cart value
  5. After another 1,500ms, the gift check runs
  6. Recommendations reload in parallel

The debounce and staggered timeouts matter. If a customer clicks "plus" three times quickly, you don't want three cart fetches, three gift checks, and three recommendation loads. The system waits until the click sequence is done, then does one clean update pass.

let cartChangeTimer = null;

function handleCartChange() {
  if (cartChangeTimer) clearTimeout(cartChangeTimer);
  cartChangeTimer = setTimeout(async () => {
    await updateFromCart();
    setTimeout(() => checkGiftEligibility(), 1500);
  }, 800);
}

document.addEventListener('cart:updated', handleCartChange);
document.addEventListener('cart:change', handleCartChange);
Enter fullscreen mode Exit fullscreen mode

The Numbers

App Solution Custom Code
Monthly cost ~€50/month €0
Performance impact Extra scripts & requests Zero (theme-integrated)
Customizability Limited to app settings Full control
Code ownership Rental (gone if you cancel) Store owner's property
Development time Instant setup A few days, one-time
3-year cost ~€1,800 One-time dev cost

When Custom Code Beats Apps

Custom code works when the requirement is well-defined, the feature can be solved with Liquid and JavaScript, and the store is a long-term operation.

Custom code wins when:

  • The function is clearly scoped (progress bars, gift logic, cart recommendations are perfect candidates)
  • The app causes performance issues
  • You need a custom look that app settings can't achieve
  • Monthly app costs exceed the one-time development cost within 6–12 months

An app wins when:

  • The feature is complex and needs regular updates (subscription management with dunning, complex bundle pricing)
  • The store owner wants to change settings frequently without a developer
  • The app brings A/B testing or analytics that would be expensive to rebuild
  • The store is brand new and it's unclear which features will stick

The rule of thumb: if an app essentially injects CSS and JavaScript to change a frontend display, it can almost always be built better and cheaper with custom code. If an app brings its own backend with a database and complex business logic, custom code usually doesn't make sense.


I'm Joshua, freelance web developer from Hamburg. I build WordPress plugins, Shopify themes and custom web tools. If you want to see the live implementation, check out Bodenständig's shop. More case studies on hafenpixel.de.

Top comments (0)