Building a Print-on-Demand T-Shirt Site: Next.js 14 + Printful + Stripe Integration on AWS
This post documents the infrastructure and application setup for 86dfrom.com, a custom t-shirt storefront leveraging Printful's print-on-demand API, Stripe for payments, and AWS for static hosting. The architecture prioritizes simplicity, cost-efficiency, and rapid iteration.
Project Overview
The 86dfrom.com site is a Next.js 14 application with a minimal but complete e-commerce flow:
- Product display (single SKU, multiple variants sourced from Printful)
- Shopping cart with variant selection
- Stripe checkout integration
- Order webhook processing
- Printful order fulfillment via API
The site is statically deployed to S3 + CloudFront, with API routes hosted on Vercel. This hybrid approach isolates the compute-heavy payment processing from the static content layer.
Repository Structure
The project lives at /Users/cb/Documents/repos/sites/86dfrom.com with the following layout:
86dfrom.com/
├── site/ # Static HTML (deployed to S3)
│ ├── index.html # Main product page
│ └── success.html # Order confirmation
├── gas/ # Google Apps Script (optional backend)
│ ├── Code.gs
│ └── appsscript.json
├── scripts/
│ ├── deploy.sh # S3 + CloudFront deployment
│ └── get-printful-variants.js
├── .env.local # Local credentials (git-ignored)
└── README.md
Infrastructure: AWS S3 + CloudFront + Route53
Rather than deploying the entire Next.js app, we opted for a static-first architecture. The reasoning:
- Cost: S3 + CloudFront is significantly cheaper than Vercel for a simple product page
- Speed: Static content served from CloudFront edge locations globally
- Separation of concerns: Static content and serverless APIs are decoupled
S3 Bucket Configuration
Created bucket 86dfrom-site with the following settings:
- Block all public access (CloudFront origin access control only)
- Enable versioning for rollback capability
- Set bucket policy to allow CloudFront distribution to read objects
The bucket policy restricts access exclusively to the CloudFront distribution, preventing direct S3 URL access:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CloudFrontAccess",
"Effect": "Allow",
"Principal": {
"Service": "cloudfront.amazonaws.com"
},
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::86dfrom-site/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DIST_ID"
}
}
}
]
}
CloudFront Distribution
Two distributions were created:
- Primary (86dfrom.com): S3 origin with index.html as default root object, caching TTL of 86400 seconds (24 hours) for HTML, longer for assets
- Redirect (86from.com): Lightweight redirect distribution that points all traffic to the primary domain using CloudFront Functions
The redirect distribution uses a simple CloudFront Function deployed to the viewer-request stage:
function handler(event) {
var request = event.request;
var host = request.headers.host.value;
if (host === '86from.com') {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: {
location: {
value: 'https://86dfrom.com' + request.uri
}
}
};
}
return request;
}
This avoids the cost of maintaining a second S3 bucket while capturing typo traffic.
Route53 DNS Configuration
Two hosted zones were established:
- 86dfrom.com: A record pointing to primary CloudFront distribution
- 86from.com: A record pointing to redirect CloudFront distribution
ACM certificates were provisioned for both domains with DNS validation via Route53 CNAME records. Validation typically completes within 5-15 minutes.
Deployment Pipeline: scripts/deploy.sh
The deployment script handles two responsibilities: static site sync and CloudFront cache invalidation.
#!/bin/bash
set -e
BUCKET="86dfrom-site"
DISTRIBUTION="E1234ABCD5678"
# Sync site/ to S3
aws s3 sync ./site/ s3://${BUCKET}/ \
--region us-east-1 \
--delete \
--cache-control "public, max-age=86400"
# Invalidate CloudFront cache
aws cloudfront create-invalidation \
--distribution-id ${DISTRIBUTION} \
--paths "/*"
echo "✓ Deployment complete"
The --delete flag ensures old files are removed; the cache-control directive sets sensible browser/edge caching. CloudFront invalidation is synchronous within the script, guaranteeing fresh content globally within seconds.
Printful Integration: Variant Population
The t-shirt site supports multiple variants (size, color) pulled from Printful's catalog. Rather than hardcoding variant IDs, we fetch them programmatically.
The script at scripts/get-printful-variants.js queries the Printful API for the "Bella+Canvas 3001 Black" t-shirt:
const fetch = require('node-fetch');
const PRINTFUL_API_KEY = process.env.PRINTFUL_API_KEY;
const BASE_URL = 'https://api.printful.com';
async function getVariants() {
const response = await fetch(
`${BASE_URL}/products?status=active`,
{
headers: {
'Authorization': `Bearer ${PRINTFUL_API_KEY}`
}
}
);
const data = await response.json();
// Filter for Bella+Canvas 3001, extract variant IDs
const variants = data.result
.filter(p => p.product_name.includes('Bella+Canvas 3001'))
.flatMap(p => p.variants.map(v => ({
id: v.id,
size: v.size,
color: v.color
})));
console.log(JSON.stringify(variants, null, 2));
}
getVariants();
The output populates .env.local with variant IDs for XS through