> cat internal-gift-card-generator.md

Shipping an internal Shopify gift card tool in one session

We send free product to brand ambassadors. The old way: someone packs a box by hand at HQ and ships it. The new way: issue a gift card, let the ambassador pick what they want, and the ecom 3PL handles the rest.

That’s a better experience for the ambassador and a lot less labor on our end. The only thing missing was a fast way to generate the cards internally without digging through Shopify Admin every time.

One session. Deployed. Works.

Here’s the stack and the two bugs I didn’t write.

What I built

A two-column internal tool: form on the left, history table on the right. Enter a name and a dollar amount, click generate, get a gift card code back. Copy it, send it to the ambassador, done. Every card goes into an Upstash Redis list so there’s a permanent log of what was created and by whom. Google OAuth in front, restricted to @everymanjack.com.

The Shopify piece is straightforward — one POST to the Admin REST API:

lib/shopify.ts
const res = await fetch(
`https://${SHOPIFY_DOMAIN}/admin/api/2024-01/gift_cards.json`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Shopify-Access-Token': SHOPIFY_TOKEN,
},
body: JSON.stringify({
gift_card: { initial_value: amount, note },
}),
}
);

Twenty-something lines including error handling. Redis is similarly thin — lpush on create, lrange 0 99 to list. The whole app is Next.js 15 App Router, deployed to Vercel.

Bug one: the build killed itself

First Vercel deployment failed immediately. The error: Failed to collect page data for /api/gift-cards.

The problem was Redis.fromEnv() sitting at module scope in lib/redis.ts. Next.js does a static analysis pass on every API route at build time, which imports the module, which calls fromEnv(), which throws because the Upstash env vars don’t exist in the build environment.

Fix is one line at the top of the route:

app/api/gift-cards/route.ts
export const dynamic = 'force-dynamic';

That tells Next.js to skip static analysis on this route entirely. It only runs at request time, when the env vars are actually present. Commit, push, green build.

Bug two: read is not write

Second deploy ran. Auth worked. Form rendered. Hit generate — 502.

Pulled the Shopify access scopes for the token:

Terminal window
curl -s "https://{store}.myshopify.com/admin/oauth/access_scopes.json" \
-H "X-Shopify-Access-Token: {token}"

The response had read_gift_cards and no write_gift_cards. The token was created with read-only access and nobody noticed because GET endpoints work fine.

The fix: go into the Shopify custom app config, add write_gift_cards, reinstall the app to generate a new token, swap it in Vercel. Five minutes once you know what you’re looking for.

The take

Neither bug was in the application code. The app itself was correct from the start. Both failures were environment-level: a framework behavior I hadn’t accounted for and a misconfigured third-party credential. That’s probably the majority of “why isn’t this deploying” conversations.

Claude built the whole thing. I debugged the two surprises. That division of labor is working well.

<- back to archive