Building a Printful + Stripe T-Shirt Commerce Site on Next.js 14: Infrastructure, API Integration, and CloudFront Distribution
This post documents the complete setup of 86dfrom.com, a Next.js 14 e-commerce site selling print-on-demand t-shirts via Printful, with Stripe payments and AWS infrastructure. The project demonstrates modern fullstack patterns: Next.js API routes for backend logic, environment-based configuration, S3 + CloudFront for static delivery, and third-party API orchestration.
Project Architecture Overview
The site is structured as a monorepo with three distinct deployment targets:
- Next.js app (
/site→ Vercel): Server-side rendering, API routes, and dynamic checkout - Google Apps Script (
/gas→ Google Cloud): Webhook handler for order notifications - Static redirect site (
86from.com→ S3 + CloudFront): Legacy domain pointing to main site
This separation allows independent scaling: the main commerce app scales via Vercel's serverless infrastructure, GAS handles asynchronous notification logic, and the redirect domain uses AWS's CDN for minimal latency.
Printful API Integration
The Printful integration centers on product variants. Rather than hard-coding variant IDs, we fetch them dynamically:
// scripts/get-printful-variants.js
const apiKey = process.env.PRINTFUL_API_KEY;
const storeId = process.env.PRINTFUL_STORE_ID; // "86Store"
async function fetchVariants() {
const response = await fetch(
`https://api.printful.com/stores/${storeId}/products`,
{
headers: { Authorization: `Bearer ${apiKey}` }
}
);
const data = await response.json();
// Filter to Bella+Canvas 3001 Black variants (IDs 4016–4020)
return data.result.filter(v => v.variant_id >= 4016 && v.variant_id <= 4020);
}
Why this approach: Printful variant IDs are stable but opaque. Hard-coding them breaks if product definitions change. By fetching at build time (or early startup), we ensure our inventory matches Printful's source of truth. The script runs once during setup, outputs the variant mapping, and those IDs populate .env.local.
Environment Configuration and Secrets Management
The project uses Next.js's built-in environment variable system:
// .env.local (created after credentials gathered)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
PRINTFUL_API_KEY=UPQNIqzJkpoV2JPKKrhwYteCKzhipRnLHA2TxLnt
PRINTFUL_STORE_ID=86Store
NEXT_PUBLIC_SITE_URL=https://86dfrom.com
Key decision: Variables prefixed with NEXT_PUBLIC_ are bundled into the client-side JavaScript (for Stripe.js initialization). All secret keys are server-only, never exposed to browsers.
After Vercel deployment, these variables are synced to Vercel's environment dashboard via the CLI, ensuring production and preview deployments have access without storing secrets in git.
Stripe Payment Integration
Two API routes handle payment flows:
/api/create-checkout-session: Accepts cart data, creates a Stripe Session, returns redirect URL/api/webhook: Receives Stripe webhook events, updates order status in Printful and triggers GAS notification
// app/api/create-checkout-session/route.ts
export async function POST(req: Request) {
const { cartItems } = await req.json();
const lineItems = cartItems.map(item => ({
price_data: {
currency: 'usd',
product_data: { name: `T-Shirt (Size ${item.size})` },
unit_amount: 2999, // $29.99 in cents
},
quantity: item.quantity,
}));
const session = await stripe.checkout.sessions.create({
mode: 'payment',
success_url: `${process.env.NEXT_PUBLIC_SITE_URL}/success`,
cancel_url: `${process.env.NEXT_PUBLIC_SITE_URL}/`,
line_items: lineItems,
});
return Response.json({ url: session.url });
}
The webhook endpoint validates Stripe's signature using the webhook secret, then dispatches events to Printful and GAS:
// app/api/webhook/route.ts
export async function POST(req: Request) {
const sig = req.headers.get('stripe-signature');
const body = await req.text();
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// Create Printful order and notify GAS
await createPrintfulOrder(session);
await notifyGAS(session);
}
return Response.json({ received: true });
}
AWS Infrastructure: S3 + CloudFront
The redirect domain 86from.com uses a lightweight S3 + CloudFront setup:
- S3 bucket:
86from.com-redirect— holds minimal HTML redirect page - CloudFront distribution: Points to S3, adds caching headers and HTTPS via ACM certificate
- Route53:
86from.comA record aliases the CloudFront distribution
Why S3 + CloudFront instead of a serverless redirect: A static redirect doesn't warrant Vercel costs. S3 + CloudFront costs pennies monthly, offers global CDN caching, and decouples the redirect domain from the main application. If the main site goes down, the redirect still works.
The redirect HTML uses a JavaScript snippet to preserve query parameters:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Redirecting...</title>
<script>
window.location.href = 'https://86dfrom.com' + window.location.search;
</script>
</head>
<body></body>
</html>
Deployment Pipeline
The scripts/deploy.sh script orchestrates multi-target deployment:
#!/bin/bash
# 1. Deploy Next.js to Vercel
npx vercel@latest --prod --token $VERCEL_TOKEN
# 2. Sync environment variables
npx vercel@latest env pull
# 3. Deploy S3 + CloudFront
aws s3 sync ./