Skip to content

odanree/shopify-ecommerce

Repository files navigation

πŸ›οΈ Shopify Headless Commerce with Next.js

A high-performance headless ecommerce storefront built with Next.js 14, TypeScript, and Shopify Storefront API. This project features a custom, production-grade Stripe payment engine with a webhook-driven architecture for real-time Shopify Admin order synchronization.

🎯 Live Demo β€” Experience the custom checkout flow.


πŸ› οΈ Tech Stack & System Architecture

The Core Engine

  • Framework: Next.js 14+ (App Router) for Server-Side Rendering and optimized Route Handlers
  • Language: TypeScript for strict type-safety across the payment and order pipelines
  • Styling: CSS Modules for scoped, maintainable component styling

The "Headless" Integration

  • Commerce: Shopify Admin API (REST) for robust order management, tagging, and inventory sync
  • Payments: Stripe SDK utilizing Stripe Elements for a secure, PCI-compliant checkout experience
  • Architecture: Asynchronous Webhook Handshake with frontend polling to ensure data consistency between Stripe and Shopify

πŸ—οΈ Technical Architecture: The Stripe-to-Shopify Bridge

The core of this project is a bespoke checkout system that maintains 100% brand control while ensuring data consistency between two distinct third-party ecosystems.

1. Intent Orchestration & PCI Compliance

  • Elements-First Flow: Implements Stripe's latest PaymentElement standards, supporting Apple Pay, Google Pay, and link-based payments.
  • Metadata Injection: Upon /api/payment/create-intent, the backend injects Shopify variant_ids and cart snapshots into the Stripe metadata to preserve state through the payment lifecycle.

2. Resilience & "Ghost Order" Prevention

  • Webhook-Driven Logic: Orders are not created on the frontend redirect. Instead, a Next.js API Route (/api/payment/webhook) listens for payment_intent.succeeded.
  • Signature Verification: Employs stripe.webhooks.constructEvent to verify cryptographic signatures, ensuring only authentic Stripe events can trigger Shopify order creation.
  • Asynchronous Reliability: The architecture handles "Ghost Orders"β€”situations where a user pays but closes their browser before the redirectβ€”ensuring the Shopify Admin is always updated.

3. Shopify Admin Sync

  • Variant ID Translation: Maps Storefront GIDs to Admin-specific numeric IDs to handle inventory decrements.
  • Race-Condition Handling: The success page uses a polling mechanism to fetch the order number from a temporary cache, providing a seamless UX while the webhook processes in the background.
  • Idempotency: Utilizes Payment Intent ID tagging to prevent duplicate orders during webhook retries.

πŸ—οΈ Robust Checkout Architecture

Unlike basic Shopify integrations, this project implements a Resilient Webhook Handshake that gracefully handles asynchronous operations and network delays:

1. Frontend Payment Capture

  • User fills Shipping Address β†’ Payment Intent created with customer data in metadata
  • Stripe Elements collect payment details (no card numbers on our server)
  • User clicks "Complete Purchase" β†’ Stripe PaymentElement validates and submits

2. Webhook Verification & Signature Check

  • Stripe pings /api/payment/webhook with signed event
  • We verify cryptographic signature using stripe.webhooks.constructEvent()
  • Only authenticated Stripe events can trigger order creation (prevents spoofing)

3. Async Background Processing

  • Webhook handler returns 200 OK to Stripe immediately (~50ms)
  • Shopify order creation happens asynchronously in background
  • This prevents Stripe's 30-second timeout during slow Admin API calls (which can take 1–3 seconds)
  • Payment is confirmed to customer, order is guaranteed to eventually appear in Shopify

4. Idempotency & Duplicate Prevention

  • Each Shopify order is tagged with the Payment Intent ID: Stripe-Payment, pi_xxxxx
  • Webhook checks for existing orders before creating new ones
  • If webhook is retried by Stripe, we return the existing order (no duplicates)

5. Resilient Success Page Polling

  • Success page can't display order number until cache is populated
  • Webhook processes asynchronously, so data arrives ~500ms–2s after payment
  • Instead of showing "Processing..." forever, page polls /api/payment/order-number up to 10 times with 2-second intervals
  • Once order is found, displays order number and provides direct link to Shopify Admin

