Skip to content
Go back

Building a Birthday Discount App for Shopify POS with Modern UI Extensions

Building custom features for Shopify POS used to require complex workarounds or third-party integrations. With the latest POS UI Extensions API (2026-01), you can now create native POS experiences that integrate seamlessly with the point-of-sale workflow.

In this tutorial, we’ll build a Birthday Discount app that automatically detects when a customer’s birthday is in the current month and allows staff to apply a special 10% discount — all without leaving the POS interface. This project demonstrates real-time cart monitoring, GraphQL API integration, and multi-target extension architecture.

Watch the full tutorial

Full video tutorial: Building a Birthday Discount App for Shopify POS

What we’re building

The app has three main components:

  1. Smart Grid Tile — A tile on the POS home screen that shows birthday eligibility in real-time
  2. Discount Modal — An interactive UI for checking eligibility and applying the 10% discount
  3. Customer Details Block — A read-only display of birth date information on the customer profile page

Each component uses a different extension target, demonstrating how POS extensions can integrate throughout the entire POS experience.

Tech stack

1. Extension targets: where your UI lives

Unlike web apps that run in a browser, POS extensions target specific locations in the Shopify POS interface. Each target provides different APIs and capabilities.

Our app uses three targets:

# shopify.extension.toml
api_version = "2026-01"

[[extensions]]
type = "ui_extension"
name = "Birthday Discount"
handle = "birthday-discount"

[extensions.capabilities]
api_access = true  # Enable direct API access

# Smart grid tile
[[extensions.targeting]]
module = "./src/Tile.jsx"
target = "pos.home.tile.render"

# Discount modal
[[extensions.targeting]]
module = "./src/Modal.jsx"
target = "pos.home.modal.render"

# Customer details block
[[extensions.targeting]]
module = "./src/CustomerBlock.jsx"
target = "pos.customer-details.block.render"

The key insight: one extension can render in multiple locations, each with its own module file but sharing utilities and business logic.

2. Real-time cart monitoring with subscriptions

The Smart Grid Tile needs to show birthday eligibility as soon as a customer is added to the cart. This requires subscribing to cart updates:

// src/Tile.jsx
import { useState, useEffect } from 'preact/hooks';
import { getCustomerBirthDate, isBirthdayMonth } from './utils';

function Extension() {
  const [eligible, setEligible] = useState(false);
  const [customerName, setCustomerName] = useState('');

  useEffect(() => {
    const unsubscribe = shopify.cart.current.subscribe(async (cart) => {
      if (cart.customer) {
        const customerData = await getCustomerBirthDate(cart.customer.id);

        if (customerData) {
          setEligible(isBirthdayMonth(customerData.birthDate));
          setCustomerName(`${customerData.firstName} ${customerData.lastName}`);
        }
      } else {
        setEligible(false);
        setCustomerName('');
      }
    });

    return unsubscribe;
  }, []);

  return (
    <s-tile
      heading="Birthday Discount"
      subheading={eligible ? `✅ ${customerName} Eligible!` : 'Apply special discount'}
      itemCount={eligible ? 1 : undefined}
      onClick={() => shopify.action.presentModal()}
    />
  );
}

Key features:

This reactive pattern ensures the tile always reflects the current cart state without manual polling.

3. Direct GraphQL API access from extensions

One of the most powerful features of the 2026-01 API is direct GraphQL access from POS extensions. No need for a separate backend — query Shopify’s Admin API directly:

// src/utils.js
export async function getCustomerBirthDate(customerId) {
  const query = `
    query getCustomer($id: ID!) {
      customer(id: $id) {
        id
        firstName
        lastName
        metafield(namespace: "facts", key: "birth_date") {
          value
        }
      }
    }
  `;

  try {
    const response = await fetch('shopify:admin/api/graphql.json', {
      method: 'POST',
      body: JSON.stringify({
        query,
        variables: { id: `gid://shopify/Customer/${customerId}` }
      })
    });

    const data = await response.json();
    const customer = data?.data?.customer;

    return {
      birthDate: customer?.metafield?.value || null,
      firstName: customer?.firstName || '',
      lastName: customer?.lastName || ''
    };
  } catch (error) {
    return { birthDate: null, firstName: '', lastName: '' };
  }
}

Important details:

This eliminates the need for a separate backend server just to query customer data.

4. Adding customer birth dates with GraphQL mutations

Before customers can be eligible for discounts, we need to store their birth dates. Shopify doesn’t have a built-in birth date field, so we use custom metafields.

You can add birth dates via the Shopify Admin GraphiQL interface:

mutation {
  customerUpdate(
    input: {
      id: "gid://shopify/Customer/9964689916218"
      metafields: [
        {
          namespace: "facts"
          key: "birth_date"
          value: "1990-02-15"
          type: "date"
        }
      ]
    }
  ) {
    customer {
      id
      firstName
      lastName
      metafield(namespace: "facts", key: "birth_date") {
        value
      }
    }
    userErrors {
      field
      message
    }
  }
}

