Building a Print-on-Demand T-Shirt Site with Next.js 14, Printful, and Stripe: Infrastructure & Deployment Strategy

This post covers the complete technical setup of 86dfrom.com, a dynamic print-on-demand (POD) t-shirt storefront built with Next.js 14, integrated with Printful's inventory API and Stripe for payments. We'll walk through the architecture decisions, infrastructure provisioning on AWS, and the deployment pipeline that connects everything.

Project Structure & Architecture

The project lives at /Users/cb/Documents/repos/sites/86dfrom.com with this layout:

86dfrom.com/
├── site/                    # Static HTML + redirects
│   ├── index.html          # Landing page
│   └── success.html        # Post-purchase confirmation
├── gas/                    # Google Apps Script
│   ├── Code.gs            # GAS deployment code
│   └── appsscript.json    # GAS manifest
├── scripts/
│   ├── deploy.sh          # S3 + CloudFront invalidation
│   └── get-printful-variants.js  # Fetch variant IDs from Printful API
└── .env.local             # Runtime secrets (Stripe, Printful keys)

The Next.js 14 application compiles cleanly with all five routes:

  • /api/variants — Returns available Bella+Canvas 3001 Black t-shirt variants from Printful
  • /api/checkout — Creates Stripe Checkout sessions
  • /api/webhook — Handles Stripe webhook events (payment confirmation, fulfillment)
  • /success — Redirect page after successful payment
  • / — Homepage with product selector

Infrastructure: AWS + CloudFront + Route53

Rather than deploy to Vercel initially, the site leverages AWS for tighter control over infrastructure and cost optimization:

S3 Bucket Configuration

An S3 bucket named 86dfrom.com was created in us-east-1 (required for CloudFront origin). The bucket is configured for static website hosting with a bucket policy that allows CloudFront distribution access:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity [ID]"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::86dfrom.com/*"
    }
  ]
}

Why S3 + CloudFront instead of Vercel? Direct control over caching headers, ability to serve from our existing CloudFront distribution (shared infrastructure with other sites like chuckladd.com and dangerouscentaur.com), and cost predictability at scale.

CloudFront Distribution

A CloudFront distribution was created with:

  • Origin: S3 bucket 86dfrom.com.s3.us-east-1.amazonaws.com
  • Origin Access Identity (OAI): Restricts direct S3 access; all traffic flows through CloudFront
  • Cache behavior: Default TTL 300s (5 min), max TTL 3600s (1 hr) for HTML; 86400s (1 day) for static assets
  • SSL/TLS: AWS Certificate Manager (ACM) certificate for 86dfrom.com

The distribution domain was then aliased in Route53 under the 86dfrom.com hosted zone with an A record pointing to the CloudFront distribution endpoint.

Handling the Typo Domain

During development, a typo domain 86from.com was created. Rather than abandon it, a second CloudFront function-based redirect was deployed:

  • ACM certificate requested for 86from.com with DNS validation (CNAME records added to Route53)
  • CloudFront function deployed to rewrite requests: 86from.com → 86dfrom.com
  • A second CloudFront distribution created as a redirect-only endpoint for 86from.com
  • Route53 A record added pointing 86from.com to the redirect distribution

Why keep the typo domain? SEO mitigation — users typing the typo get a transparent 301 redirect before hitting our canonical domain, avoiding duplicate content penalties and preserving any accidental link equity.

Deployment Pipeline

The scripts/deploy.sh script automates the S3 + CloudFront workflow:

#!/bin/bash
# Build Next.js application
npm run build

# Sync site/ directory to S3
aws s3 sync ./site/ s3://86dfrom.com/ \
  --delete \
  --cache-control "max-age=300"

# Invalidate CloudFront cache
aws cloudfront create-invalidation \
  --distribution-id [DIST_ID] \
  --paths "/*"

echo "Deployment complete"

This script:

  • Ensures all local files are synced to S3 (deletes removed files)
  • Sets cache headers (5-min TTL for HTML, respects build-time header metadata for static assets)
  • Invalidates the entire CloudFront cache to force edge updates

Why invalidate all paths? The /* invalidation is blunt but necessary because CloudFront doesn't know which S3 objects were updated — a full invalidation costs 5¢ and ensures immediate cache freshness for users.

Environment Configuration & Secrets Management

The .env.local file (gitignored) contains runtime credentials:

NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_SECRET_KEY=sk_live_...
PRINTFUL_API_KEY=[api-key]
PRINTFUL_STORE_ID=86Store
WEBHOOK_SECRET=whsec_...

Key distinction: NEXT_PUBLIC_* variables are embedded in the client bundle (safe for public keys); other secrets remain server-side only.

The Printful API token was provisioned with full scope across all stores in the dangerouscentaur account, allowing the get-printful-variants.js script to fetch real-time inventory:

// scripts/get-printful-variants.js
const apiKey = process.env.PRINTFUL_API_KEY;
const storeId = process.env.PRINTFUL_STORE_ID;

async function getVariants() {
  const response = await fetch(
    `https://api.printful.com/stores/${storeId}/products`,
    { headers: { Authorization: `Bearer ${apiKey}` } }
  );
  const data = await response.json();
  // Filter for Bella+Canvas 3001 Black variants
  const variants =