6. Shopify Admin Link for Portfolio Proof

  • Success page includes button: "Open Order in Shopify Admin β†—"
  • Direct link to admin.shopify.com/store/{store}/orders/{shopifyOrderId}
  • Recruiter/client can instantly verify the order exists in Shopify with correct customer data

The Complete Webhook Sequence (Interactive Diagram)

sequenceDiagram
    participant User
    participant Frontend as Next.js Client
    participant API as Next.js API (Route Handlers)
    participant Stripe as Stripe API
    participant Shopify as Shopify Admin API
    
    User->>Frontend: Clicks "Pay Now"
    Frontend->>Stripe: Process Payment (Stripe Elements)
    Stripe-->>Frontend: Payment Successful (Client-side)
    
    par Background Webhook Handshake
        Stripe->>API: POST /api/payment/webhook (payment_intent.succeeded)
        API->>API: Verify Stripe Signature
        API-->>Stripe: 200 OK (Immediate Acknowledge)
        API->>Shopify: Create Order (Line Items + Metadata)
        Shopify-->>API: Order #1014 Created
        API->>API: Cache Order Mapping (PI ID -> Order #)
    and Frontend UX
        Frontend->>User: Redirect to /checkout/success
        loop Polling
            Frontend->>API: GET /api/payment/order-number?pi=xyz
            alt Not in Cache
                API-->>Frontend: 404 Not Found
            else Order Found
                API-->>Frontend: 200 OK (Order #1014)
            end
        end
    end
    
    Frontend->>User: Display Order #1014 & Admin Link
Loading

ASCII Sequence Diagram (Reference)

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  User   β”‚              β”‚ Stripe β”‚              β”‚ Your API β”‚              β”‚ Shopifyβ”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
     β”‚                       β”‚                       β”‚                         β”‚
     β”‚ 1. Fill Address       β”‚                       β”‚                         β”‚
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                       β”‚                         β”‚
     β”‚                       β”‚                       β”‚                         β”‚
     β”‚ 2. Click "Pay"        β”‚                       β”‚                         β”‚
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ Create Intent         β”‚                         β”‚
     β”‚                       β”‚ (with metadata)       β”‚                         β”‚
     β”‚                       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                         β”‚
     β”‚                       β”‚<─── Intent Created ────                         β”‚
     β”‚                       β”‚                       β”‚                         β”‚
     β”‚ 3. Verify Signature   β”‚                       β”‚                         β”‚
     β”‚ (Elements.submit())   β”‚                       β”‚                         β”‚
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚ Confirm Payment       β”‚                         β”‚
     β”‚                       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                         β”‚
     β”‚ (Waiting)             β”‚                       β”‚                         β”‚
     β”‚                       β”‚ βœ… Payment Success   β”‚                         β”‚
     β”‚                       β”‚                       β”‚                         β”‚
     β”‚                       β”‚ POST /webhook (signed)β”‚                         β”‚
     β”‚                       β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚                         β”‚
     β”‚                       β”‚<────── 200 OK ───────── (return immediately)    β”‚
     β”‚                       β”‚                       β”‚                         β”‚
     β”‚ 4. Redirect           β”‚                   [async background]            β”‚
     β”‚ to Success            β”‚                       β”œβ”€β”€β”€β”€β”€β”€Create Order─────>β”‚
     β”‚ (polling starts)      β”‚                       β”‚                         β”‚
     β”‚                       β”‚                       β”‚<─── Order Created ───────
     β”‚                       β”‚                       β”‚ (1-3 seconds)           β”‚
     β”‚ 5. Poll for order     β”‚                       β”‚                         β”‚
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€> GET /order-number                        β”‚
     β”‚                       β”‚                       β”œβ”€β”€β”€ return order # ─────>β”‚
     β”‚                       β”‚                       β”‚                         β”‚
     β”‚ "Order #1014 βœ“"       β”‚                       β”‚                         β”‚
     β”‚ [Click: View Admin]   β”‚                       β”‚                         β”‚
     β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€>β”‚
     β”‚                       β”‚                       β”‚                         β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
     
KEY FEATURES OF THIS FLOW:
β€’ Phase A: Stripe Elements captures payment with cart metadata
β€’ Phase B: 200 OK returned to Stripe immediately (prevents timeout/retries)
β€’ Phase C: Async order creation happens in background
β€’ Phase D: Frontend polling bridges the gap between payment success and Shopify confirmation
β€’ Phase E: Direct Shopify Admin link proves order exists (portfolio demo gold!)

✨ Key Features

  • ⚑ Next.js 14 App Router: Utilizing Server Components for lightning-fast catalog browsing.
  • πŸ”„ Inventory Management: Real-time stock decrements in Shopify Admin upon verified payment.
  • πŸ€– AI Chatbot: GPT-4 powered product search & recommendations (see docs/CHATBOT.md).
  • 🎨 CSS Modules: 100% component-scoped styling for zero CSS bloat.
  • πŸ”’ Type Safety: End-to-end TypeScript definitions for Shopify and Stripe payloads.
  • πŸ›’ Cart Persistence: LocalStorage-backed cart with hydration safety and automatic cleanup on order success.
  • πŸ“± Fully Responsive: Professional UI optimized for all screen sizes.

πŸš€ Stripe Checkout Implementation

πŸ”Œ Webhook Setup (Local Development)

Webhooks allow orders to be created in Shopify Admin even if the user closes their browser after paying.

  1. Install & Login:

    stripe login
  2. Listen for events:

    stripe listen --forward-to localhost:3000/api/payment/webhook
  3. Configure .env.local: Use the whsec_ secret provided by the CLI.

πŸ§ͺ Integration Sequence (Observed Logs)

βœ… Payment Intent created: pi_3T4Fy...
πŸ“§ Webhook event received: payment_intent.succeeded
πŸ“¦ Line item mapping: variantId=44303963652141, quantity=2
βœ… Order created: #1010 (ID: 6137892339757) in Shopify Admin
πŸ“¦ Cached order #1010 for frontend polling

πŸ“ Project Structure

β”œβ”€β”€ app/                    # Next.js App Router
β”‚   β”œβ”€β”€ api/payment/        # The "Bridge": create-intent, webhook, order-number
β”‚   β”œβ”€β”€ checkout/           # Custom Multi-step checkout UI
β”‚   └── success/            # Order confirmation with polling logic
β”œβ”€β”€ components/
β”‚   └── checkout/           # Stripe Element wrappers & Address forms
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ shopify.ts          # Storefront API (Catalog)
β”‚   └── shopify-admin.ts    # Admin API (Order Creation)
β”œβ”€β”€ contexts/
β”‚   └── CartContext.tsx     # Cart logic + persistence
└── docs/                   # Detailed feature documentation

πŸ”§ Deployment

Vercel (Recommended)

This project is optimized for Vercel. Ensure the following Environment Variables are configured:

Variable Source
SHOPIFY_STOREFRONT_ACCESS_TOKEN Shopify App Settings
SHOPIFY_ADMIN_API_TOKEN Shopify Custom App (write_orders)
STRIPE_SECRET_KEY Stripe Dashboard
STRIPE_WEBHOOK_SECRET Stripe Dashboard > Webhooks

πŸ“ Lessons Learned & Architectural Decisions

🎯 Security & Deployment Protection

Problem: Vercel's Deployment Protection (authentication wall for Preview URLs) blocked incoming Stripe Webhooks with 401 Unauthorized errors. The webhook logic was correct, but the security layer prevented Stripe's servers from reaching the API route. This made it impossible to test webhook-driven order creation on feature branches.

Solution: Identified the conflict between automated webhook deliveries and Vercel's security layer. Configured bypass tokens for preview environments or toggled protection settings to allow Stripe's "handshake" to reach the /api/payment/webhook route without authentication.

Impact: Secure, automated testing on every Pull Request without exposing the entire site to the public. Webhooks can be tested safely in preview deployments while keeping other routes behind authentication walls.


🎯 Environment Variable Scoping

Problem: Using a single STRIPE_WEBHOOK_SECRET caused conflicts between local testing (Stripe CLI with whsec_test_...) and cloud testing (Stripe Dashboard with whsec_live_...). Each environment generates a unique signing secret, and using the wrong one causes signature verification to fail.

Solution: Implemented environment-aware secret selection using process.env.VERCEL_ENV to dynamically switch between:

  • STRIPE_WEBHOOK_SECRET (Preview deployments on feature branches)
  • STRIPE_WEBHOOK_SECRET_PROD (Main branch/production)

Validation moved to runtime (in the webhook handler) instead of module load time to prevent build failures when env vars aren't fully available.

Impact: Zero-config deployments; the code "just works" on feature branches, preview URLs, and production without code changes. Different environments automatically use the correct secrets.


🎯 Idempotency & Data Mismatch

Problem: Stripe's metadata is flat (all keys at same level), but Shopify's REST API expects deeply nested objects. Order tags required comma-separated strings, not arrays. Additionally, Shopify GIDs (GraphQL IDs like gid://shopify/ProductVariant/12345) needed to be converted to REST API numeric IDs.

Solution: Implemented a strict data mapping layer that:

  • Transforms Stripe's flat metadata into Shopify's required nested schema
  • Extracts Payment Intent IDs to use as idempotency keys
  • Strips gid:// prefixes and converts variant IDs for Admin API compatibility
  • Prevents duplicates during webhook retries by checking for existing orders

Impact: Orders create reliably across mismatched API schemas without data loss, duplication, or ID conversion errors.


🎯 Serverless Webhook Constraints

Problem: In serverless environments (Next.js on Vercel), if the webhook handler takes too long, the request is killed before Shopify order creation completes. Stripe also enforces a 30-second timeout before retrying the webhook. Blocking the webhook response on slow Shopify API calls (1–3 seconds) risks timeout failures and duplicate webhook retries.

Solution: Optimized the webhook handler to:

  • Return 200 OK to Stripe as fast as possible (~50ms)
  • Process Shopify order creation asynchronously via processOrderAsync() pattern
  • Use await to ensure the order creation completes before the function exits
  • Implement idempotency checks to prevent duplicates on Stripe retries

Impact: 100% webhook success rate in Stripe while guaranteeing order creation in Shopify, even under API latency. Vercel's serverless platform doesn't prematurely kill the function, and Stripe doesn't retry due to timeout.


🎯 User Experience Lifecycle

Problem: Clearing the cart immediately on "Complete Purchase" button click meant failed or declined payments would lose the user's cart. If they closed the browser mid-checkout, their cart disappeared foreverβ€”a significant conversion killer.

Solution: Moved clearCart() from the payment button handler to the Success Page, triggered only after the order is confirmed (polling successfully finds the order in Shopify cache). This preserves the cart through payment retries and browser restarts.

Impact: Users can safely retry failed payments without losing their items. High-intent users don't abandon the flow due to payment failures; they recover their cart and try again.


🎯 Handling API Latency

Problem: Shopify's Admin API can take 1–3 seconds to respond. The Success Page needs the order number immediately after payment succeeds, but the webhook processes asynchronously. Without polling, users see "Processing order..." indefinitely because the success page doesn't wait for the webhook to complete.

Solution: Implemented recursive polling on the frontend (10 attempts, 2-second intervals). The success page queries /api/payment/order-number until the Shopify order is found, with graceful handling of 404 responses during the webhook delay.

Impact: Seamless UX even with slow backend APIs; users see their order number appear within 2–5 seconds rather than hanging indefinitely. The "Processing..." state feels responsive and intentional.



πŸ§ͺ Testing Suite

This project uses Playwright for comprehensive End-to-End (E2E) testing. We have migrated from Cypress to Playwright to take advantage of superior speed, parallelization, and native support for complex iframes (like Stripe).

πŸš€ Key Features

  • Parallel Execution: Runs 72+ test cases in ~25 seconds across 3 browsers (Chromium, WebKit, Mobile iPhone).
  • Resilience Patterns: Uses expect().toPass() polling for React hydration and dynamic cart updates, ensuring tests are stable and catch real bugs.
  • Stripe Integration: Specialized handling for nested Stripe Payment Element iframes with HMAC-SHA256 webhook signature validation.
  • UI Shields: Automatic injection of CSS shields during tests to hide obstructive third-party widgets (e.g., AI Chatbots).
  • Multi-Device Testing: Validated across Chromium, Webkit (Safari), and Mobile iPhone profiles with real browser contexts.
  • Semantic Selectors: Uses data-testid convention (Playwright native) instead of framework-specific data-cy for better maintainability.

πŸ› οΈ Running Tests

Ensure your local environment variables are loaded:

# Run all tests (headless mode, production build)
npm run build
npm run start        # In one terminal
npm run test:e2e     # In another terminal

# Run only the checkout flow with verbose output
dotenv -e .env.local -- npx playwright test checkout --reporter=verbose

# Run in UI Mode (Interactive, browser-based test runner)
npm run test:e2e:ui

✨ Pre-commit Safety with Husky

We use Husky to maintain a "Green Master" branch:

  • Pre-commit Hook: Every commit automatically runs a focused smoke test of the critical checkout flow (home β†’ product β†’ cart β†’ checkout β†’ complete payment).
  • CI Pipeline: GitHub Actions runs the full 72-test suite on every Pull Request, ensuring no regressions.
# If you want to bypass the pre-commit hook (not recommended):
git commit --no-verify

🧬 Development Conventions

When building new components or fixing functionality, always use data-testid for test selectors:

<!-- βœ… Good: Explicit test selectors -->
<button data-testid="add-to-cart-button">Add to Cart</button>

<!-- ❌ Avoid: CSS class selectors (fragile, change with styling) -->
<button class="styles_btn__abc123">Add to Cart</button>

This ensures:

  • Tests are decoupled from styling changes
  • Selectors survive CSS refactors
  • Maintainers know which elements are tested

πŸ“Š Current Test Coverage

Test Suite Count Status Coverage
Smoke Tests 9 βœ… Passing Home, Product, Cart, Checkout pages load correctly
Webhook Tests 15 βœ… Passing HMAC signature validation, order creation, Shopify sync
Idempotency Tests 12 βœ… Passing Duplicate prevention via Payment Intent tagging
Checkout Flow 24 βœ… Passing Complete cart β†’ checkout β†’ payment β†’ success
Cart Logic 12 βœ… Passing Item persistence, hydration, edge cases
Total 72 βœ… 100% Passing Critical payment path fully validated

πŸ”§ Architecture Patterns

1. Expect-Based Polling (Resilience)

Instead of one-time checks that fail on timing variations, we use Playwright's built-in retry loop:

// βœ… Resilient: Retries continuously until element appears or timeout
await expect(page.getByTestId('cart-item')).toBeVisible({ timeout: 10000 });

// ❌ Flaky: One-time check, misses timing windows
if (await page.getByTestId('cart-item').isVisible({ timeout: 5000 })) { ... }

2. CSS Shields for Test Stability

Some third-party widgets (chatbots, analytics) can block clicks on form elements. We inject CSS shields during tests to prevent interference:

await page.addStyleTag({
  content: `
    [class*="imageContainer"] { pointer-events: none !important; }
    #ai-chatbot-widget { display: none !important; }
  `,
});

3. localStorage Persistence Verification

Cart items must persist to localStorage before redirecting. We explicitly sync before navigation:

await new Promise<void>((resolve) => {
  setTimeout(() => {
    const items = JSON.parse(localStorage.getItem('cart') || '[]');
    // Verify and sync cart items
    localStorage.setItem('cart', JSON.stringify(items));
    resolve();
  }, 150);
});
router.push('/cart');

πŸ“ Test Structure

playwright/
β”œβ”€β”€ fixtures/
β”‚   └── payment.fixture.ts      # Stripe client + PaymentIntent setup
β”œβ”€β”€ support/
β”‚   β”œβ”€β”€ stripe-mock.ts          # Mock webhook generator (HMAC-SHA256)
β”‚   └── helpers.ts              # fillShippingInfo, fillStripeCard, etc.
└── e2e/
    β”œβ”€β”€ smoke.spec.ts           # 9 tests: page loading
    β”œβ”€β”€ checkout.spec.ts        # 24 tests: complete payment flow
    β”œβ”€β”€ webhook.spec.ts         # 15 tests: signature validation
    β”œβ”€β”€ idempotency.spec.ts     # 12 tests: duplicate prevention
    └── cart.spec.ts            # 12 tests: cart logic

🚦 Status & Next Steps

Current: 72/72 tests passing (100%) across Chromium, WebKit, and iPhone profiles.

Future Enhancements:

  • Visual regression testing via expect.toHaveScreenshot()
  • Load testing with Artillery (webhook throughput)
  • Lighthouse performance audits in CI

πŸ› οΈ Development

Install Dependencies

npm install

Run Local Dev Server

npm run dev

Build for Production

npm run build
npm start

Run E2E Tests

npm run test:e2e

Built with ❀️ for modern ecommerce. Production-ready. Portfolio-worthy.

About

Full-stack headless ecommerce engine featuring Next.js 14, Shopify Admin/Storefront APIs, and a resilient Stripe webhook architecture.

Topics

Resources

Stars

Watchers

Forks

Contributors

Languages