POS UI Extensions
Shopify Point of Sale (POS) is the in-store counterpart to the online storefront. POS UI extensions let you embed custom functionality directly into the POS app that store associates use on iPads and iPhones during in-person sales. This opens a powerful channel for apps that bridge online and offline commerce -- loyalty programs, clienteling tools, inventory lookup, and custom workflows all become accessible at the point of sale.
POS UI extensions require Shopify POS Pro, which is included with Shopify Plus or available as an add-on. The POS app runs on iOS devices (iPad and iPhone). Extensions are rendered using Shopify's remote UI framework, similar to checkout and admin extensions.
POS Extension Targets
POS extensions can render at several locations within the POS app:
| Target | Location | Description |
|---|---|---|
pos.home.tile.render | Smart Grid | A tile on the POS home screen |
pos.home.modal.render | Full-screen modal | Modal opened from a Smart Grid tile |
pos.product-details.action.render | Product detail action | Action on the product detail view |
pos.product-details.block.render | Product detail block | Embedded block on product details |
pos.order-details.action.render | Order detail action | Action on the order detail view |
pos.order-details.block.render | Order detail block | Embedded block on order details |
pos.customer-details.action.render | Customer detail action | Action on customer profiles |
pos.customer-details.block.render | Customer detail block | Embedded block on customer profile |
pos.checkout.action.render | Cart action | Action during the checkout flow |
pos.purchase.post.action.render | Post-purchase | Action after sale is completed |
Creating a POS Extension
# Generate a POS UI extension
shopify app generate extension --template pos_ui --name loyalty-pos
Extension Configuration
# extensions/loyalty-pos/shopify.extension.toml
api_version = "2025-01"
[[extensions]]
type = "pos_ui_extension"
name = "Loyalty Program"
handle = "loyalty-pos"
description = "Look up and manage customer loyalty points at the point of sale"
[[extensions.targeting]]
target = "pos.home.tile.render"
module = "./src/SmartGridTile.tsx"
[[extensions.targeting]]
target = "pos.home.modal.render"
module = "./src/LoyaltyModal.tsx"
[[extensions.targeting]]
target = "pos.customer-details.block.render"
module = "./src/CustomerLoyaltyBlock.tsx"
[[extensions.targeting]]
target = "pos.purchase.post.action.render"
module = "./src/PostPurchasePoints.tsx"
Smart Grid Tiles
The Smart Grid is the POS home screen -- a grid of tiles that store associates use to quickly access frequently used functions. Your app can add tiles to this grid.
// extensions/loyalty-pos/src/SmartGridTile.tsx
import {
reactExtension,
Tile,
useApi,
Text,
Icon,
} from '@shopify/ui-extensions-react/point-of-sale';
const smartGridTile = reactExtension(
'pos.home.tile.render',
() => <LoyaltyTile />
);
export default smartGridTile;
function LoyaltyTile() {
const api = useApi<'pos.home.tile.render'>();
return (
<Tile
title="Loyalty"
subtitle="Look up points"
onPress={() => {
// Opens the modal registered for pos.home.modal.render
api.smartGrid.presentModal();
}}
enabled={true}
/>
);
}
Keep tile labels short -- store associates glance at them quickly between customer interactions. Use a clear, single-word title and a brief subtitle. The tile should communicate its purpose at a glance. Avoid jargon or technical terms that staff may not understand.
POS Modal Extensions
When a store associate taps your Smart Grid tile, a full-screen modal opens. This is where your main POS functionality lives.
// extensions/loyalty-pos/src/LoyaltyModal.tsx
import { useState, useCallback } from 'react';
import {
reactExtension,
Screen,
ScrollView,
Navigator,
SearchBar,
Section,
Row,
Text,
Button,
Banner,
useApi,
} from '@shopify/ui-extensions-react/point-of-sale';
const loyaltyModal = reactExtension(
'pos.home.modal.render',
() => <LoyaltyModal />
);
export default loyaltyModal;
function LoyaltyModal() {
return (
<Navigator>
<Screen name="CustomerSearch" title="Loyalty Lookup">
<CustomerSearchScreen />
</Screen>
<Screen name="CustomerDetail" title="Loyalty Details">
<CustomerDetailScreen />
</Screen>
<Screen name="RedeemPoints" title="Redeem Points">
<RedeemPointsScreen />
</Screen>
</Navigator>
);
}
function CustomerSearchScreen() {
const api = useApi<'pos.home.modal.render'>();
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<any[]>([]);
const [searching, setSearching] = useState(false);
const handleSearch = useCallback(async (query: string) => {
setSearchQuery(query);
if (query.length < 2) {
setResults([]);
return;
}
setSearching(true);
try {
const response = await fetch(
`/api/loyalty/search?q=${encodeURIComponent(query)}`
);
const data = await response.json();
setResults(data.customers);
} catch (err) {
console.error('Search failed:', err);
} finally {
setSearching(false);
}
}, []);
return (
<ScrollView>
<SearchBar
placeholder="Search by name, email, or phone..."
value={searchQuery}
onTextChange={handleSearch}
onSearch={handleSearch}
/>
{results.length > 0 && (
<Section title={`${results.length} members found`}>
{results.map((customer) => (
<Row
key={customer.id}
title={customer.name}
subtitle={`${customer.pointsBalance.toLocaleString()} points | ${customer.tier} tier`}
onPress={() => {
api.navigator.navigate('CustomerDetail', {
customerId: customer.id,
});
}}
/>
))}
</Section>
)}
{searchQuery.length >= 2 && results.length === 0 && !searching && (
<Banner
title="No members found"
variant="information"
action="Enroll New Member"
onPress={() => {
// Navigate to enrollment screen
}}
/>
)}
</ScrollView>
);
}
function CustomerDetailScreen() {
const api = useApi<'pos.home.modal.render'>();
const [customer, setCustomer] = useState<any>(null);
// In a real implementation, fetch customer data based on navigation params
return (
<ScrollView>
<Section title="Loyalty Status">
<Row
title="Points Balance"
rightSide={
<Text variant="headingLarge">
{customer?.pointsBalance?.toLocaleString() ?? '---'}
</Text>
}
/>
<Row
title="Tier"
rightSide={<Text>{customer?.tier ?? '---'}</Text>}
/>
<Row
title="Member Since"
rightSide={
<Text>
{customer?.enrolledAt
? new Date(customer.enrolledAt).toLocaleDateString()
: '---'}
</Text>
}
/>
</Section>
<Section title="Recent Activity">
{(customer?.recentActivity ?? []).map((activity: any) => (
<Row
key={activity.id}
title={activity.description}
subtitle={new Date(activity.date).toLocaleDateString()}
rightSide={
<Text tone={activity.type === 'earned' ? 'success' : 'warning'}>
{activity.type === 'earned' ? '+' : '-'}
{activity.points}
</Text>
}
/>
))}
</Section>
<Button
title="Redeem Points"
type="primary"
onPress={() => {
api.navigator.navigate('RedeemPoints', {
customerId: customer?.id,
});
}}
/>
</ScrollView>
);
}
function RedeemPointsScreen() {
const api = useApi<'pos.home.modal.render'>();
const [pointsToRedeem, setPointsToRedeem] = useState(0);
const [processing, setProcessing] = useState(false);
async function handleRedeem() {
setProcessing(true);
try {
const response = await fetch('/api/loyalty/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// customerId from navigation params
points: pointsToRedeem,
}),
});
if (response.ok) {
const result = await response.json();
// Apply discount to current cart
await api.cart.applyCartDiscount(
'fixedAmount',
result.dollarValue.toString(),
'Loyalty Points Redemption'
);
api.navigator.dismiss();
}
} catch (err) {
console.error('Redemption failed:', err);
} finally {
setProcessing(false);
}
}
return (
<ScrollView>
<Section title="Redeem Points">
<Row title="Points to redeem" subtitle="100 points = $1.00" />
{/* Point selection UI */}
<Button
title={processing ? 'Processing...' : 'Apply to Cart'}
type="primary"
onPress={handleRedeem}
disabled={processing || pointsToRedeem === 0}
/>
</Section>
</ScrollView>
);
}
Retail environments often have unreliable network connections. POS extensions must handle offline scenarios gracefully. Cache critical data locally, queue actions that require network access, and always show clear feedback when the network is unavailable. A POS extension that freezes on a network timeout will disrupt the checkout line.
Hardware Integration
POS extensions can interact with connected hardware through the POS Hardware API.
Receipt Printers
// Print a custom loyalty receipt
async function printLoyaltyReceipt(api: any, customer: any) {
const receiptContent = {
body: [
{ type: 'text', content: 'LOYALTY PROGRAM RECEIPT', bold: true, align: 'center' },
{ type: 'line' },
{ type: 'text', content: `Member: ${customer.name}` },
{ type: 'text', content: `Tier: ${customer.tier}` },
{ type: 'text', content: `Points Balance: ${customer.pointsBalance.toLocaleString()}` },
{ type: 'line' },
{ type: 'text', content: `Points Earned Today: +${customer.pointsEarnedToday}` },
{ type: 'text', content: `New Balance: ${customer.newBalance.toLocaleString()}` },
{ type: 'line' },
{
type: 'barcode',
content: customer.loyaltyBarcode,
format: 'CODE128',
},
{ type: 'text', content: 'Scan to check your balance online', align: 'center', size: 'small' },
],
};
try {
await api.hardware.printer.print(receiptContent);
} catch (err) {
console.error('Failed to print receipt:', err);
// Fall back to showing the info on screen
}
}
Barcode Scanners
// Listen for barcode scans to look up loyalty members
function useBarcodeScanner(onScan: (barcode: string) => void) {
const api = useApi<'pos.home.modal.render'>();
useEffect(() => {
const unsubscribe = api.hardware.scanner.subscribe(
(event: { data: string; type: string }) => {
// Filter for loyalty card barcodes (they start with "LYL-")
if (event.data.startsWith('LYL-')) {
onScan(event.data);
}
}
);
return () => unsubscribe();
}, [api, onScan]);
}
// Usage in a component
function ScannerEnabledSearch() {
const [customer, setCustomer] = useState(null);
useBarcodeScanner(async (barcode) => {
const response = await fetch(
`/api/loyalty/lookup?barcode=${encodeURIComponent(barcode)}`
);
const data = await response.json();
setCustomer(data.customer);
});
return (
<ScrollView>
<Banner
title="Scan a loyalty card"
variant="information"
/>
{customer && (
<Section title="Member Found">
<Row title={customer.name} subtitle={`${customer.pointsBalance} points`} />
</Section>
)}
</ScrollView>
);
}
Hardware devices can be disconnected, out of paper, or malfunctioning. Never assume hardware operations will succeed. Always wrap hardware calls in try/catch blocks and provide a fallback (like showing information on screen instead of printing). The POS should never block a sale because of a hardware failure in your extension.
Building POS Extensions with Dev MCP
Shopify's Dev MCP (Model Context Protocol) server can accelerate POS extension development by generating boilerplate code, suggesting extension targets, and helping debug issues.
# Use Claude Code with Shopify Dev MCP to scaffold a POS extension
# The MCP server understands POS-specific patterns
# Example prompt to Claude Code with Dev MCP:
# "Create a POS extension that shows customer purchase history
# when a store associate views a customer profile"
The Dev MCP server provides context about:
- Available POS extension targets and their APIs
- POS-specific UI components and their props
- Hardware API capabilities and limitations
- Best practices for retail workflows
Testing POS Extensions
POS extensions cannot be tested in a browser. You must use the Shopify POS app on a physical iOS device or the POS development simulator.
# Start development server for POS extensions
shopify app dev --tunnel
# The CLI will display a QR code to connect the POS app
# Scan it with the Shopify POS app on your iOS device
Testing Checklist
- Test on both iPad and iPhone -- Screen sizes differ significantly
- Test with actual hardware -- Receipt printers and scanners behave differently than mocks
- Test offline scenarios -- Disconnect Wi-Fi and verify graceful degradation
- Test during active sales -- Extensions should not slow down the checkout flow
- Test with multiple staff members -- Verify session isolation between POS users
- Test the dismissal flow -- Ensure modals close cleanly and do not leave stale state
Continue to Web Pixel Extensions to learn how to implement privacy-compliant customer event tracking and analytics for Shopify storefronts.