Building a Post-Purchase Tracking Widget for Shopify
FTC Disclosure: I'm the developer of Parcelglance, the Shopify tracking widget discussed in this article.
After a customer clicks "Buy" on your Shopify store, what happens? Silence. The customer gets an order confirmation email and then... nothing. They check their email obsessively. They contact your support team asking "Where's my order?" They leave negative reviews because delivery took 5 days (even though you said 5-7).
This is the post-purchase experience gap, and it's the number one cause of unnecessary support tickets for Shopify stores.
I built Parcelglance to close this gap. It's a tracking widget that gives customers a beautiful, branded order tracking experience directly on your store — no more "where's my order?" emails.
In this article, I'll share the technical decisions, architecture, and lessons from building this Shopify app.
The Problem Space
Shopify does provide basic order tracking, but it has significant limitations:
- Generic branding — Shopify's tracking page looks like Shopify, not your brand
- Limited carrier support — Only major carriers; no support for regional or international shippers
- No proactive updates — Customers have to check; nobody sends updates
- No upsell opportunity — The tracking page is a dead end; no cross-sell potential
- Poor mobile experience — Shopify's default tracking isn't optimized for mobile
These limitations create a massive opportunity for a better solution.
Architecture Overview
Parcelglance is built as a Shopify Embedded App using three main components:
- Admin App — Shopify-embedded React app for configuration
- Widget Runtime — Lightweight JavaScript injected into the storefront
- Tracking API — Cloudflare Worker backend that aggregates carrier data
Component 1: The Widget Runtime
The widget is the customer-facing part. It needs to be:
- Tiny — Under 15KB gzipped (storefront performance matters)
- Fast — Renders tracking info without blocking page load
- Compatible — Works with any Shopify theme (Online Store 2.0 and vintage)
- Brandable — Merchants can customize colors, fonts, and layout
I chose Preact over React for the widget — 3KB vs 40KB makes a huge difference on storefront load time.
// widget/index.tsx — Main widget component
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
export function TrackingWidget({ orderId, shopDomain, config }) {
const [tracking, setTracking] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchTrackingData(orderId, shopDomain)
.then(setTracking)
.catch(err => setError(err.message))
.finally(() => setLoading(false));
}, [orderId, shopDomain]);
if (loading) return <TrackingSkeleton config={config} />;
if (error) return <TrackingError message={error} config={config} />;
if (!tracking) return null;
return (
<div class="pg-widget" style={applyTheme(config.theme)}>
<TrackingHeader tracking={tracking} config={config} />
<TrackingTimeline events={tracking.events} config={config} />
<TrackingMap tracking={tracking} config={config} />
<TrackingCTA tracking={tracking} config={config} />
</div>
);
}
The widget loads via a Shopify App Block (Online Store 2.0) or a script tag injection for vintage themes. The installation logic detects the theme type automatically:
function installWidget() {
const isOS2 = document.querySelectorAll('shopify-section').length > 0;
if (isOS2) {
registerAppBlock();
} else {
injectLegacy();
}
}
Component 2: The Tracking API
This is the most complex part. I need to aggregate tracking data from 80+ carriers worldwide, each with different API formats, authentication methods, and update frequencies.
Carrier Integration Architecture
class CarrierAdapter:
"""Base class for all carrier integrations."""
async def fetch_tracking(self, tracking_number: str) -> TrackingResult:
raise NotImplementedError
def normalize_status(self, raw_status: str) -> TrackingStatus:
"""Map carrier-specific status to our unified status enum."""
return self.STATUS_MAP.get(raw_status, TrackingStatus.UNKNOWN)
class USPSAdapter(CarrierAdapter):
BASE_URL = "https://secure.shippingapis.com/ShippingAPI.dll"
async def fetch_tracking(self, tracking_number: str) -> TrackingResult:
params = {
"API": "TrackV2",
"XML": self._build_request_xml(tracking_number)
}
response = await self.session.get(self.BASE_URL, params=params)
return self._parse_response(response.text)
class FedExAdapter(CarrierAdapter):
async def fetch_tracking(self, tracking_number: str) -> TrackingResult:
response = await self.session.post(
"https://apis.fedex.com/track/v1/trackingnumbers",
headers={"Authorization": f"Bearer {self.oauth_token}"},
json={
"includeDetailedScans": True,
"trackingInfo": [{
"trackingNumberInfo": {"trackingNumber": tracking_number}
}]
}
)
return self._parse_response(response.json())
Each carrier adapter handles:
- Authentication (API key, OAuth, XML credentials)
- Request formatting (REST, SOAP, XML, JSON)
- Response parsing and normalization
- Rate limiting and retry logic
- Webhook registration (when supported)
The Worker Backend
I chose Cloudflare Workers for the tracking API because:
- Global edge deployment — Tracking lookups happen from anywhere; edge reduces latency
- Low cold start — Workers spin up in under 50ms vs 500ms+ for traditional serverless
- Request-based pricing — Pay per request, not per idle second
- KV storage — Fast key-value cache for tracking data
// worker/src/index.ts
export default {
async fetch(request) {
const url = new URL(request.url);
if (request.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders(request) });
}
// Rate limiting per shop
const shopId = url.searchParams.get('shop');
const rateLimit = await checkRateLimit(shopId);
if (rateLimit.exceeded) {
return jsonResponse({ error: 'Rate limit exceeded' }, request, 429);
}
if (url.pathname.startsWith('/api/track/')) {
return handleTrackingLookup(url, request);
}
return new Response('Not Found', { status: 404 });
}
};
async function handleTrackingLookup(url, request) {
const trackingNumber = url.pathname.split('/').pop();
const shop = url.searchParams.get('shop');
// Check cache first (KV)
const cacheKey = `track:${shop}:${trackingNumber}`;
const cached = await TRACKING_KV.get(cacheKey, 'json');
if (cached && !isStale(cached)) {
return jsonResponse(cached, request);
}
// Detect carrier from tracking number pattern
const carrier = detectCarrier(trackingNumber);
if (!carrier) {
return jsonResponse({ error: 'Unknown carrier' }, request, 400);
}
const adapter = getAdapter(carrier);
const result = await adapter.fetchTracking(trackingNumber);
await TRACKING_KV.put(cacheKey, JSON.stringify(result), {
expirationTtl: 1800
});
return jsonResponse(result, request);
}
Carrier Detection
One interesting challenge: detecting which carrier a tracking number belongs to. Each carrier has distinct tracking number patterns:
function detectCarrier(trackingNumber) {
const patterns = [
[/^94\d{20,22}$/, 'usps'],
[/^\d{12,15}$/, 'fedex'],
[/^1Z[A-Z0-9]{16}$/, 'ups'],
[/^JNT\d{12,}$/, 'jnte'],
[/^[A-Z]{2}\d{9}[A-Z]{2}$/, 'int_registered'],
];
for (const [pattern, carrier] of patterns) {
if (pattern.test(trackingNumber)) return carrier;
}
return null;
}
Component 3: The Admin App
The admin app is a Shopify-embedded React app where merchants configure:
- Widget appearance — Colors, fonts, layout, logo
- Tracking page URL — Custom slug for the tracking page
- Email notifications — Branded tracking emails
- Carrier connections — Which carriers to enable
- Analytics — WISMO ticket reduction metrics
import { createApp } from '@shopify/app-bridge';
function AdminDashboard() {
const app = useAppBridge();
return (
<Page title="Parcelglance">
<Layout>
<Layout.Section>
<Card>
<WidgetPreview config={currentConfig} />
</Card>
</Layout.Section>
<Layout.Section secondary>
<Card title="Tracking Stats">
<StatsGrid
totalTracked={stats.total}
delivered={stats.delivered}
inTransit={stats.inTransit}
wismoReduction={stats.wismoReduction}
/>
</Card>
</Layout.Section>
</Layout>
</Page>
);
}
Performance Optimization
The widget's impact on storefront performance was my biggest concern. Here's how I optimized:
- Lazy loading — Widget only loads when the tracking section is visible (IntersectionObserver)
- Code splitting — Map component loaded only when tracking has location data
- Preconnect — DNS prefetch to the tracking API domain
- Service Worker — Cache tracking data for repeat visits
- Minimal CSS — Scoped styles using CSS modules, no framework CSS bloat
Result: The widget adds less than 50ms to Largest Contentful Paint (LCP) in testing.
Results
After launching Parcelglance, merchants are seeing:
- 60% reduction in "Where's my order?" support tickets
- 4.8/5 average customer satisfaction rating for the tracking experience
- 15% increase in repeat purchases from the tracking page CTA
- Zero impact on storefront performance scores
What's Next
- Proactive notifications — SMS/email updates at key milestones
- Delivery predictions — ML-based estimated delivery dates
- Returns integration — Self-service returns from the tracking page
- Multi-language — Auto-detect customer language for tracking page
If you're running a Shopify store and drowning in "where's my order" emails, check out Parcelglance. It takes 5 minutes to install and the impact is immediate.
What's your current post-purchase experience like? How do you handle tracking for your Shopify store? Share your experience in the comments.
Top comments (0)