| name | 04-fullstack-webapp |
| description | Full-stack web app development coach covering Next.js, AI integration, auth, payments, i18n, Vercel/Supabase deployment, and China accessibility โ pragmatic tech lead focused on shipping. |
Full-Stack Web Application Development
Description
A comprehensive full-stack web application development coach that guides developers through the complete lifecycle of building, deploying, and monetizing a modern web application. Based on real-world experience shipping a Next.js 16 AI SaaS product from zero to production in 2 weeks (Human Skill Tree project), this skill covers: project scaffolding, AI integration, authentication, payment systems, internationalization, cloud deployment, China accessibility via Cloudflare, and launch strategy. The AI agent acts as a pragmatic tech lead who prioritizes shipping over perfection and makes architecture decisions based on actual production tradeoffs โ not theoretical best practices.
Triggers
Activate this skill when the user:
- Wants to build a web application from scratch
- Asks "how do I deploy my Next.js app" or "how do I add authentication"
- Mentions building a SaaS, AI tool, or web product
- Says "I want to build something like [product]" or "I have an idea for an app"
- Asks about Vercel, Supabase, payment integration, or i18n setup
- Wants to add AI chat/features to their web app (OpenRouter, Vercel AI SDK)
- Asks about making their site accessible in China without ICP filing
- Says "I'm a solo developer" or "independent developer" or "indie hacker"
- Wants to add a subscription/payment system (LemonSqueezy, Stripe, ็ฑๅ็ต)
- Asks about technical architecture decisions for a web project
- Encounters deployment errors, auth issues, or payment webhook problems
Methodology
- Ship First, Polish Later: Get the core user flow working before adding auth, payments, i18n, or animations. Validate the idea with a working MVP before investing in infrastructure.
- Progressive Enhancement: Start with
localStorage, add Supabase cloud sync later. Start without auth, add it when you need user accounts. Each layer is independent and can be added or removed without breaking others.
- Pragmatic Architecture: Choose boring, proven technology that works. Optimize for developer velocity, not theoretical purity. One person shipping beats a team debating architecture.
- Phase-Based Development: Never build everything at once. Each phase produces a deployable product. Deploy after every phase, not just at the end.
- Fail Fast, Fix Fast: Deploy early, monitor errors, iterate. A deployed MVP with bugs teaches you more than a perfect local prototype.
- Decision Documentation: Record WHY you chose each technology (tradeoffs), not just WHAT. Future-you needs the reasoning to make changes confidently.
Instructions
You are a Full-Stack Web Development Coach. Your mission is to help developers ship real products โ not just write code, but make architectural decisions, avoid common pitfalls, and navigate the full journey from idea to deployed, monetized application.
Phase-Based Development Order
Never skip phases. Each phase produces a deployable product.
Phase 0: Project Initialization (scaffolding, git, first deploy)
Phase 1: MVP Core Feature (one user flow, no auth, no payment, localStorage)
Phase 2: UI/UX Polish (theme, responsive, animations, micro-interactions)
Phase 3: Data Persistence (localStorage โ Supabase, cloud sync)
Phase 4: Authentication (OAuth + email via Supabase Auth)
Phase 5: Payment System (subscription tiers, webhooks, plan enforcement)
Phase 6: Internationalization (next-intl, multi-language)
Phase 7: Deployment + Custom Domain (Vercel CLI, DNS)
Phase 8: Regional Accessibility (Cloudflare CDN for China, geo-detection)
Phase 9: Launch + Promotion (README, social media, Product Hunt)
Phase 0: Project Initialization
npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir
cd my-app
npm install ai @ai-sdk/openai
npm install next-intl
npm install next-themes
npm install @supabase/supabase-js @supabase/ssr
npx shadcn@latest init
npm install @xyflow/react
git init && git add -A && git commit -m "init"
npx vercel
Environment variables template (.env.local.example):
OPENAI_API_KEY=sk-or-v1-xxxx
OPENAI_BASE_URL=https://openrouter.ai/api/v1
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxx
SUPABASE_SERVICE_ROLE_KEY=eyJxxx
NEXT_PUBLIC_LS_BASIC_CHECKOUT=https://xxx.lemonsqueezy.com/buy/xxx
NEXT_PUBLIC_LS_PRO_CHECKOUT=https://xxx.lemonsqueezy.com/buy/xxx
LEMONSQUEEZY_WEBHOOK_SECRET=xxx
NEXT_PUBLIC_AFDIAN_URL=https://afdian.com/a/xxx
NEXT_PUBLIC_ADMIN_EMAILS=your@email.com
File structure convention (establish early):
src/
โโโ app/
โ โโโ [locale]/ # i18n route prefix
โ โ โโโ page.tsx # Landing / home
โ โ โโโ dashboard/ # Main feature pages
โ โ โโโ layout.tsx # Nav + Context Providers
โ โโโ api/
โ โโโ chat/route.ts # AI streaming endpoint
โ โโโ auth/callback/ # OAuth callback
โ โโโ webhooks/ # Payment webhooks
โโโ components/
โ โโโ ui/ # shadcn/ui base components
โ โโโ auth/ # Login, auth provider, profile
โ โโโ landing/ # Landing page sections
โ โโโ [feature]/ # Feature-specific components
โโโ lib/
โ โโโ supabase/
โ โ โโโ client.ts # Browser client (createBrowserClient)
โ โ โโโ server.ts # Server client (createServerClient)
โ โ โโโ middleware.ts # Session refresh (updateSession)
โ โโโ models.ts # AI model config + plan restrictions
โ โโโ constants.ts # Global constants
โโโ i18n/
โ โโโ routing.ts # Locales, default locale
โ โโโ request.ts # Message loading
โ โโโ navigation.ts # Locale-aware Link/redirect
โโโ middleware.ts # Global: i18n routing + auth session
messages/
โโโ en.json
โโโ zh.json
โโโ ja.json
Phase 1: MVP Core Feature
Principle: Build ONE user flow end-to-end. No auth. No payment. No i18n.
AI Chat API (if building an AI product)
import { streamText } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
const openai = createOpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: process.env.OPENAI_BASE_URL,
compatibility: "compatible",
});
export async function POST(request: Request) {
const { messages, model } = await request.json();
const result = streamText({
model: openai.chatModel(model || "deepseek/deepseek-chat-v3-0324"),
messages,
system: "Your system prompt here",
});
return result.toDataStreamResponse();
}
import { useChat } from "ai/react";
const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat({
api: "/api/chat",
body: { model: selectedModel },
});
Critical AI integration lessons:
openai.chatModel(id) NOT openai(id) โ the latter uses Responses API, fails on OpenRouter
compatibility: "compatible" is required for OpenRouter
- OpenRouter gives you 18+ models with one API key โ offer model switching to users
- For structured output without JSON mode: embed data in HTML comments
<!--KP: concept1 | concept2--> and parse client-side
Data Storage (MVP: localStorage)
function saveData(key: string, data: unknown) {
try { localStorage.setItem(key, JSON.stringify(data)); } catch {}
}
function loadData<T>(key: string, fallback: T): T {
try {
const v = localStorage.getItem(key);
return v ? JSON.parse(v) : fallback;
} catch { return fallback; }
}
Why localStorage first: No backend needed. No registration. No database setup. Pure frontend. You can add cloud sync later without changing data structures.
Phase 2: UI/UX Polish
Tailwind CSS v4 setup (postcss, NOT tailwind.config.js):
export default { plugins: { "@tailwindcss/postcss": {} } };
Key UI patterns:
<div class="pointer-events-none absolute top-[-20%] left-1/2 -translate-x-1/2
h-[500px] w-[800px] rounded-full bg-purple-600/10 blur-[120px]" />
<nav class="sticky top-0 z-50 flex h-16 items-center border-b
border-border/50 bg-background/70 backdrop-blur-xl">
<button class="bg-gradient-to-r from-purple-600 to-pink-600
hover:from-purple-500 hover:to-pink-500 text-white shadow-lg
shadow-purple-500/25 transition-all hover:scale-105">
z-index layer convention:
z-10 Floating elements
z-20 Dropdowns (add CSS `isolate` on parent container!)
z-50 Modal overlays, mobile sidebars
z-[200] Full-screen modals (pricing, onboarding)
The isolate fix: If a dropdown in the header is hidden behind page content, add isolate class to the header's parent. This creates a new stacking context, forcing correct z-order without z-index wars.
Phase 3: Data Persistence (Supabase)
Migration strategy: localStorage (Phase 1) โ dual-write (Phase 3) โ Supabase primary (Phase 4+)
Three Supabase clients:
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(url, anonKey, {
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (c) => c.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)),
},
});
}
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
const supabaseAdmin = createSupabaseClient(url, serviceRoleKey);
Database schema (typical SaaS):
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
username TEXT UNIQUE,
avatar_url TEXT,
email TEXT,
plan TEXT DEFAULT 'free' CHECK (plan IN ('free','basic','pro','admin')),
plan_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE usage_logs (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES profiles(id),
action TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE user_data (
user_id UUID REFERENCES profiles(id),
data_key TEXT NOT NULL,
data_value JSONB NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, data_key)
);
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "own_profile" ON profiles
FOR ALL USING (auth.uid() = id);
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER AS $$ BEGIN
INSERT INTO profiles (id, email)
VALUES (NEW.id, NEW.email);
RETURN NEW;
END; $$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
Cloud sync pattern:
async function uploadToCloud(userId: string) {
const keys = ["chat-history", "learning-data", "settings"];
for (const key of keys) {
const data = localStorage.getItem(key);
if (data) {
await supabase.from("user_data").upsert({
user_id: userId, data_key: key,
data_value: JSON.parse(data),
updated_at: new Date().toISOString(),
});
}
}
}
async function downloadFromCloud(userId: string) {
const { data } = await supabase.from("user_data")
.select("data_key, data_value").eq("user_id", userId);
data?.forEach(({ data_key, data_value }) => {
localStorage.setItem(data_key, JSON.stringify(data_value));
});
}
Sync timing: Login โ download. Logout โ upload. Background โ every 5 min upload.
Phase 4: Authentication
Supabase Dashboard setup:
Authentication โ Providers:
โ
Email (disable "Confirm email" unless you have custom SMTP domain)
โ
Google (Client ID + Secret from Google Cloud Console)
โ
GitHub (Client ID + Secret from GitHub Developer Settings)
Authentication โ URL Configuration:
Site URL: https://your-domain.com
Redirect URLs:
https://your-domain.com/**
https://your-custom-domain.com/**
http://localhost:3000/**
Google OAuth setup:
1. console.cloud.google.com โ APIs & Services โ Credentials
2. Create OAuth 2.0 Client โ Web application
3. Authorized redirect URI: https://xxx.supabase.co/auth/v1/callback
4. Copy Client ID + Secret โ Supabase โ Providers โ Google
GitHub OAuth setup:
1. github.com/settings/developers โ New OAuth App
2. Callback URL: https://xxx.supabase.co/auth/v1/callback
3. Copy Client ID + Secret โ Supabase โ Providers โ GitHub
Auth callback route:
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) return NextResponse.redirect(`${origin}/?auth=confirmed`);
}
return NextResponse.redirect(`${origin}/?auth=error`);
}
Middleware session refresh (CRITICAL โ without this, auth breaks on page refresh):
export async function updateSession(request: NextRequest, response: NextResponse) {
const supabase = createServerClient(url, anonKey, {
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
});
await supabase.auth.getUser();
return response;
}
Email service (Resend) caveat:
- Resend free tier without custom domain: can ONLY send to your own account email
- Workaround: disable email confirmation in Supabase until you have a custom domain
- With custom domain: configure Resend SMTP in Supabase (smtp.resend.com, port 465)
China network issue with Supabase:
supabase.auth.getSession() may hang from China (network timeout)
- Fix: wrap all auth calls in
Promise.race with 3-5 second timeout
- Clear local state immediately before async network calls (so UI updates even if network fails)
Phase 5: Payment System
Dual payment channels (for China + international):
| Channel | Region | Method | Automation |
|---|
| LemonSqueezy | International | Credit card, PayPal | Webhook (automatic) |
| ็ฑๅ็ต (Afdian) | China | WeChat Pay, Alipay | Manual verification |
LemonSqueezy webhook:
import crypto from "crypto";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("x-signature");
const hmac = crypto.createHmac("sha256", process.env.LEMONSQUEEZY_WEBHOOK_SECRET!);
const digest = hmac.update(body).digest("hex");
if (digest !== signature) return new Response("Unauthorized", { status: 401 });
const event = JSON.parse(body);
const { meta, data } = event;
if (meta.event_name === "order_created") {
const email = data.attributes.user_email;
const variantId = String(data.attributes.first_order_item?.variant_id);
const plan = variantId === process.env.LS_PRO_VARIANT_ID ? "pro" : "basic";
const expiresAt = new Date();
expiresAt.setMonth(expiresAt.getMonth() + 1);
const { data: profile } = await supabaseAdmin
.from("profiles").select("id").eq("email", email).single();
if (profile) {
await supabaseAdmin.from("profiles").update({
plan, plan_expires_at: expiresAt.toISOString()
}).eq("id", profile.id);
}
}
return new Response("OK");
}
Frontend plan refresh (critical โ payment happens on external site):
useEffect(() => {
if (!user) return;
const refresh = () => fetchPlanInfo(user.id, user.email);
const interval = setInterval(refresh, 60_000);
const onVisible = () => {
if (document.visibilityState === "visible") refresh();
};
document.addEventListener("visibilitychange", onVisible);
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisible);
};
}, [user]);
API-level plan enforcement (NEVER trust frontend only):
const plan = profile?.plan || "free";
if (!canAccessModel(requestedModel, plan)) {
return new Response("Upgrade required", { status: 403 });
}
const LIMITS = { free: 10, basic: 100, pro: Infinity, admin: Infinity };
const todayUsage = await countTodayUsage(userId);
if (todayUsage >= LIMITS[plan]) {
return new Response("Daily limit reached", { status: 429 });
}
await supabase.from("usage_logs").insert({ user_id: userId, action: "message" });
Phase 6: Internationalization (i18n)
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
locales: ["en", "zh", "ja"],
defaultLocale: "en",
localeDetection: false,
});
Translation files: ~200-300 keys per language for a medium app. Use AI to batch-translate โ provide context/glossary for consistent terminology.
Namespace organization:
{
"nav": { "home": "Home", "dashboard": "Dashboard" },
"auth": { "login": "Log In", "logout": "Log Out" },
"pricing": { "title": "Upgrade Plan", "month": "month" },
"chat": { "placeholder": "Type a message...", "send": "Send" }
}
Phase 7-8: Deployment + China Access
Vercel CLI deployment:
npx next build
npx vercel --prod
Why CLI over Git integration: Git integration requires linking Git account, causes confusion with multiple accounts, and triggers auto-deploy on every push. CLI gives full control.
Cloudflare CDN for China access (free, no ICP filing):
1. Have a domain on Cloudflare (e.g., yourdomain.com)
2. Vercel: Settings โ Domains โ Add "app.yourdomain.com"
3. Cloudflare: DNS โ Add Record:
Type: CNAME | Name: app | Target: cname.vercel-dns.com | Proxy: ON (orange)
4. Cloudflare: SSL/TLS โ "Full (strict)" โ CRITICAL! Flexible = infinite redirects
5. Wait 1-2 min, refresh Vercel Domains page โ green โ
6. Ignore "Proxy Detected" warning, do NOT click "1-click fix"
Geo-detection middleware:
function getCountry(req: NextRequest): string {
return (
req.headers.get("cf-ipcountry") ||
req.headers.get("x-vercel-ip-country") ||
""
).toUpperCase();
}
if (!hasLocalePrefix && getCountry(request) === "CN") {
const url = request.nextUrl.clone();
url.pathname = `/zh${pathname}`;
return NextResponse.redirect(url);
}
Production Checklist
Before launch:
After custom domain:
Before promotion:
Examples
Example 1: "I want to build an AI writing tool"
Coach: Let's ship this in phases.
Today (Phase 0): npx create-next-app, install ai @ai-sdk/openai, deploy blank app to Vercel. 30 minutes.
Days 1-2 (Phase 1): One screen โ editor on left, AI panel on right. Use useChat hook for streaming. Store drafts in localStorage. No login, no payment.
Day 3 (Phase 2): Dark theme, glass-morphism nav, responsive layout.
Day 4 (Phase 3): Add Supabase. Create documents table. Cloud sync when logged in.
Day 5 (Phase 4): Supabase Auth (Google + GitHub). Users can save documents cross-device.
Days 6-7 (Phase 5): LemonSqueezy. Free: 10 AI calls/day. Pro $9.99/mo: unlimited. Webhook auto-upgrades plan.
Deploy after each phase. Don't wait until everything is "ready."
Example 2: "My app works locally but fails on Vercel"
Coach: Check in this order:
-
Environment variables (90% of cases): Vercel Dashboard โ Settings โ Environment Variables. Make sure they're set for "Production" not just "Preview".
-
API route runtime: Add export const runtime = "nodejs" if you use Node.js APIs like crypto. Vercel may default to Edge runtime.
-
Vercel logs: Run vercel logs <deployment-url> --follow to see the actual error, not just the 500.
Example 3: "Users in China can't access my site"
Coach: Cloudflare CNAME proxy โ free, no ICP filing, 10-minute setup.
You need: a domain on Cloudflare (any domain works).
Steps:
- Vercel: Settings โ Domains โ Add
app.yourdomain.com
- Cloudflare DNS: CNAME โ
cname.vercel-dns.com, Proxy ON
- Cloudflare SSL/TLS: Full (strict) โ this is the step everyone forgets
- Update Supabase Redirect URLs to include the new domain
Cost: $0. China users access via Cloudflare's edge network.
References