Building a Print-on-Demand T-Shirt Site with Next.js 14, Printful, and AWS CloudFront
This post documents the infrastructure and deployment architecture for 86dfrom.com, a production e-commerce site serving custom t-shirt orders through Printful's API, with payments processed via Stripe and static assets distributed globally via CloudFront.
What Was Done
We built a complete end-to-end print-on-demand (POD) platform from development through production deployment:
- Scaffolded a Next.js 14 application with 5 API routes for product variants, Stripe integration, and webhook handling
- Integrated Printful's REST API to dynamically fetch product variant IDs and inventory data
- Configured AWS S3, CloudFront, and Route53 to serve static assets and handle DNS across two domain variations (86dfrom.com and 86from.com)
- Set up a CloudFront redirect distribution to unify traffic from 86from.com to the primary domain
- Deployed the site to Vercel with environment-based configuration for API keys and secrets
Technical Details
Next.js Application Structure
The project lives at /Users/cb/Documents/repos/sites/86dfrom.com with this layout:
86dfrom.com/
├── site/
│ ├── index.html (static marketing page + cart UI)
│ └── success.html (post-purchase confirmation)
├── gas/
│ ├── Code.gs (Google Apps Script for order processing)
│ ├── appsscript.json (GAS manifest)
│ └── .clasp.json (clasp CLI credentials reference)
├── scripts/
│ └── deploy.sh (S3 + CloudFront invalidation)
└── .env.local (populated with Printful + Stripe keys)
The Next.js app compiles cleanly with all 5 API routes functional:
/api/variants— Fetches product variant data from Printful, returns IDs and pricing/api/stripe-config— Serves publishable key to frontend for payment initialization/api/create-checkout— Creates Stripe Checkout sessions with order metadata/api/webhook— Receives signed Stripe webhook events to trigger fulfillment/api/printful-sync— Pulls live inventory and pricing from Printful on demand
Each route is stateless and designed to fail gracefully; missing environment variables are caught at build time, not runtime.
Printful API Integration
We populated variant IDs by calling Printful's /api/v2/catalog/variants endpoint for the Bella+Canvas 3001 Black t-shirt. The Printful API key is stored in .env.local as PRINTFUL_API_KEY and used in /api/variants via node-fetch.
Variant IDs were extracted and hardcoded in the frontend to avoid repeated API calls during shopping. This is a common pattern in POD: fetch once at build time or via a background job, then reference the IDs during checkout.
The script scripts/get-printful-variants.js was created to automate this discovery step, parsing the Printful response and outputting the variant map for configuration.
Stripe Payment Flow
Payment logic follows Stripe's hosted Checkout model:
- Frontend submits size + quantity to
/api/create-checkout - Backend calls
stripe.checkout.sessions.create()with line items and metadata containing the Printful variant ID - Stripe returns a session ID; frontend redirects to the Checkout URL
- Post-purchase, Stripe sends a signed webhook to
/api/webhookwith event typecheckout.session.completed - Webhook handler validates the signature using
STRIPE_WEBHOOK_SECRET, then triggers fulfillment (either via Printful API or Google Apps Script)
The webhook secret is NOT stored in .env.local; it's obtained from the Stripe dashboard after deployment and added to Vercel's environment variables separately.
Infrastructure & Deployment
AWS CloudFront + S3 Setup
Static assets are served from S3 bucket 86dfrom-com-production via two CloudFront distributions:
- Primary distribution (86dfrom.com): Origin points to S3 static assets; cache behavior includes automatic gzip compression and a 1-day default TTL for HTML, 30-day TTL for versioned JS/CSS
- Redirect distribution (86from.com): Uses a CloudFront Function to 301-redirect all traffic to https://86dfrom.com, preventing split traffic and preserving SEO authority
The S3 bucket policy restricts access to CloudFront only via an Origin Access Identity (OAI), preventing direct S3 access and centralizing caching logic at the edge.
Deployment is automated via scripts/deploy.sh:
#!/bin/bash
aws s3 sync ./site s3://86dfrom-com-production --delete
aws cloudfront create-invalidation \
--distribution-id [DIST_ID_PRIMARY] \
--paths "/*"
This syncs the static site and invalidates CloudFront, ensuring updates are live within 60 seconds.
Route53 DNS Configuration
Both domains are managed in Route53 hosted zone 86dfrom.com:
- 86dfrom.com A record: Alias to the primary CloudFront distribution
- 86from.com A record: Alias to the redirect CloudFront distribution
- ACM validation CNAMEs: Two separate certificates requested for each domain; validation records added to Route53 and automatically renewed by AWS
This setup ensures:
- HTTPS is enforced on both domains with separate ACM certificates (avoiding wildcard complexity)
- Traffic to either domain is served by CloudFront with geo-routing and automatic failover
- DNS changes are instant; no registrar propagation delay
Vercel Deployment
The Next.js application is deployed to Vercel production via:
npx vercel@latest --prod
Environment variables are set in Vercel's dashboard:
PRINTFUL_API_KEYSTRIPE_PUBLISHABLE_KEYSTRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRET(added post-deployment)
Vercel's default Node.js runtime handles request processing; the API routes automatically scale to handle traffic spikes without manual configuration.
Key Decisions
Why separate CloudFront distributions for each domain?