Skills Development Full-Stack Web App Development Coach

Full-Stack Web App Development Coach

v20260325
04-fullstack-webapp
A comprehensive coach guiding developers through the entire SaaS lifecycle: from initial idea and scaffolding (Next.js) to building MVP features, integrating AI, setting up authentication, implementing payment systems (Stripe/LemonSqueezy), and achieving global deployment (Cloudflare/Vercel). Focuses on pragmatic, 'ship first' architecture decisions for solo developers and indie hackers.
Get Skill
148 downloads
Overview

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

# Core dependencies (install what you need)
npm install ai @ai-sdk/openai              # Vercel AI SDK (for AI features)
npm install next-intl                       # i18n (if multi-language)
npm install next-themes                     # Theme toggle
npm install @supabase/supabase-js @supabase/ssr  # Auth + Database

# UI components
npx shadcn@latest init                      # Component library
# Common components: button, card, dialog, input, badge, tabs, toast, scroll-area

# Visualization (if needed)
npm install @xyflow/react                   # Node graphs, flow charts

# Dev setup
git init && git add -A && git commit -m "init"
npx vercel                                  # First deploy (blank app)

Environment variables template (.env.local.example):

# AI (OpenRouter - one key for 18+ models)
OPENAI_API_KEY=sk-or-v1-xxxx
OPENAI_BASE_URL=https://openrouter.ai/api/v1

# Supabase (add when needed in Phase 3-4)
NEXT_PUBLIC_SUPABASE_URL=https://xxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxx
SUPABASE_SERVICE_ROLE_KEY=eyJxxx            # Server-only, never expose to client

# Payment (add when needed in Phase 5)
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

# Admin
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)

// src/app/api/chat/route.ts
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",  // CRITICAL for OpenRouter
});

export async function POST(request: Request) {
  const { messages, model } = await request.json();

  const result = streamText({
    model: openai.chatModel(model || "deepseek/deepseek-chat-v3-0324"),
    // ↑ MUST use .chatModel() not openai() directly
    // openai() defaults to Responses API which OpenRouter doesn't support
    messages,
    system: "Your system prompt here",
  });

  return result.toDataStreamResponse();
}
// Client-side: use useChat hook
import { useChat } from "ai/react";

const { messages, input, handleInputChange, handleSubmit, isLoading, stop } = useChat({
  api: "/api/chat",
  body: { model: selectedModel },
});

Critical AI integration lessons:

  1. openai.chatModel(id) NOT openai(id) — the latter uses Responses API, fails on OpenRouter
  2. compatibility: "compatible" is required for OpenRouter
  3. OpenRouter gives you 18+ models with one API key — offer model switching to users
  4. 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):

// postcss.config.mjs
export default { plugins: { "@tailwindcss/postcss": {} } };

Key UI patterns:

<!-- Ambient glow background -->
<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]" />

<!-- Glass navigation bar -->
<nav class="sticky top-0 z-50 flex h-16 items-center border-b
  border-border/50 bg-background/70 backdrop-blur-xl">

<!-- Gradient CTA button -->
<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:

// Browser client (components)
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
}

// Server client (server components, API routes)
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)),
    },
  });
}

// Service role client (webhooks only — full admin access)
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
const supabaseAdmin = createSupabaseClient(url, serviceRoleKey);

Database schema (typical SaaS):

-- User profiles with plan info
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()
);

-- Usage tracking (for rate limiting)
CREATE TABLE usage_logs (
  id BIGSERIAL PRIMARY KEY,
  user_id UUID REFERENCES profiles(id),
  action TEXT NOT NULL,       -- 'message', 'export', etc.
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Generic KV store for cloud sync (replaces localStorage)
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)
);

-- RLS: users can only access own data
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
CREATE POLICY "own_profile" ON profiles
  FOR ALL USING (auth.uid() = id);

-- Auto-create profile on signup
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:

// Upload: localStorage → Supabase (on sign-out, periodic)
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(),
      });
    }
  }
}

// Download: Supabase → localStorage (on sign-in)
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:

// src/app/api/auth/callback/route.ts
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):

// src/lib/supabase/middleware.ts
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();  // This refreshes the session cookie
  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:

// src/app/api/webhooks/lemonsqueezy/route.ts
import crypto from "crypto";

export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get("x-signature");

  // Verify HMAC signature (ALWAYS do this)
  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);

    // Map variant ID to plan
    const plan = variantId === process.env.LS_PRO_VARIANT_ID ? "pro" : "basic";
    const expiresAt = new Date();
    expiresAt.setMonth(expiresAt.getMonth() + 1);

    // Find user by email, update plan
    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):

// In auth-provider.tsx
useEffect(() => {
  if (!user) return;
  const refresh = () => fetchPlanInfo(user.id, user.email);

  // Poll every 60 seconds
  const interval = setInterval(refresh, 60_000);

  // Refresh when tab becomes visible (user returns from payment page)
  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):

// In API route: check plan + usage before processing
const plan = profile?.plan || "free";

// Check model access
if (!canAccessModel(requestedModel, plan)) {
  return new Response("Upgrade required", { status: 403 });
}

// Check daily usage limit
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 });
}

// Log usage
await supabase.from("usage_logs").insert({ user_id: userId, action: "message" });

Phase 6: Internationalization (i18n)

// src/i18n/routing.ts
import { defineRouting } from "next-intl/routing";
export const routing = defineRouting({
  locales: ["en", "zh", "ja"],
  defaultLocale: "en",
  localeDetection: false,  // We handle detection manually in middleware
});

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          # Verify locally first
npx vercel --prod       # Deploy to production

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") ||        // Cloudflare
    req.headers.get("x-vercel-ip-country") ||  // Vercel
    ""
  ).toUpperCase();
}

// In middleware: auto-redirect Chinese users to /zh
if (!hasLocalePrefix && getCountry(request) === "CN") {
  const url = request.nextUrl.clone();
  url.pathname = `/zh${pathname}`;
  return NextResponse.redirect(url);
}

Production Checklist

Before launch:

  • npx next build passes locally
  • All env vars set in Vercel (Production environment)
  • Supabase RLS policies enabled on all tables
  • API routes verify auth + check usage limits
  • Webhook endpoints verify signatures
  • SUPABASE_SERVICE_ROLE_KEY never in NEXT_PUBLIC_*
  • Mobile responsive tested
  • Error handling: API timeout, network disconnect, model unavailable

After custom domain:

  • Supabase Redirect URLs include new domain
  • Cloudflare SSL = Full (strict)
  • Both domains accessible (old + new)
  • Test from China network (disable VPN)

Before promotion:

  • README has demo GIF
  • Landing page has clear CTA
  • Visitor badge + Star History in README

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:

  1. Environment variables (90% of cases): Vercel Dashboard → Settings → Environment Variables. Make sure they're set for "Production" not just "Preview".

  2. API route runtime: Add export const runtime = "nodejs" if you use Node.js APIs like crypto. Vercel may default to Edge runtime.

  3. 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:

  1. Vercel: Settings → Domains → Add app.yourdomain.com
  2. Cloudflare DNS: CNAME → cname.vercel-dns.com, Proxy ON
  3. Cloudflare SSL/TLS: Full (strict) — this is the step everyone forgets
  4. Update Supabase Redirect URLs to include the new domain

Cost: $0. China users access via Cloudflare's edge network.

References

Info
Category Development
Name 04-fullstack-webapp
Version v20260325
Size 23.82KB
Updated At 2026-04-22
Language