A step-by-step guide to building your own custom t-shirt e-commerce website using JavaScript and Printful for on-demand printing and fulfillment.
Who is this guide for?
This guide is written for people who are new to web development or running an online store. Every step is explained in plain language. You don't need to be a programmer — just follow along patiently and you'll have a working t-shirt store!
- What You're Building
- What You Need Before You Start
- Create Your Printful Account
- Set Up Your Development Environment
- Create the Project
- Project Structure Explained
- Connect to the Printful API
- Build the Product Catalog Page
- Build the Shopping Cart
- Set Up Payments (Stripe)
- Send Orders to Printful for Fulfillment
- Deploy Your Website
- Troubleshooting
- Helpful Resources
You will build a website where customers can:
- Browse a catalog of custom t-shirts with your designs
- Select sizes and colors
- Add items to a shopping cart
- Pay securely online
- Receive their order — which Printful automatically prints and ships on your behalf
You never touch inventory. Printful handles all printing, packing, and shipping after a customer places an order. This is called print-on-demand.
How the money flows:
- Customer pays your website (via Stripe)
- Your website automatically places an order with Printful
- Printful charges you their base price (lower than what your customer paid)
- You keep the difference as profit
| Service | Why You Need It | Link |
|---|---|---|
| Printful | Handles printing & shipping your shirts | printful.com |
| Stripe | Processes customer payments | stripe.com |
| Vercel or Netlify | Hosts your website for free | vercel.com or netlify.com |
| GitHub | Stores your code (free) | github.com |
-
Node.js (version 18 or newer) — the engine that runs your JavaScript code
→ Download from nodejs.org — choose the LTS version
→ After installing, open your terminal and verify:node --version -
A code editor — we recommend Visual Studio Code (free)
→ Download from code.visualstudio.com -
Git — saves and tracks your code changes
→ Download from git-scm.com
What is a terminal?
A terminal (also called "command prompt" on Windows) is a text-based window where you type commands. On Mac, pressCmd + Space, type "Terminal", and press Enter. On Windows, press the Windows key, type "cmd", and press Enter.
- Go to printful.com and click Get Started for Free
- Create an account with your email
- In your Printful dashboard, click New Store
- Choose Manual Order / API as the store type (this lets your website connect directly to Printful)
- Give your store a name (e.g., "My Shirt Store")
- In Printful, go to Product Catalog
- Choose a t-shirt style (e.g., Gildan 64000 Unisex Softstyle T-Shirt)
- Click Add product → upload your design image (PNG with transparent background works best, at least 300 DPI)
- Set your retail price (what customers pay)
- Save the product — Printful gives it a Product ID (a number like
12345). Write this down — you'll need it later.
- In Printful, click your profile icon → Settings → API
- Click Create Token
- Give it a name like "My Website"
- Copy the token and store it somewhere safe (like a password manager). You won't be able to see it again.
⚠️ Security warning: Never share your API key publicly or put it directly in your code. We'll store it safely using environment variables in a later step.
- Mac: Press
Cmd + Space, type "Terminal", press Enter - Windows: Press Win key, type "cmd" or "PowerShell", press Enter
- Linux: Press
Ctrl + Alt + T
In your terminal, navigate to where you want to create your project. For example, to put it on your Desktop:
# Mac/Linux
cd ~/Desktop
# Windows
cd C:\Users\YourName\DesktopWe'll use Next.js — a popular JavaScript framework that makes it easy to build websites with a built-in backend (so your Printful API key stays secret).
Run this command in your terminal:
npx create-next-app@latest my-shirt-storeYou'll be asked some questions. Answer them like this:
✔ Would you like to use TypeScript? → No
✔ Would you like to use ESLint? → Yes
✔ Would you like to use Tailwind CSS? → Yes
✔ Would you like to use `src/` directory? → No
✔ Would you like to use App Router? → Yes
✔ Would you like to customize the default import alias? → No
Then move into your new project folder:
cd my-shirt-storeRun each of these commands one at a time:
# Stripe — for payment processing
npm install stripe @stripe/stripe-js @stripe/react-stripe-js
# Axios — for making API calls to Printful
npm install axios
# React Icons — for nice-looking icons
npm install react-iconsYour API keys must never be visible to the public. We store them in a special hidden file called .env.local.
In your project folder, create a new file named .env.local:
# Mac/Linux
touch .env.local
# Windows (in PowerShell)
New-Item .env.localOpen .env.local in your code editor and add the following — replacing the placeholder values with your actual keys:
# Printful API Key (from Printful dashboard → Settings → API)
PRINTFUL_API_KEY=your_printful_api_key_here
# Stripe Keys (from Stripe dashboard → Developers → API Keys)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
# Your website's URL (use this during development)
NEXT_PUBLIC_BASE_URL=http://localhost:3000
What is
NEXT_PUBLIC_?
Variables starting withNEXT_PUBLIC_are safe to expose in the browser. Variables without it (likeSTRIPE_SECRET_KEY) stay hidden on the server only.
Make sure .env.local is listed in your .gitignore file (Next.js adds it automatically) so it's never uploaded to GitHub.
After setup, your project will look like this. Here's what each part does:
my-shirt-store/
│
├── app/ ← All your website pages live here
│ ├── page.js ← Home page (your product catalog)
│ ├── cart/
│ │ └── page.js ← Shopping cart page
│ ├── checkout/
│ │ └── page.js ← Checkout / payment page
│ └── api/ ← Backend code (hidden from users)
│ ├── products/
│ │ └── route.js ← Fetches products from Printful
│ ├── checkout/
│ │ └── route.js ← Creates Stripe payment session
│ └── webhook/
│ └── route.js ← Receives payment confirmation & creates Printful order
│
├── components/ ← Reusable pieces of your site
│ ├── ProductCard.js ← Displays a single product
│ ├── Cart.js ← Cart sidebar component
│ └── Navbar.js ← Top navigation bar
│
├── context/
│ └── CartContext.js ← Manages cart state across pages
│
├── public/ ← Images and static files
│
├── .env.local ← Your secret API keys (never commit this!)
├── next.config.js ← Next.js configuration
└── package.json ← Project dependencies list
In your terminal (from inside my-shirt-store):
mkdir -p app/api/products app/api/checkout app/api/webhook components contextThis file asks Printful for your product list and sends it to your website. Create app/api/products/route.js:
import axios from 'axios';
import { NextResponse } from 'next/server';
// This runs on the server - your API key is never exposed to visitors
export async function GET() {
try {
const response = await axios.get('https://api.printful.com/store/products', {
headers: {
Authorization: `Bearer ${process.env.PRINTFUL_API_KEY}`,
},
});
// Return the list of products from Printful
return NextResponse.json(response.data.result);
} catch (error) {
console.error('Error fetching products from Printful:', error.message);
return NextResponse.json(
{ error: 'Failed to load products. Please try again later.' },
{ status: 500 }
);
}
}Create app/api/products/[id]/route.js to fetch a single product with all its variants (sizes/colors):
import axios from 'axios';
import { NextResponse } from 'next/server';
export async function GET(request, { params }) {
const { id } = params;
try {
const response = await axios.get(`https://api.printful.com/store/products/${id}`, {
headers: {
Authorization: `Bearer ${process.env.PRINTFUL_API_KEY}`,
},
});
return NextResponse.json(response.data.result);
} catch (error) {
console.error(`Error fetching product ${id}:`, error.message);
return NextResponse.json(
{ error: 'Product not found.' },
{ status: 404 }
);
}
}The cart context lets every page on your site access the shopping cart. Create context/CartContext.js:
'use client';
import { createContext, useContext, useState } from 'react';
// Create the cart "bucket" that holds cart data
const CartContext = createContext();
export function CartProvider({ children }) {
const [cartItems, setCartItems] = useState([]);
// Add an item to the cart
function addToCart(product) {
setCartItems((prev) => {
const existing = prev.find((item) => item.variantId === product.variantId);
if (existing) {
// If already in cart, increase quantity
return prev.map((item) =>
item.variantId === product.variantId
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [...prev, { ...product, quantity: 1 }];
});
}
// Remove an item from the cart
function removeFromCart(variantId) {
setCartItems((prev) => prev.filter((item) => item.variantId !== variantId));
}
// Calculate the total price
const cartTotal = cartItems.reduce(
(total, item) => total + item.price * item.quantity,
0
);
return (
<CartContext.Provider value={{ cartItems, addToCart, removeFromCart, cartTotal }}>
{children}
</CartContext.Provider>
);
}
// Custom hook to use the cart anywhere in the app
export function useCart() {
return useContext(CartContext);
}Open app/layout.js and wrap the app with the cart provider:
import { CartProvider } from '@/context/CartContext';
import './globals.css';
export const metadata = {
title: 'My Shirt Store',
description: 'Custom t-shirts printed on demand',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<CartProvider>
{children}
</CartProvider>
</body>
</html>
);
}Create components/ProductCard.js:
'use client';
import { useCart } from '@/context/CartContext';
export default function ProductCard({ product }) {
const { addToCart } = useCart();
// Handle adding item to cart
function handleAddToCart() {
addToCart({
variantId: product.id,
name: product.name,
price: product.retail_price,
image: product.thumbnail_url,
});
}
return (
<div className="border rounded-lg p-4 shadow hover:shadow-lg transition">
{/* Product image */}
<img
src={product.thumbnail_url}
alt={product.name}
className="w-full h-64 object-cover rounded mb-4"
/>
{/* Product name and price */}
<h2 className="text-lg font-semibold">{product.name}</h2>
<p className="text-gray-600 mt-1">${product.retail_price}</p>
{/* Add to cart button */}
<button
onClick={handleAddToCart}
className="mt-4 w-full bg-black text-white py-2 rounded hover:bg-gray-800 transition"
>
Add to Cart
</button>
</div>
);
}Replace the contents of app/page.js with:
'use client';
import { useEffect, useState } from 'react';
import ProductCard from '@/components/ProductCard';
import { useCart } from '@/context/CartContext';
import Link from 'next/link';
export default function HomePage() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const { cartItems } = useCart();
// Load products when the page opens
useEffect(() => {
fetch('/api/products')
.then((res) => res.json())
.then((data) => {
setProducts(data);
setLoading(false);
})
.catch(() => {
setError('Could not load products. Please refresh the page.');
setLoading(false);
});
}, []);
return (
<main className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold">Our T-Shirts</h1>
<Link href="/cart" className="relative">
🛒 Cart
{cartItems.length > 0 && (
<span className="ml-1 bg-red-500 text-white text-xs rounded-full px-2 py-0.5">
{cartItems.length}
</span>
)}
</Link>
</div>
{/* Loading state */}
{loading && <p className="text-center text-gray-500">Loading products...</p>}
{/* Error state */}
{error && <p className="text-center text-red-500">{error}</p>}
{/* Product grid */}
{!loading && !error && (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
)}
</main>
);
}Create app/cart/page.js:
'use client';
import { useCart } from '@/context/CartContext';
import Link from 'next/link';
export default function CartPage() {
const { cartItems, removeFromCart, cartTotal } = useCart();
// Show message if cart is empty
if (cartItems.length === 0) {
return (
<main className="max-w-2xl mx-auto px-4 py-16 text-center">
<h1 className="text-2xl font-bold mb-4">Your cart is empty</h1>
<Link href="/" className="text-blue-600 underline">
← Back to shop
</Link>
</main>
);
}
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Shopping Cart</h1>
{/* List each cart item */}
<ul className="space-y-4">
{cartItems.map((item) => (
<li key={item.variantId} className="flex items-center gap-4 border-b pb-4">
<img src={item.image} alt={item.name} className="w-20 h-20 object-cover rounded" />
<div className="flex-1">
<p className="font-medium">{item.name}</p>
<p className="text-gray-500">Qty: {item.quantity}</p>
<p className="text-gray-500">${(item.price * item.quantity).toFixed(2)}</p>
</div>
<button
onClick={() => removeFromCart(item.variantId)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</li>
))}
</ul>
{/* Total and checkout button */}
<div className="mt-6 text-right">
<p className="text-xl font-bold">Total: ${cartTotal.toFixed(2)}</p>
<Link
href="/checkout"
className="mt-4 inline-block bg-black text-white px-8 py-3 rounded hover:bg-gray-800 transition"
>
Proceed to Checkout →
</Link>
</div>
</main>
);
}- Go to stripe.com and create a free account
- Go to Developers → API Keys
- Copy your Publishable key (starts with
pk_test_) and Secret key (starts withsk_test_) - Add both to your
.env.localfile (see Step 5.3)
Create app/api/checkout/route.js:
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const { cartItems } = await request.json();
try {
// Build the list of items Stripe will show on its checkout page
const lineItems = cartItems.map((item) => ({
price_data: {
currency: 'usd',
product_data: {
name: item.name,
images: [item.image],
},
unit_amount: Math.round(item.price * 100), // Stripe uses cents
},
quantity: item.quantity,
}));
// Create the Stripe checkout session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: lineItems,
mode: 'payment',
// Where to redirect after successful payment
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
// Where to redirect if customer cancels
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}/cart`,
// Save cart items so the webhook can use them later
metadata: {
cartItems: JSON.stringify(cartItems),
},
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error('Stripe error:', error.message);
return NextResponse.json(
{ error: 'Payment setup failed. Please try again.' },
{ status: 500 }
);
}
}Create app/checkout/page.js:
'use client';
import { useCart } from '@/context/CartContext';
import { useState } from 'react';
export default function CheckoutPage() {
const { cartItems, cartTotal } = useCart();
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
async function handleCheckout() {
setLoading(true);
setError(null);
try {
const response = await fetch('/api/checkout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartItems }),
});
const data = await response.json();
if (data.url) {
// Redirect customer to Stripe's secure payment page
window.location.href = data.url;
} else {
setError('Could not start checkout. Please try again.');
}
} catch {
setError('An unexpected error occurred. Please try again.');
} finally {
setLoading(false);
}
}
return (
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Checkout</h1>
<div className="border rounded p-4 mb-6">
<h2 className="font-semibold mb-2">Order Summary</h2>
{cartItems.map((item) => (
<div key={item.variantId} className="flex justify-between text-sm py-1">
<span>{item.name} × {item.quantity}</span>
<span>${(item.price * item.quantity).toFixed(2)}</span>
</div>
))}
<div className="border-t mt-2 pt-2 font-bold flex justify-between">
<span>Total</span>
<span>${cartTotal.toFixed(2)}</span>
</div>
</div>
{error && <p className="text-red-500 mb-4">{error}</p>}
<button
onClick={handleCheckout}
disabled={loading}
className="w-full bg-black text-white py-3 rounded hover:bg-gray-800 transition disabled:opacity-50"
>
{loading ? 'Redirecting to payment...' : 'Pay with Card →'}
</button>
</main>
);
}When a customer successfully pays, Stripe sends your server a "webhook" — a notification saying "payment successful!" Your server then automatically places the order with Printful.
Create app/api/webhook/route.js:
import Stripe from 'stripe';
import axios from 'axios';
import { NextResponse } from 'next/server';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
export async function POST(request) {
const payload = await request.text();
const signature = request.headers.get('stripe-signature');
let event;
try {
// Verify the webhook actually came from Stripe (security check)
event = stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
console.error('Webhook signature verification failed:', error.message);
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 });
}
// Only act when payment is confirmed complete
if (event.type === 'checkout.session.completed') {
const session = event.data.object;
// Retrieve customer shipping address from Stripe
const stripeSession = await stripe.checkout.sessions.retrieve(session.id, {
expand: ['shipping_details'],
});
const shipping = stripeSession.shipping_details;
const cartItems = JSON.parse(session.metadata.cartItems);
// Build the order for Printful
const printfulOrder = {
recipient: {
name: shipping?.name || 'Customer',
address1: shipping?.address?.line1 || '',
address2: shipping?.address?.line2 || '',
city: shipping?.address?.city || '',
state_code: shipping?.address?.state || '',
country_code: shipping?.address?.country || 'US',
zip: shipping?.address?.postal_code || '',
},
items: cartItems.map((item) => ({
sync_variant_id: item.variantId, // Printful product variant ID
quantity: item.quantity,
})),
};
try {
// Send the order to Printful — they handle printing and shipping
await axios.post('https://api.printful.com/orders', printfulOrder, {
headers: {
Authorization: `Bearer ${process.env.PRINTFUL_API_KEY}`,
},
});
console.log('Order successfully sent to Printful for order:', session.id);
} catch (printfulError) {
console.error('Failed to create Printful order:', printfulError.message);
// In production, you would want to alert yourself here (e.g., send an email)
}
}
return NextResponse.json({ received: true });
}Update your app/api/checkout/route.js to collect the customer's shipping address:
// Add this inside stripe.checkout.sessions.create({ ... })
shipping_address_collection: {
allowed_countries: ['US', 'CA', 'GB', 'AU'], // Countries you ship to
},- Go to Stripe Dashboard → Developers → Webhooks
- Click Add endpoint
- Enter your website's webhook URL:
https://your-domain.com/api/webhook - Under Select events, choose
checkout.session.completed - Click Add endpoint
- Copy the Signing secret (starts with
whsec_) - Add it to
.env.local:STRIPE_WEBHOOK_SECRET=whsec_your_signing_secret_here
Testing webhooks locally:
While developing, use the Stripe CLI to forward events to your local machine:stripe listen --forward-to localhost:3000/api/webhook
- Create a new repository on github.com
- Follow GitHub's instructions to push your code
- Make absolutely sure
.env.localis in your.gitignore— never upload your secrets to GitHub!
Vercel is made by the same team as Next.js and makes deployment very easy.
- Go to vercel.com and sign up with your GitHub account
- Click New Project → Import your GitHub repository
- Add your environment variables:
- Click Environment Variables
- Add each key-value pair from your
.env.localfile - Update
NEXT_PUBLIC_BASE_URLto your actual Vercel URL (e.g.,https://my-shirt-store.vercel.app)
- Click Deploy
Vercel will automatically redeploy your site whenever you push new code to GitHub.
After deployment, go back to your Stripe webhook settings and update the URL to your live domain:
https://your-actual-domain.vercel.app/api/webhook
When you're ready to accept real payments:
- In Stripe, go to Developers → API Keys and switch to Live mode (toggle at top of page)
- Copy your live keys (they start with
pk_live_andsk_live_) - Update your environment variables on Vercel with the live keys
- Create a new live Stripe webhook and update
STRIPE_WEBHOOK_SECRET
- Check that your
PRINTFUL_API_KEYis set correctly in.env.local - Verify you created products in Printful (Catalog → Your Products)
- Check your browser's Developer Tools console for error messages (press
F12)
- Make sure you're using test keys during development (
pk_test_/sk_test_) - Use Stripe's test card number:
4242 4242 4242 4242(any future expiry, any CVC) - Check the Stripe Dashboard → Logs for detailed error information
- Check that your Stripe webhook is set up and active
- During local development, use the Stripe CLI to simulate webhook events:
stripe trigger checkout.session.completed
- Verify
STRIPE_WEBHOOK_SECRETmatches the one in your Stripe Dashboard
- Stop the development server (press
Ctrl + C) and restart it:npm run dev - Hard-refresh your browser:
Ctrl + Shift + R(Windows) orCmd + Shift + R(Mac)
npm run devThen open your browser and go to: http://localhost:3000
| Resource | Link |
|---|---|
| Printful API Documentation | developers.printful.com |
| Printful Product Catalog | printful.com/products |
| Stripe Documentation | stripe.com/docs |
| Stripe Testing Guide | stripe.com/docs/testing |
| Next.js Documentation | nextjs.org/docs |
| Tailwind CSS (for styling) | tailwindcss.com/docs |
| Vercel Deployment Guide | vercel.com/docs |
You now have everything you need to launch your own custom t-shirt store. Here's a quick recap of what you've built:
- ✅ A product catalog that automatically syncs with your Printful designs
- ✅ A shopping cart that tracks items and quantities
- ✅ A secure checkout powered by Stripe
- ✅ Automatic order fulfillment — Printful prints and ships for you
Suggested next steps:
- Add more shirt designs in Printful and they'll appear on your store automatically
- Customize the look and feel using Tailwind CSS classes
- Add a custom domain name through Vercel's settings
- Set up email notifications for customers using Resend or SendGrid