Building a Printful + Stripe T-Shirt Store on Next.js 14: Infrastructure, API Integration, and Deployment Strategy
This post walks through the complete build of 86dfrom.com, a Next.js 14 e-commerce site powered by Printful for print-on-demand fulfillment and Stripe for payments. We'll cover the architecture decisions, infrastructure setup across AWS/CloudFront, and the integration patterns that make this stack work.
Project Structure & Architecture
The project lives at /Users/cb/Documents/repos/sites/86dfrom.com with this layout:
86dfrom.com/
├── site/ # Static assets + HTML shell
├── gas/ # Google Apps Script (optional backend)
├── scripts/ # Deployment automation
└── package.json # Next.js 14 app root
The why here: we're treating this as a hybrid deployment—the Next.js app lives on Vercel (for dynamic routes and serverless functions), while static assets can optionally be cached on CloudFront/S3. The gas/ directory exists for potential future webhook handling via Google Sheets or Apps Script integration, though Vercel's /api/webhook routes handle webhooks natively.
Printful Integration: Variant ID Discovery
The first blocker was populating variant IDs for the Bella+Canvas 3001 Black t-shirt. Printful's API requires exact variant IDs when creating orders; you can't ship without them.
The discovery process:
- Authenticate to Printful API with store credentials
- Fetch the product catalog for store 86Store
- Filter for Bella+Canvas 3001 in black colorway
- Extract the 5 size variants (XS, S, M, L, XL) and their numeric IDs
We created scripts/get-printful-variants.js to automate this. The script makes a GET request to:
GET https://api.printful.com/store/{store_id}/products
Authorization: Bearer {PRINTFUL_API_KEY}
This returns the full product tree. We parse it to find the specific SKU and extract variant IDs like 4016, 4017, 4018, 4019, 4020 for XS–XL in black.
Key decision: We ignored Heather and Oxblood colorways at this stage. Starting with one color reduces complexity; adding more colors is a data config change, not a code change.
Environment Configuration & Secrets Management
Once variant IDs were confirmed, we created .env.local at the project root with:
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
PRINTFUL_API_KEY=...
PRINTFUL_STORE_ID=86Store
PRODUCT_VARIANTS={
"XS": 4016,
"S": 4017,
"M": 4018,
"L": 4019,
"XL": 4020
}
Why split public/secret keys:
NEXT_PUBLIC_*variables are baked into client-side bundles—safe to expose Stripe's publishable keySTRIPE_SECRET_KEYstays server-side only, used in/api/checkoutand/api/webhookroutesPRINTFUL_API_KEYis also server-side; we never send it to the browser
The variant mapping as JSON (or a TypeScript constant) lets us define sizes and IDs once, then reference them in both the product listing (site/index.html or a React component) and checkout logic.
Next.js 14 Build & Deployment Pipeline
The build chain is straightforward:
npm run build # Compiles Next.js, outputs .next/ folder
npx vercel@latest --prod # Deploys to Vercel production
Before deploying, we verified that all 5 API routes compile cleanly:
/api/products— returns available t-shirt sizes and prices/api/checkout— creates a Stripe checkout session/api/webhook— listens for Stripe payment.intent.succeeded events/api/order-status— checks Printful order status by Stripe payment ID/api/health— liveness check (optional, useful for monitoring)
Each route validates its request, logs to stdout, and returns JSON. No async issues; all database calls (if any) would go through Stripe or Printful's APIs, not a local DB at this stage.
Stripe Webhook Setup
After Vercel deployment goes live at a public domain (e.g., https://86dfrom.com), the next step is registering the webhook endpoint in Stripe's Dashboard:
- Navigate to Developers → Webhooks
- Add endpoint:
https://86dfrom.com/api/webhook - Select events:
payment_intent.succeeded,charge.refunded - Copy the signing secret (whsec_...) and add it to Vercel's environment variables as
STRIPE_WEBHOOK_SECRET
/api/webhook then verifies the request signature using this secret before processing:
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(body, sig, secret);
This prevents replay attacks and ensures only legitimate Stripe events trigger order creation.
DNS & Domain Configuration
For 86dfrom.com, we need:
- A/CNAME record pointing to Vercel's endpoints (Vercel provides exact DNS values after project creation)
- Optional: CloudFront distribution in front of S3 for static asset caching (if we use the
site/directory)
If using CloudFront + S3:
- Create S3 bucket:
86dfrom-com-assets - Request ACM certificate for
86dfrom.com(DNS validation) - Add CNAME validation records to Route53 (if using Route53 for DNS)
- Create CloudFront distribution with S3 as origin, ACM cert attached
- Update Route53 to alias
86dfrom.comto CloudFront distribution
The scripts/deploy.sh` automates S3 upload and CloudFront cache invalidation:
#!/bin/bash
aws s3 sync site/ s3://86dfrom-com-assets --delete
aws cloudfront create-invalidation \