Bootstrap a new project: $ARGUMENTS
$ARGUMENTS can include:
- Project name (e.g. "my-app")
- Framework hint: "nextjs" or "vite" (if omitted, the skill asks)
- "with-stripe" to include Stripe setup
- "with-gsap" to include GSAP animation library
- "with-ai" to include Vercel AI SDK + OpenAI dependencies
- "minimal" to skip optional libraries and get the leanest possible setup
- Empty — asks all questions interactively
Purpose
This skill configures the complete production stack used across all projects in this workspace. It handles both frameworks with identical auth, styling, and monitoring patterns. The end result is a running pnpm dev with:
- React 19 + TypeScript 5 (strict)
- Tailwind CSS v3 + shadcn/ui (new-york style, CSS variable theming)
- Supabase auth + database (browser and server clients)
- Sentry error monitoring (production-only, redacts secrets)
- React Query (
@tanstack/react-query) for data fetching - React Hook Form + Zod validation
- Lucide React icons
- next-themes dark mode
- Zod-validated environment variables
- Semantic token system (light + dark mode)
pnpmonly (never npm or yarn)
Phase 0 — Gather Requirements
Ask the user all of the following before writing any files:
Q1: Framework
"Vite or Next.js?
- Vite — SPA, client-side routing (React Router), ideal for dashboards, tools, or apps without SSR/SEO needs
- Next.js 15 — App Router, SSR/RSC, file-based routing, ideal for content sites, marketing pages, and full-stack apps
Type
viteornextjs:"
Q2: Project name
"Project name? (used for package.json
name, folder, and Sentry project slug)"
Q3: App URL
"Production URL? (e.g.
https://myapp.com) — used in metadata and Supabase redirect config. Leave blank to set later."
Q4: Supabase project
"Supabase project URL? (e.g.
https://xyzabcdef.supabase.co) — paste your Project URL from the Supabase dashboard. Leave blank to configure later." Also ask: "Supabase Anon key? (from Supabase → Project Settings → API)"
Q5: Sentry (optional)
"Include Sentry error monitoring? (y/n) — If yes, paste your Sentry DSN (from sentry.io → Project → Client Keys)."
Q6: Optional libraries
"Which optional libraries do you need? (space-separated, or press enter to skip)
stripe— payment + subscriptionsgsap— scroll animations, micro-interactionsai— Vercel AI SDK +@ai-sdk/openaifor streaming LLM responsesresend— transactional emailmotion— Framer Motion (lightweight alternative to GSAP for component animations)"
Q7: Font pair
"Font pair? (or press enter for the default)
- Default: Newsreader (serif headings) + Manrope (sans body) — editorial, warm, scholarly
inter— Inter + Inter (clean, modern, SaaS-friendly)geist— Geist Sans + Geist Mono (Vercel's system font)custom— I'll configure my own after setup"
Confirm all answers with the user before proceeding.
Phase 1 — Scaffold the Project
If Vite:
Check if the project directory exists. If not, create it:
pnpm create vite@latest <project-name> -- --template react-ts
cd <project-name>
Remove the Vite template boilerplate:
- Delete
src/App.css,src/index.css(will be replaced by globals.css) - Clear
src/App.tsxcontent - Clear
src/main.tsxcontent (will be rewritten)
If Next.js:
Check if the project directory exists. If not, create it:
pnpm create next-app@latest <project-name> --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --no-git
cd <project-name>
Remove Next.js template boilerplate:
- Clear
src/app/page.tsx(replace with minimal placeholder) - Clear
src/app/globals.css(will be rewritten with full token system) - Remove
public/vercel.svg,public/next.svg
Phase 2 — Install Core Dependencies
Run in the project root. Split into logical groups so failures are easy to diagnose.
Always install
# UI + styling
pnpm add tailwindcss-animate class-variance-authority clsx tailwind-merge lucide-react next-themes
# Forms + validation
pnpm add react-hook-form @hookform/resolvers zod
# Data fetching
pnpm add @tanstack/react-query
# Supabase (always both packages — SSR version covers all cases)
pnpm add @supabase/supabase-js @supabase/ssr
# Dev tools
pnpm add -D tsx dotenv
Next.js only
# Sentry for Next.js (wraps webpack, instruments RSC + edge)
pnpm add @sentry/nextjs
Vite only
# React Router for client-side routing
pnpm add react-router-dom
# Sentry for React (browser only in Vite)
pnpm add @sentry/react
If stripe requested
pnpm add stripe @stripe/stripe-js
If gsap requested
pnpm add gsap @gsap/react
If ai requested
pnpm add ai @ai-sdk/openai @ai-sdk/react
If resend requested
pnpm add resend
If motion requested
pnpm add motion
After installation, initialize shadcn/ui:
pnpm dlx shadcn@latest init
When prompted by the shadcn CLI:
- Style: New York
- Base color: Neutral
- CSS variables: Yes
- Tailwind config:
tailwind.config.ts - Global CSS:
src/app/globals.css(Next.js) orsrc/index.css→ rename tosrc/globals.css(Vite) - Components alias:
@/components - Utils alias:
@/lib/utils
Install the core shadcn components immediately after init:
pnpm dlx shadcn@latest add button card input label badge tabs select textarea dialog sheet dropdown-menu avatar separator progress scroll-area accordion
Phase 3 — TypeScript Config
tsconfig.json (Next.js — overwrite what create-next-app generated)
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
tsconfig.json (Vite — overwrite what create-vite generated)
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"],
"exclude": ["node_modules"]
}
Phase 4 — Tailwind Config
Write tailwind.config.ts (same for both frameworks, adjust content paths):
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
// Next.js:
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
// Vite (replace the above with):
// "./index.html",
// "./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
fontFamily: {
sans: ["var(--font-body)", "ui-sans-serif", "system-ui", "-apple-system", "sans-serif"],
heading: ["var(--font-heading)", "system-ui", "sans-serif"],
display: ["var(--font-heading)", "system-ui", "sans-serif"],
mono: ["var(--font-mono)", "ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
serif: ["var(--font-heading)", "var(--font-serif)", "ui-serif", "Georgia", "serif"],
body: ["var(--font-body)", "ui-sans-serif", "system-ui", "sans-serif"],
label: ["var(--font-body)", "system-ui", "sans-serif"],
},
fontSize: {
display: ["56px", { lineHeight: "1.05", letterSpacing: "-0.02em", fontWeight: "800" }],
h1: ["48px", { lineHeight: "1.1", letterSpacing: "-0.02em", fontWeight: "800" }],
h2: ["40px", { lineHeight: "1.15", letterSpacing: "-0.01em", fontWeight: "700" }],
h3: ["28px", { lineHeight: "1.2", fontWeight: "600" }],
h4: ["20px", { lineHeight: "1.25", fontWeight: "600" }],
"body-lg": ["18px", { lineHeight: "1.7", fontWeight: "400" }],
body: ["16px", { lineHeight: "1.7", fontWeight: "400" }],
small: ["14px", { lineHeight: "1.6", fontWeight: "400" }],
label: ["12px", { lineHeight: "1.4", letterSpacing: "0.04em", fontWeight: "500" }],
button: ["14px", { lineHeight: "1.2", fontWeight: "600" }],
micro: ["10px", { lineHeight: "1.4", letterSpacing: "0.05em", fontWeight: "700" }],
},
borderRadius: {
"card-lg": "2.5rem",
button: "var(--radius-button)",
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
maxWidth: {
content: "var(--container-content)",
measure: "var(--measure)",
"measure-wide": "var(--measure-wide)",
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
success: { DEFAULT: "hsl(var(--success))", foreground: "hsl(var(--success-foreground))" },
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
nav: { DEFAULT: "hsl(var(--nav-bg))", foreground: "hsl(var(--nav-fg))" },
surface: "hsl(var(--surface))",
"surface-bright": "hsl(var(--surface-bright))",
"surface-container-lowest": "hsl(var(--surface-container-lowest))",
"surface-container-low": "hsl(var(--surface-container-low))",
"surface-container": "hsl(var(--surface-container))",
"surface-container-high": "hsl(var(--surface-container-high))",
"surface-container-highest": "hsl(var(--surface-container-highest))",
"primary-container": "hsl(var(--primary-container))",
"on-primary-container": "hsl(var(--on-primary-container))",
"outline-variant": "hsl(var(--outline-variant))",
"surface-light": "hsl(var(--bg-light))",
"surface-warm": "hsl(var(--bg-warm))",
"surface-elevated": "hsl(var(--bg-elevated))",
"border-light": "hsl(var(--border-light))",
"text-dark": "hsl(var(--text-dark))",
"text-muted-dark": "hsl(var(--text-muted-dark))",
chart: {
"1": "hsl(var(--chart-1))",
"2": "hsl(var(--chart-2))",
"3": "hsl(var(--chart-3))",
"4": "hsl(var(--chart-4))",
"5": "hsl(var(--chart-5))",
},
},
keyframes: {
"accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" } },
"accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" } },
"fade-up": { from: { opacity: "0", transform: "translateY(16px)" }, to: { opacity: "1", transform: "translateY(0)" } },
"fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"fade-up": "fade-up 0.5s ease-out forwards",
"fade-in": "fade-in 0.4s ease-out forwards",
},
transitionDuration: {
fast: "var(--duration-fast)",
normal: "var(--duration-normal)",
slow: "var(--duration-slow)",
},
transitionTimingFunction: {
out: "var(--ease-out)",
expressive: "var(--ease-expressive)",
},
boxShadow: {
"nav-scroll": "0 4px 6px -1px hsl(var(--foreground) / 0.08), 0 2px 4px -2px hsl(var(--foreground) / 0.05)",
"primary-glow": "var(--shadow-primary-glow)",
"primary-glow-sm": "var(--shadow-primary-glow-sm)",
"primary-glow-md": "var(--shadow-primary-glow-md)",
"primary-glow-lg": "var(--shadow-primary-glow-lg)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;
Font variants: If the user chose
inter, replace the CSS variable references withInter, ui-sans-serif. Ifgeist, useGeist, ui-sans-serif. The variable wiring stays the same — only the font names change in globals.css.
Phase 5 — Globals CSS (Token System)
Write src/app/globals.css (Next.js) or src/globals.css (Vite) with the full token system.
This is the core visual identity file. The tokens below are neutral defaults. The user should customize --primary, --background, and font variables for their brand.
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
/* === Neutral Default Palette === */
/* Swap these HSL values for your brand colors */
--background: 0 0% 98%;
--foreground: 224 71% 4%;
--card: 0 0% 100%;
--card-foreground: 224 71% 4%;
--popover: 0 0% 100%;
--popover-foreground: 224 71% 4%;
--primary: 220 90% 56%; /* Brand blue — REPLACE WITH YOUR COLOR */
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 96%;
--secondary-foreground: 220 9% 46%;
--muted: 220 14% 96%;
--muted-foreground: 220 9% 46%;
--accent: 220 14% 96%;
--accent-foreground: 220 9% 46%;
--destructive: 0 74% 42%;
--destructive-foreground: 0 0% 98%;
--success: 142 71% 28%;
--success-foreground: 0 0% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 220 90% 56%;
--radius: 0.5rem;
--radius-button: 9999px;
/* === Typography === */
/* Set font families here; Tailwind config reads via var() */
--font-heading: system-ui, sans-serif; /* Override with your heading font */
--font-body: system-ui, sans-serif; /* Override with your body font */
--font-serif: Georgia, serif;
--font-mono: ui-monospace, monospace;
--line-height-heading: 1.1;
--line-height-body: 1.65;
/* === Motion === */
--duration-fast: 150ms;
--duration-normal: 300ms;
--duration-slow: 450ms;
--ease-out: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-expressive: cubic-bezier(0.16, 1, 0.3, 1);
/* === Layout === */
--container-content: 90rem;
--measure: min(65ch, 50rem);
--measure-wide: min(90rem, 95vw);
/* === Surface hierarchy (Material 3 inspired) === */
--surface: var(--background);
--surface-bright: 0 0% 100%;
--surface-container-lowest: 0 0% 100%;
--surface-container-low: 220 14% 97%;
--surface-container: 220 14% 95%;
--surface-container-high: 220 14% 93%;
--surface-container-highest: 220 14% 90%;
--primary-container: 220 90% 96%;
--on-primary-container: 220 90% 30%;
--outline-variant: 220 13% 85%;
--bg-elevated: 220 14% 95%;
--bg-light: 0 0% 98%;
--bg-warm: 30 20% 97%;
--border-light: 220 13% 88%;
--text-dark: 224 71% 4%;
--text-muted-dark: 220 9% 40%;
/* === Nav === */
--nav-bg: var(--surface-bright);
--nav-fg: var(--foreground);
/* === Charts === */
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
/* === Shadows === */
--shadow-primary-glow: 0 4px 14px hsl(var(--primary) / 0.12);
--shadow-primary-glow-sm: 0 2px 8px hsl(var(--primary) / 0.08);
--shadow-primary-glow-md: 0 8px 32px hsl(var(--primary) / 0.06);
--shadow-primary-glow-lg: 0 16px 48px hsl(var(--primary) / 0.06);
}
.dark {
--background: 224 71% 4%;
--foreground: 213 31% 91%;
--card: 224 71% 6%;
--card-foreground: 213 31% 91%;
--popover: 224 71% 6%;
--popover-foreground: 213 31% 91%;
--primary: 210 100% 66%;
--primary-foreground: 224 71% 4%;
--secondary: 222 47% 11%;
--secondary-foreground: 215 20% 65%;
--muted: 223 47% 11%;
--muted-foreground: 215 20% 65%;
--accent: 216 34% 17%;
--accent-foreground: 210 40% 98%;
--destructive: 0 63% 31%;
--destructive-foreground: 210 40% 98%;
--success: 142 60% 42%;
--success-foreground: 0 0% 98%;
--border: 216 34% 17%;
--input: 216 34% 17%;
--ring: 210 100% 66%;
--surface: 224 71% 4%;
--surface-bright: 224 71% 6%;
--surface-container-lowest: 224 71% 3%;
--surface-container-low: 224 71% 5%;
--surface-container: 224 71% 7%;
--surface-container-high: 224 71% 9%;
--surface-container-highest: 224 71% 12%;
--primary-container: 220 60% 12%;
--on-primary-container: 210 80% 70%;
--outline-variant: 217 33% 20%;
--bg-elevated: 224 71% 7%;
--bg-light: 224 71% 4%;
--bg-warm: 220 30% 6%;
--border-light: 217 33% 20%;
--text-dark: 213 31% 91%;
--text-muted-dark: 215 20% 65%;
--nav-bg: var(--surface-bright);
--nav-fg: var(--foreground);
--chart-1: 210 100% 66%;
--chart-2: 160 60% 55%;
--chart-3: 30 80% 65%;
--chart-4: 280 65% 70%;
--chart-5: 340 75% 65%;
}
}
/* Global resets */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: var(--font-body, system-ui, sans-serif);
line-height: var(--line-height-body);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
h1, h2, h3, h4, h5, h6 {
font-family: var(--font-heading, system-ui, sans-serif);
line-height: var(--line-height-heading);
}
If user chose a named font pair:
inter→ add@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap')and set--font-heading: 'Inter', ...and--font-body: 'Inter', ...geist→ installgeistpackage (pnpm add geist) and set vars accordingly- For Next.js font pairs, use
next/font/googlein layout.tsx instead of CSS imports (preferred)
Phase 6 — Environment Variable Validation
Write src/lib/env.ts:
/**
* Zod-validated environment variables.
* Import `env` instead of accessing `process.env` directly.
*/
import { z } from "zod";
const envSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
// Supabase (required for auth + database)
NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(), // Next.js: NEXT_PUBLIC_ prefix
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().optional(), // Vite: use VITE_ prefix instead
// VITE_SUPABASE_URL: z.string().url().optional(), // Vite variant
// VITE_SUPABASE_ANON_KEY: z.string().optional(), // Vite variant
// Sentry (optional — app runs without it)
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
SENTRY_DSN: z.string().url().optional(),
SENTRY_AUTH_TOKEN: z.string().optional(),
SENTRY_ORG: z.string().optional(),
SENTRY_PROJECT: z.string().optional(),
// Stripe (optional)
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().optional(),
// AI (optional)
OPENAI_API_KEY: z.string().optional(),
OPENAI_MODEL: z.string().optional(),
// Email (optional)
RESEND_API_KEY: z.string().optional(),
RESEND_FROM_DOMAIN: z.string().optional(),
// Multi-tenant org scoping (optional — used when one deployment = one org)
TENANT_ORG_ID: z.string().uuid().optional(),
});
export type Env = z.infer<typeof envSchema>;
const parsed = envSchema.safeParse(
typeof process !== "undefined" ? process.env : import.meta.env // works for both Next.js and Vite
);
if (!parsed.success) {
console.error("Environment validation failed:", parsed.error.flatten());
throw new Error("Invalid environment variables — check your .env.local");
}
export const env = parsed.data;
Write .env.local.example (never .env — secrets stay out of git):
# Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# For Vite projects, use VITE_ prefix instead:
# VITE_SUPABASE_URL=https://your-project.supabase.co
# VITE_SUPABASE_ANON_KEY=your-anon-key
# Database (for Drizzle ORM — not needed if using Supabase JS client only)
# DATABASE_URL=postgresql://postgres:[password]@db.your-project.supabase.co:5432/postgres
# Sentry (optional — errors only sent in production)
NEXT_PUBLIC_SENTRY_DSN=https://xxx@xxx.ingest.sentry.io/xxx
SENTRY_AUTH_TOKEN=your-auth-token
SENTRY_ORG=your-org
SENTRY_PROJECT=your-project
# Stripe (optional)
STRIPE_SECRET_KEY=sk_live_xxx
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
# AI (optional)
OPENAI_API_KEY=sk-xxx
OPENAI_MODEL=gpt-4o
# Email (optional)
RESEND_API_KEY=re_xxx
RESEND_FROM_DOMAIN=hello@yourapp.com
# Multi-tenant scoping (optional)
TENANT_ORG_ID=your-org-uuid
Add .env.local to .gitignore (if not already present).
Phase 7 — Supabase Clients
Next.js: Browser client
src/lib/supabase/client.ts:
import { createBrowserClient } from "@supabase/ssr";
export function createSupabaseBrowserClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}
Next.js: Server client
src/lib/supabase/server.ts:
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createSupabaseServerClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
} catch {
// Called from Server Component — safe to ignore; middleware refreshes the session.
}
},
},
},
);
}
Next.js: Middleware
src/middleware.ts:
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
function isRefreshTokenError(error: unknown): boolean {
return (
typeof error === "object" && error !== null &&
"__isAuthError" in error &&
(error as { code?: string }).code === "refresh_token_not_found"
);
}
function clearAuthCookies(request: NextRequest, response: NextResponse) {
request.cookies.getAll().forEach(({ name }) => {
if (name.startsWith("sb-") && name.includes("-auth-token")) {
response.cookies.set(name, "", { maxAge: 0, path: "/" });
}
});
}
export async function middleware(request: NextRequest) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
return NextResponse.next({ request });
}
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() { return request.cookies.getAll(); },
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value));
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options),
);
},
},
});
let user: { id: string } | null = null;
try {
const { data } = await supabase.auth.getUser();
user = data.user;
} catch (error) {
if (isRefreshTokenError(error)) {
clearAuthCookies(request, supabaseResponse);
} else {
console.error("[middleware] Supabase auth error:", error);
}
}
// ---- Protected routes ----
// Edit this list to match your app's protected paths
const path = request.nextUrl.pathname;
const isProtected =
path === "/dashboard" ||
path.startsWith("/dashboard/") ||
path === "/account" ||
path.startsWith("/account/");
if (isProtected && !user) {
const redirectUrl = request.nextUrl.clone();
redirectUrl.pathname = "/auth/signin";
redirectUrl.searchParams.set("redirect", path);
return NextResponse.redirect(redirectUrl);
}
return supabaseResponse;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
};
Vite: Supabase client (browser only)
src/lib/supabase/client.ts:
import { createClient } from "@supabase/supabase-js";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error("Missing VITE_SUPABASE_URL or VITE_SUPABASE_ANON_KEY");
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey);
Vite note: Use
@supabase/supabase-jsdirectly (not@supabase/ssr) since there's no server. The@supabase/ssrpackage is still installed (included in the install step) but only the browser client is used. You can add server functions via Supabase Edge Functions.
Phase 8 — Sentry Setup
Skip this phase if the user declined Sentry.
Next.js: Three config files
sentry.client.config.ts:
import * as Sentry from "@sentry/nextjs";
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN;
const isProduction = process.env.NODE_ENV === "production";
if (dsn && isProduction) {
Sentry.init({
dsn,
enabled: true,
enableLogs: true,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration({ maskAllText: true, maskAllInputs: true, blockAllMedia: true }),
Sentry.consoleLoggingIntegration({ levels: ["warn", "error"] }),
],
beforeSend(event) {
const req = event.request as { headers?: Record<string, string> } | undefined;
if (req?.headers) {
for (const k of ["authorization", "cookie", "x-api-key"]) {
const match = Object.keys(req.headers).find((h) => h.toLowerCase() === k);
if (match) req.headers[match] = "[Redacted]";
}
}
return event;
},
ignoreErrors: [/^chrome-extension:\/\//i, /^moz-extension:\/\//i, "Network Error", "AbortError"],
});
}
sentry.server.config.ts:
import * as Sentry from "@sentry/nextjs";
const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({
dsn,
tracesSampleRate: 0.1,
enableLogs: true,
sendDefaultPii: false,
});
}
sentry.edge.config.ts:
import * as Sentry from "@sentry/nextjs";
const dsn = process.env.SENTRY_DSN || process.env.NEXT_PUBLIC_SENTRY_DSN;
if (dsn) {
Sentry.init({ dsn, tracesSampleRate: 0.1, enableLogs: true });
}
Next.js: next.config.ts with Sentry wrapper
next.config.ts:
import type { NextConfig } from "next";
import { withSentryConfig } from "@sentry/nextjs";
const nextConfig: NextConfig = {
trailingSlash: false,
compress: true,
productionBrowserSourceMaps: false,
serverExternalPackages: ["postgres", "drizzle-orm", "@supabase/ssr", "sharp"],
images: {
remotePatterns: [
{ protocol: "https", hostname: "*.supabase.co", pathname: "/storage/**" },
],
},
};
export default withSentryConfig(nextConfig, {
org: process.env.SENTRY_ORG ?? "<your-sentry-org>",
project: process.env.SENTRY_PROJECT ?? "<your-sentry-project>",
silent: !process.env.CI,
widenClientFileUpload: true,
webpack: { treeshake: { removeDebugLogging: true } },
});
Next.js without Sentry: plain next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
trailingSlash: false,
compress: true,
productionBrowserSourceMaps: false,
serverExternalPackages: ["postgres", "drizzle-orm", "@supabase/ssr", "sharp"],
images: {
remotePatterns: [
{ protocol: "https", hostname: "*.supabase.co", pathname: "/storage/**" },
],
},
};
export default nextConfig;
Vite: Sentry setup
src/lib/sentry.ts:
import * as Sentry from "@sentry/react";
export function initSentry() {
const dsn = import.meta.env.VITE_SENTRY_DSN as string | undefined;
const isProduction = import.meta.env.PROD;
if (dsn && isProduction) {
Sentry.init({
dsn,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0,
integrations: [
Sentry.replayIntegration({ maskAllText: true, maskAllInputs: true }),
],
ignoreErrors: ["Network Error", "AbortError"],
});
}
}
Call initSentry() at the top of src/main.tsx before rendering the app.
Phase 9 — Providers & Layout
Next.js: Providers
src/app/providers.tsx:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { ThemeProvider } from "next-themes";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
}),
);
return (
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</ThemeProvider>
);
}
Next.js: Root layout
src/app/layout.tsx — font loading depends on the user's chosen font pair.
For the default (Newsreader + Manrope) pair:
import type { Metadata } from "next";
import { Newsreader, Manrope, JetBrains_Mono } from "next/font/google";
import { Providers } from "./providers";
import "./globals.css";
const newsreader = Newsreader({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700"],
style: ["normal", "italic"],
variable: "--font-heading",
display: "swap",
});
const manrope = Manrope({
subsets: ["latin"],
weight: ["300", "400", "500", "600", "700", "800"],
variable: "--font-body",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
subsets: ["latin"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Your App",
description: "Your app description",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`${newsreader.variable} ${manrope.variable} ${jetbrainsMono.variable}`}>
<Providers>{children}</Providers>
</body>
</html>
);
}
For inter: Use Inter from next/font/google for both --font-heading and --font-body.
For geist: Install geist package; import GeistSans and GeistMono.
For custom: Use var(--font-heading) placeholders — the user fills in the font later.
Next.js: Minimal placeholder page
src/app/page.tsx:
export default function HomePage() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-h1 font-heading">Your app is running</h1>
<p className="text-muted-foreground">Edit <code>src/app/page.tsx</code> to get started.</p>
</div>
</main>
);
}
Vite: main.tsx entry
src/main.tsx:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "next-themes";
import { initSentry } from "./lib/sentry"; // omit if no Sentry
import App from "./App";
import "./globals.css";
initSentry(); // omit if no Sentry
const queryClient = new QueryClient({
defaultOptions: {
queries: { staleTime: 60 * 1000, refetchOnWindowFocus: false },
},
});
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</ThemeProvider>
</BrowserRouter>
</StrictMode>,
);
Vite: App.tsx
src/App.tsx:
import { Routes, Route } from "react-router-dom";
function HomePage() {
return (
<main className="flex min-h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-h1 font-heading">Your app is running</h1>
<p className="text-muted-foreground">Edit <code>src/App.tsx</code> to get started.</p>
</div>
</main>
);
}
export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
);
}
Vite: vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 3000,
},
});
Phase 10 — Package.json Scripts
Add/merge these into package.json scripts (do not replace existing scripts):
Next.js scripts
{
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx",
"check:env": "tsx scripts/check-env.ts"
}
}
Vite scripts (already set by create-vite, but standardize)
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit",
"lint": "eslint . --ext .ts,.tsx",
"check:env": "tsx scripts/check-env.ts"
}
}
Write scripts/check-env.ts — validates all env vars and reports missing ones:
import { config } from "dotenv";
config({ path: ".env.local" });
const required: string[] = [
// Add vars that are truly required for your app to function:
// "NEXT_PUBLIC_SUPABASE_URL",
// "NEXT_PUBLIC_SUPABASE_ANON_KEY",
];
const missing = required.filter((k) => !process.env[k]);
if (missing.length > 0) {
console.error("Missing required env vars:", missing.join(", "));
process.exit(1);
}
console.log("Environment OK");
Phase 11 — Utility Helpers
src/lib/utils.ts (shadcn standard — may already exist after init)
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
src/lib/tenant.ts (multi-tenant scope — always useful, opt-in at runtime)
/**
* Returns the tenant org ID from TENANT_ORG_ID env var.
* Services use this to scope queries by organization_id.
* Returns null when not set — app works without it for single-tenant deployments.
*/
export function getTenantOrgId(): string | null {
const id = typeof process !== "undefined"
? process.env.TENANT_ORG_ID
: (import.meta.env as Record<string, string>).VITE_TENANT_ORG_ID;
return id ?? null;
}
Phase 12 — Verify the Setup
Run in order and show all output to the user:
# 1. Type check
pnpm typecheck
# 2. Lint
pnpm lint
# 3. Start dev server (just confirm it starts, then ctrl+c)
pnpm dev
If pnpm typecheck or pnpm lint fails, fix the errors before declaring success.
Common issues:
- Missing
@types/node:pnpm add -D @types/node - shadcn components not found: re-run
pnpm dlx shadcn@latest add <component> next/fontmodule not found in Vite: Only usenext/fontin Next.js — Vite uses CSS@importorfontsourcepackagesimport.meta.envtypes missing in Vite: add/// <reference types="vite/client" />tosrc/vite-env.d.ts
Phase 13 — Final Report
Output a structured summary:
## Project Setup Complete
### Project
- **Name:** [name]
- **Framework:** [Next.js 15 App Router | Vite + React Router]
- **Location:** [path]
### Installed Libraries
| Category | Packages |
|----------|---------|
| UI | tailwindcss, shadcn/ui (new-york), lucide-react, tailwindcss-animate |
| Styling | class-variance-authority, clsx, tailwind-merge, next-themes |
| Forms | react-hook-form, @hookform/resolvers, zod |
| Data | @tanstack/react-query |
| Auth/DB | @supabase/supabase-js, @supabase/ssr |
| Monitoring | [Sentry package] |
| [Optional] | [any extras requested] |
### Files Written
- `tailwind.config.ts` — full token system
- `src/app/globals.css` — semantic CSS variables (light + dark)
- `src/lib/env.ts` — Zod-validated environment variables
- `src/lib/supabase/client.ts` — Supabase browser client
- [Next.js] `src/lib/supabase/server.ts` — Supabase server client
- [Next.js] `src/middleware.ts` — auth guard + session refresh
- [Next.js] `sentry.client.config.ts`, `sentry.server.config.ts`, `sentry.edge.config.ts`
- [Next.js] `next.config.ts` — [with/without] Sentry wrapper
- [Vite] `vite.config.ts`, `src/main.tsx`, `src/App.tsx`
- `src/app/providers.tsx` — QueryClient + ThemeProvider
- `src/app/layout.tsx` — root layout with fonts
- `src/lib/utils.ts` — cn() helper
- `src/lib/tenant.ts` — getTenantOrgId()
- `scripts/check-env.ts` — env validation script
- `.env.local.example` — env template
- `components.json` — shadcn config
### Next Steps
1. Copy `.env.local.example` to `.env.local` and fill in your values
2. Customize `--primary` color in `src/app/globals.css` for your brand
3. Update font variables if using custom fonts
4. Set protected routes in `src/middleware.ts`
5. Run `/type-safety-chain <supabase-project-id>` to implement the DB→hooks chain
6. Run `pnpm dev` to verify everything loads
### Commands
\`\`\`bash
pnpm dev # Start dev server
pnpm typecheck # TypeScript check
pnpm lint # ESLint
pnpm check:env # Validate env vars
\`\`\`
Critical Rules
- pnpm only — never npm or yarn
- Never hardcode secrets — all credentials go in
.env.local(not.env) - Never use
get/set/removefor Supabase cookies — onlygetAll/setAllvia@supabase/ssr - Sentry only initializes in production — check
NODE_ENV === "production"in client config - Push
"use client"to leaf components — layouts and pages stay as Server Components in Next.js - Tailwind semantic classes only —
bg-primary, notbg-blue-600;text-muted-foreground, nottext-gray-500 - shadcn components, not raw HTML — use
Button,Card,Inputetc. from@/components/ui - Verify before closing — always run
pnpm typecheckandpnpm devat the end