The facts.birth_date metafield is stored in ISO 8601 format (YYYY-MM-DD), making it easy to parse and compare dates in JavaScript.

For production, merchants can bulk-import birth dates using CSV or integrate with their CRM to sync customer data automatically.

5. Applying discounts programmatically

When a customer is eligible, staff can apply the discount with one tap. The POS Cart API makes this straightforward:

// src/Modal.jsx
const applyDiscount = async () => {
  setApplying(true);

  try {
    await shopify.cart.applyCartDiscount(
      'Percentage',              // type: Percentage, FixedAmount, or Code
      '🎉 Birthday Discount',   // title shown in cart
      '10'                       // amount: "10" = 10%
    );

    shopify.toast.show('Birthday discount applied! 🎉', { duration: 3000 });

    setTimeout(() => {
      window.close();
    }, 500);
  } catch (error) {
    shopify.toast.show('Failed to apply discount. Please try again.', { duration: 3000 });
  } finally {
    setApplying(false);
  }
};

Features:

The discount appears immediately in the cart, and staff can proceed with checkout.

6. Handling protected customer data permissions

Customer data in Shopify is protected, requiring special permissions. For POS extensions, you must:

  1. Declare scopes in shopify.app.toml:
[access_scopes]
scopes = "read_customers,write_customers"
  1. Deploy the app:
npm run shopify app deploy
  1. Install via Partner Dashboard:

Get an install link from your Partner Dashboard (Apps → [Your App] → “Get install link”) and use that URL to install the app on your store. This properly registers the scopes.

Simply running npm run shopify app dev won’t grant the necessary permissions — deployment and proper installation are required for customer data access.

7. Extension context: customer details block

The customer details block uses a different API surface. Instead of monitoring the cart, it accesses the current customer directly:

// src/CustomerBlock.jsx
useEffect(() => {
  const loadCustomerData = async () => {
    // Direct access to customer ID
    const customerId = shopify.customer?.id;

    if (customerId) {
      const data = await getCustomerBirthDate(customerId);
      setCustomerData(data);

      if (data?.birthDate) {
        setEligible(isBirthdayMonth(data.birthDate));
      }
    }
  };

  loadCustomerData();
}, []);

Each extension target provides different APIs:

Understanding which APIs are available for each target is key to building effective POS extensions.

8. POS web components: building the UI

POS extensions use custom web components prefixed with s-. These provide a native look and feel:

return (
  <s-page>
    <s-scroll-box>
      <s-stack direction="block" gap="base" padding="base">
        <s-heading>Birthday Discount</s-heading>
      </s-stack>

      {loading && (
        <s-stack direction="block" gap="base" padding="base">
          <s-text>Loading customer information...</s-text>
        </s-stack>
      )}

      {eligible && (
        <s-section>
          <s-stack direction="block" gap="base">
            <s-badge tone="success">🎉 Eligible</s-badge>
            <s-heading>Birthday Discount Available!</s-heading>
            <s-text>
              <strong>{customerName}</strong> is celebrating their birthday this month! 🎂
            </s-text>
            <s-button onClick={applyDiscount} disabled={applying}>
              {applying ? 'Applying...' : 'Apply Birthday Discount'}
            </s-button>
          </s-stack>
        </s-section>
      )}
    </s-scroll-box>
  </s-page>
);

Common components:

These components automatically match the POS design system, ensuring a consistent experience across devices.

9. Deployment and testing

Testing POS extensions requires a physical device or the POS web version:

# Start dev server
npm run shopify app dev

# Deploy to production
npm run shopify app deploy

During development:

For production:

Practical benefits of this architecture

For merchants:

For developers:

For the business:

Common pitfalls and how to avoid them

  1. Forgetting to deploy before testing customer data access

    POS extensions require proper installation to register scopes. Always deploy and install via Partner Dashboard before testing customer queries.

  2. Not handling missing birth dates gracefully

    Not all customers will have birth dates. Check for null values and provide helpful messages when data is missing.

  3. Ignoring Preact development mode double-invocations

    Preact (like React) intentionally runs effects twice in development to catch bugs. This is expected — production only runs once.

  4. Using incorrect customer ID format

    Always convert customer IDs to GID format: gid://shopify/Customer/${id} before querying GraphQL.

Taking it further

This birthday discount app is a foundation. You can extend it with:

The core pattern — real-time monitoring + direct API access + multi-target UI — applies to many other POS use cases: loyalty programs, referral tracking, custom pricing rules, and more.

Source code and resources

The complete source code for this project is available on GitHub:

📂 Repository: github.com/webspeaks/shopify-pos-birthday-discount-app

Additional resources:

If you’re building Shopify POS features or exploring the new UI Extensions API, this project provides a practical starting point with real-world patterns for cart monitoring, API access, and multi-surface UI rendering.


Share this post on:

Previous Post
How to Debug Shopify POS App Console Logs on iOS (Using Safari DevTools)
Next Post
Mastering useFormStatus Hook in React: Reusable Submit Buttons