Building a Print-on-Demand T-Shirt Site with Next.js 14, Google Apps Script, and AWS Infrastructure

What Was Done

We built a complete e-commerce stack for 86dfrom.com, a print-on-demand t-shirt storefront. The project spans three distinct layers: a Next.js 14 frontend deployed on Vercel, a Google Apps Script backend for order processing and Stripe webhook handling, and AWS infrastructure (S3 + CloudFront + Route53) for static asset delivery and DNS management.

This post documents the architecture decisions, infrastructure setup, and integration patterns that tie these systems together.

Project Structure & File Layout

The codebase lives in two places to maintain separation of concerns:

  • /Users/cb/Desktop/86dfrom/ — active development directory
  • ~/Documents/repos/sites/86dfrom.com/ — production repository with subdirectories:
    • site/ — HTML/CSS/JS for static pages (served via S3 + CloudFront)
    • gas/ — Google Apps Script deployment code and configuration
    • scripts/ — deployment automation scripts

This dual-repo pattern allows the Next.js app to live in Vercel (for dynamic routes) while static assets and fallback pages use the AWS CDN tier.

Core Application Stack

Next.js 14 Frontend (Vercel)

The storefront is a clean Next.js 14 build with five compiled routes:

  • /api/products — fetch Printful catalog via their REST API
  • /api/cart — manage shopping cart state
  • /api/checkout — initiate Stripe payment sessions
  • /api/webhook — receive Stripe webhook events (payment confirmation, disputes)
  • / — homepage with product gallery and checkout flow

The build compiles cleanly with no warnings or errors. We verified this by running next build in the project root—all routes resolve correctly.

Printful Integration

Product data comes from Printful's API. The flow:

  1. A script at scripts/get-printful-variants.js hits the Printful API endpoint to fetch available t-shirt variants
  2. For the Bella+Canvas 3001 Black model, we collected variant IDs from Printful (specifically items 4016–4020 in the Printful product catalog)
  3. These IDs get stored in .env.local so the frontend can request accurate pricing and availability
  4. When a customer adds an item to cart, the ID is sent to Stripe via the checkout API route

The Printful account is registered under "Hello Dangerous" (the dangerouscentaur.com parent organization) with a dedicated store named "86Store."

Stripe Payment Processing

Payments flow through Stripe with these key components:

  • Publishable key (pk_live_... or pk_test_...) — embedded in frontend, used to initialize payment UI
  • Secret key (sk_live_... or sk_test_...) — stored server-side in .env.local, used to create payment intents
  • Webhook signing secret (whsec_...) — retrieved from Stripe after deploying to production, used to verify webhook authenticity in the /api/webhook route

We opted to capture payment intents on the backend (at the /api/checkout endpoint) rather than client-side, following PCI compliance best practices. This means the frontend never touches raw card data.

Google Apps Script Backend

The gas/Code.gs file contains functions that receive order events and write data to a Google Sheet. Key functions:

  • doPost(e) — HTTP POST handler that validates Stripe webhook signatures and logs order details
  • addOrderToSheet(orderData) — appends a row to a spreadsheet with customer name, email, amount, and timestamp
  • sendConfirmationEmail(email, orderInfo) — optionally emails the customer a receipt

The Apps Script is deployed as a standalone web app (not as an add-on). It has a unique deployment URL in the format https://script.google.com/macros/d/{DEPLOYMENT_ID}/userweb.

Configuration lives in gas/appsscript.json, which declares runtime settings and OAuth scopes (e.g., spreadsheets, mail).

AWS Infrastructure: S3, CloudFront, Route53

S3 Buckets

Two S3 buckets were created:

  • 86dfrom.com — primary bucket for index.html and static assets
  • 86from.com — redirect bucket (note the different spelling) that 301-redirects all traffic to 86dfrom.com

Both buckets are configured with static website hosting disabled in favor of CloudFront access. This is the correct pattern: CloudFront sits in front of S3 as the origin, and S3 is restricted to CloudFront via bucket policies that use the Origin Access Identity (OAI) mechanism.

Bucket policy example pattern (sanitized):

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::cloudfront:user/CloudFront OAI ID"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::bucket-name/*"
    }
  ]
}

CloudFront Distributions

Two CloudFront distributions were created, one for each domain:

  • 86dfrom.com distribution — primary CDN
    • Origin: S3 bucket 86dfrom.com
    • Default root object: index.html
    • SSL/TLS: ACM certificate for 86dfrom.com (auto-validated via Route53 DNS records)
    • Cache behaviors: 24-hour TTL for *.html, 365-day TTL for *.woff2 (fonts)
    • Function attachment: CloudFront function for rewriting / to /index.html (required for SPA routing)
  • 86from.com distribution — redirect only
    • Origin: Custom origin pointing to a redirect Lambda@Edge function
    • Purpose: Catch typos and direct 86from.com traffic to https://86dfrom.com via 301 redirect
    • Lambda@Edge function: simple JavaScript that returns