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:

  1. Primary (86dfrom.com): S3 origin with index.html as default root object, caching TTL of 86400 seconds (24 hours) for HTML, longer for assets
  2. 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