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.
- 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
- 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
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.
- Elements-First Flow: Implements Stripe's latest
PaymentElementstandards, supporting Apple Pay, Google Pay, and link-based payments. - Metadata Injection: Upon
/api/payment/create-intent, the backend injects Shopifyvariant_idsand cart snapshots into the Stripe metadata to preserve state through the payment lifecycle.
- Webhook-Driven Logic: Orders are not created on the frontend redirect. Instead, a Next.js API Route (
/api/payment/webhook) listens forpayment_intent.succeeded. - Signature Verification: Employs
stripe.webhooks.constructEventto 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.
- 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.
Unlike basic Shopify integrations, this project implements a Resilient Webhook Handshake that gracefully handles asynchronous operations and network delays:
- 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
- Stripe pings
/api/payment/webhookwith signed event - We verify cryptographic signature using
stripe.webhooks.constructEvent() - Only authenticated Stripe events can trigger order creation (prevents spoofing)
- Webhook handler returns
200 OKto 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
- 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)
- 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-numberup to 10 times with 2-second intervals - Once order is found, displays order number and provides direct link to Shopify Admin
- 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
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
βββββββββββ ββββββββββ ββββββββββββ ββββββββββ
β 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!)
- β‘ 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.
Webhooks allow orders to be created in Shopify Admin even if the user closes their browser after paying.
-
Install & Login:
stripe login
-
Listen for events:
stripe listen --forward-to localhost:3000/api/payment/webhook
-
Configure
.env.local: Use thewhsec_secret provided by the CLI.
β
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
βββ 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
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 |
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.
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.
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.
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 OKto Stripe as fast as possible (~50ms) - Process Shopify order creation asynchronously via
processOrderAsync()pattern - Use
awaitto 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.
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.
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.
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).
- 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-testidconvention (Playwright native) instead of framework-specificdata-cyfor better maintainability.
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:uiWe 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-verifyWhen 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
| 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 |
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 })) { ... }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; }
`,
});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');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
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
npm installnpm run devnpm run build
npm startnpm run test:e2eBuilt with β€οΈ for modern ecommerce. Production-ready. Portfolio-worthy.