"use client"; import { Check, ClipboardCopy, InfoIcon, RefreshCw, Settings, Share2, Shuffle, Star, Terminal, } from "lucide-react"; import { motion } from "motion/react"; import Image from "next/image"; import { useTheme } from "next-themes"; import { useQueryStates } from "nuqs"; import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_STACK, isStackDefault, PRESET_TEMPLATES, type StackState, TECH_OPTIONS, } from "@/lib/constant"; import { stackParsers, stackQueryStatesOptions } from "@/lib/stack-url-state"; import { cn } from "@/lib/utils"; const validateProjectName = (name: string): string | undefined => { const INVALID_CHARS = ["<", ">", ":", '"', "|", "?", "*"]; const MAX_LENGTH = 255; if (name === ".") return undefined; if (!name) return "Project name cannot be empty"; if (name.length > MAX_LENGTH) { return `Project name must be less than ${MAX_LENGTH} characters`; } if (INVALID_CHARS.some((char) => name.includes(char))) { return "Project name contains invalid characters"; } if (name.startsWith(".") || name.startsWith("-")) { return "Project name cannot start with a dot or dash"; } if ( name.toLowerCase() === "node_modules" || name.toLowerCase() === "favicon.ico" ) { return "Project name is reserved"; } return undefined; }; const CATEGORY_ORDER: Array = [ "webFrontend", "nativeFrontend", "backend", "runtime", "api", "database", "orm", "dbSetup", "webDeploy", "serverDeploy", "auth", "packageManager", "addons", "examples", "git", "install", ]; const hasPWACompatibleFrontend = (webFrontend: string[]) => webFrontend.some((f) => ["tanstack-router", "react-router", "solid", "next"].includes(f), ); const hasTauriCompatibleFrontend = (webFrontend: string[]) => webFrontend.some((f) => [ "tanstack-router", "react-router", "nuxt", "svelte", "solid", "next", ].includes(f), ); const getBadgeColors = (category: string): string => { switch (category) { case "webFrontend": case "nativeFrontend": return "border-blue-300 bg-blue-100 text-blue-800 dark:border-blue-700/30 dark:bg-blue-900/30 dark:text-blue-300"; case "runtime": return "border-amber-300 bg-amber-100 text-amber-800 dark:border-amber-700/30 dark:bg-amber-900/30 dark:text-amber-300"; case "backend": return "border-sky-300 bg-sky-100 text-sky-800 dark:border-sky-700/30 dark:bg-sky-900/30 dark:text-sky-300"; case "api": return "border-indigo-300 bg-indigo-100 text-indigo-800 dark:border-indigo-700/30 dark:bg-indigo-900/30 dark:text-indigo-300"; case "database": return "border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-700/30 dark:bg-emerald-900/30 dark:text-emerald-300"; case "orm": return "border-cyan-300 bg-cyan-100 text-cyan-800 dark:border-cyan-700/30 dark:bg-cyan-900/30 dark:text-cyan-300"; case "auth": return "border-green-300 bg-green-100 text-green-800 dark:border-green-700/30 dark:bg-green-900/30 dark:text-green-300"; case "dbSetup": return "border-pink-300 bg-pink-100 text-pink-800 dark:border-pink-700/30 dark:bg-pink-900/30 dark:text-pink-300"; case "addons": return "border-violet-300 bg-violet-100 text-violet-800 dark:border-violet-700/30 dark:bg-violet-900/30 dark:text-violet-300"; case "examples": return "border-teal-300 bg-teal-100 text-teal-800 dark:border-teal-700/30 dark:bg-teal-900/30 dark:text-teal-300"; case "packageManager": return "border-orange-300 bg-orange-100 text-orange-800 dark:border-orange-700/30 dark:bg-orange-900/30 dark:text-orange-300"; case "git": case "webDeploy": case "serverDeploy": case "install": return "border-gray-300 bg-gray-100 text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400"; default: return "border-gray-300 bg-gray-100 text-gray-800 dark:border-gray-700/30 dark:bg-gray-900/30 dark:text-gray-300"; } }; function TechIcon({ icon, name, className, }: { icon: string; name: string; className?: string; }) { const { theme } = useTheme(); if (!icon) return null; if (!icon.startsWith("https://")) { return ( {icon} ); } let iconSrc = icon; if ( theme === "light" && (icon.includes("drizzle") || icon.includes("prisma") || icon.includes("express") || icon.includes("clerk")) ) { iconSrc = icon.replace(".svg", "-light.svg"); } return ( {`${name} ); } const getCategoryDisplayName = (categoryKey: string): string => { const result = categoryKey.replace(/([A-Z])/g, " $1"); return result.charAt(0).toUpperCase() + result.slice(1); }; interface CompatibilityResult { adjustedStack: StackState | null; notes: Record; changes: Array<{ category: string; message: string }>; } const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => { const nextStack = { ...stack }; let changed = false; const notes: CompatibilityResult["notes"] = {}; const changes: Array<{ category: string; message: string }> = []; for (const cat of CATEGORY_ORDER) { notes[cat] = { notes: [], hasIssue: false }; } const isConvex = nextStack.backend === "convex"; const isBackendNone = nextStack.backend === "none"; if (isConvex) { const convexOverrides: Partial = { runtime: "none", database: "none", orm: "none", api: "none", dbSetup: "none", examples: ["todo"], }; const hasClerkCompatibleFrontend = nextStack.webFrontend.some((f) => ["tanstack-router", "react-router", "tanstack-start", "next"].includes( f, ), ) || nextStack.nativeFrontend.some((f) => ["native-nativewind", "native-unistyles"].includes(f), ); if (nextStack.auth !== "clerk" || !hasClerkCompatibleFrontend) { convexOverrides.auth = "none"; } for (const [key, value] of Object.entries(convexOverrides)) { const catKey = key as keyof StackState; if (JSON.stringify(nextStack[catKey]) !== JSON.stringify(value)) { const displayName = getCategoryDisplayName(catKey); const valueDisplay = Array.isArray(value) ? value.join(", ") : value; const message = `${displayName} set to '${valueDisplay}'`; notes[catKey].notes.push( `Convex backend selected: ${displayName} will be set to '${valueDisplay}'.`, ); notes.backend.notes.push( `Convex requires ${displayName} to be '${valueDisplay}'.`, ); notes[catKey].hasIssue = true; notes.backend.hasIssue = true; (nextStack[catKey] as string | string[]) = value; changed = true; changes.push({ category: "convex", message, }); } } const incompatibleConvexFrontends = ["solid"]; const originalWebFrontendLength = nextStack.webFrontend.length; nextStack.webFrontend = nextStack.webFrontend.filter( (f) => !incompatibleConvexFrontends.includes(f), ); if (nextStack.webFrontend.length !== originalWebFrontendLength) { changed = true; notes.webFrontend.notes.push( "Solid is not compatible with Convex backend and has been removed.", ); notes.backend.notes.push("Convex backend is not compatible with Solid."); notes.webFrontend.hasIssue = true; notes.backend.hasIssue = true; changes.push({ category: "convex", message: "Removed incompatible web frontends (Solid)", }); } if (nextStack.nativeFrontend[0] === "none") { } else { } } else if (isBackendNone) { const noneOverrides: Partial = { auth: "none", database: "none", orm: "none", api: "none", runtime: "none", dbSetup: "none", examples: [], }; for (const [key, value] of Object.entries(noneOverrides)) { const catKey = key as keyof StackState; if (JSON.stringify(nextStack[catKey]) !== JSON.stringify(value)) { const displayName = getCategoryDisplayName(catKey); const valueDisplay = Array.isArray(value) ? "none" : value; const message = `${displayName} set to '${valueDisplay}'`; notes[catKey].notes.push( `No backend selected: ${displayName} will be set to '${valueDisplay}'.`, ); notes.backend.notes.push( `No backend requires ${displayName} to be '${valueDisplay}'.`, ); notes[catKey].hasIssue = true; (nextStack[catKey] as string | string[]) = value; changed = true; changes.push({ category: "backend-none", message, }); } } } else { if (nextStack.runtime === "none") { notes.runtime.notes.push( "Runtime 'None' is only for Convex. Defaulting to 'Bun'.", ); notes.runtime.hasIssue = true; nextStack.runtime = DEFAULT_STACK.runtime; changed = true; changes.push({ category: "runtime", message: "Runtime set to 'Bun' (None is only for Convex)", }); } if (nextStack.api === "none" && (isConvex || isBackendNone)) { } else if (nextStack.api === "none" && !(isConvex || isBackendNone)) { if (nextStack.examples.length > 0) { notes.api.notes.push("API 'None' selected: Examples will be removed."); notes.examples.notes.push( "Examples require an API. They will be removed when API is 'None'.", ); notes.api.hasIssue = true; notes.examples.hasIssue = true; nextStack.examples = []; changed = true; changes.push({ category: "api", message: "Examples removed (API 'None' does not support examples)", }); } } if (nextStack.database === "none") { if (nextStack.orm !== "none") { notes.database.notes.push( "Database 'None' selected: ORM will be set to 'None'.", ); notes.orm.notes.push( "ORM requires a database. It will be set to 'None'.", ); notes.database.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "none"; changed = true; changes.push({ category: "database", message: "ORM set to 'None' (requires a database)", }); } if (nextStack.auth !== "none" && nextStack.backend !== "convex") { notes.database.notes.push( "Database 'None' selected: Auth will be disabled.", ); notes.auth.notes.push( "Authentication requires a database. It will be set to 'None'.", ); notes.database.hasIssue = true; notes.auth.hasIssue = true; nextStack.auth = "none"; changed = true; changes.push({ category: "database", message: "Authentication set to 'None' (requires a database)", }); } if (nextStack.dbSetup !== "none") { notes.database.notes.push( "Database 'None' selected: DB Setup will be set to 'Basic'.", ); notes.dbSetup.notes.push( "DB Setup requires a database. It will be set to 'Basic Setup'.", ); notes.database.hasIssue = true; notes.dbSetup.hasIssue = true; nextStack.dbSetup = "none"; changed = true; changes.push({ category: "database", message: "DB Setup set to 'None' (requires a database)", }); } } else if (nextStack.database === "mongodb") { if (nextStack.orm !== "prisma" && nextStack.orm !== "mongoose") { notes.database.notes.push( "MongoDB requires Prisma or Mongoose ORM. Prisma will be selected.", ); notes.orm.notes.push( "MongoDB requires Prisma or Mongoose ORM. Prisma will be selected.", ); notes.database.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "prisma"; changed = true; changes.push({ category: "database", message: "ORM set to 'Prisma' (MongoDB requires Prisma or Mongoose)", }); } } else { if (nextStack.orm === "mongoose") { notes.database.notes.push( "Relational databases are not compatible with Mongoose ORM. Defaulting to Drizzle.", ); notes.orm.notes.push( "Mongoose ORM only works with MongoDB. Defaulting to Drizzle.", ); notes.database.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "drizzle"; changed = true; changes.push({ category: "database", message: "ORM set to 'Drizzle' (Mongoose only works with MongoDB)", }); } if (nextStack.dbSetup === "turso") { if (nextStack.database !== "sqlite") { notes.dbSetup.notes.push( "Turso requires SQLite. It will be selected.", ); notes.database.notes.push( "Turso DB setup requires SQLite. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "sqlite"; changed = true; changes.push({ category: "dbSetup", message: "Database set to 'SQLite' (required by Turso)", }); } if (nextStack.orm !== "drizzle") { notes.dbSetup.notes.push( "Turso requires Drizzle ORM. It will be selected.", ); notes.orm.notes.push( "Turso DB setup requires Drizzle ORM. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "drizzle"; changed = true; changes.push({ category: "dbSetup", message: "ORM set to 'Drizzle' (required by Turso)", }); } } else if (nextStack.dbSetup === "prisma-postgres") { if (nextStack.database !== "postgres") { notes.dbSetup.notes.push("Requires PostgreSQL. It will be selected."); notes.database.notes.push( "Prisma PostgreSQL setup requires PostgreSQL. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "postgres"; changed = true; changes.push({ category: "dbSetup", message: "Database set to 'PostgreSQL' (required by Prisma PostgreSQL setup)", }); } } else if (nextStack.dbSetup === "mongodb-atlas") { if (nextStack.database !== "mongodb") { notes.dbSetup.notes.push("Requires MongoDB. It will be selected."); notes.database.notes.push( "MongoDB Atlas setup requires MongoDB. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "mongodb"; changed = true; changes.push({ category: "dbSetup", message: "Database set to 'MongoDB' (required by MongoDB Atlas setup)", }); } if (nextStack.orm !== "prisma" && nextStack.orm !== "mongoose") { notes.dbSetup.notes.push( "Requires Prisma or Mongoose ORM. Prisma will be selected.", ); notes.orm.notes.push( "MongoDB Atlas setup requires Prisma or Mongoose ORM. Prisma will be selected.", ); notes.dbSetup.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "prisma"; changed = true; changes.push({ category: "dbSetup", message: "ORM set to 'Prisma' (MongoDB Atlas requires Prisma or Mongoose)", }); } } else if (nextStack.dbSetup === "neon") { if (nextStack.database !== "postgres") { notes.dbSetup.notes.push( "Neon requires PostgreSQL. It will be selected.", ); notes.database.notes.push( "Neon DB setup requires PostgreSQL. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "postgres"; changed = true; changes.push({ category: "dbSetup", message: "Database set to 'PostgreSQL' (required by Neon)", }); } } else if (nextStack.dbSetup === "supabase") { if (nextStack.database !== "postgres") { notes.dbSetup.notes.push( "Supabase (local) requires PostgreSQL. It will be selected.", ); notes.database.notes.push( "Supabase (local) DB setup requires PostgreSQL. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "postgres"; changed = true; changes.push({ category: "dbSetup", message: "Database set to 'PostgreSQL' (required by Supabase setup)", }); } } else if (nextStack.dbSetup === "d1") { if (nextStack.database !== "sqlite") { notes.dbSetup.notes.push( "Cloudflare D1 requires SQLite. It will be selected.", ); notes.database.notes.push( "Cloudflare D1 DB setup requires SQLite. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "sqlite"; changed = true; changes.push({ category: "dbSetup", message: "Database set to 'SQLite' (required by Cloudflare D1)", }); } if (nextStack.runtime !== "workers") { notes.dbSetup.notes.push( "Cloudflare D1 requires Cloudflare Workers runtime. It will be selected.", ); notes.runtime.notes.push( "Cloudflare D1 DB setup requires Cloudflare Workers runtime. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.runtime.hasIssue = true; nextStack.runtime = "workers"; changed = true; changes.push({ category: "dbSetup", message: "Runtime set to 'Cloudflare Workers' (required by D1)", }); } if (nextStack.orm !== "drizzle") { notes.dbSetup.notes.push( "Cloudflare D1 requires Drizzle ORM. It will be selected.", ); notes.orm.notes.push( "Cloudflare D1 DB setup requires Drizzle ORM. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "drizzle"; changed = true; changes.push({ category: "dbSetup", message: "ORM set to 'Drizzle' (required by Cloudflare D1)", }); } if (nextStack.backend !== "hono") { notes.dbSetup.notes.push( "Cloudflare D1 requires Hono backend. It will be selected.", ); notes.backend.notes.push( "Cloudflare D1 DB setup requires Hono backend. It will be selected.", ); notes.dbSetup.hasIssue = true; notes.backend.hasIssue = true; nextStack.backend = "hono"; changed = true; changes.push({ category: "dbSetup", message: "Backend set to 'Hono' (required by Cloudflare D1)", }); } } else if (nextStack.dbSetup === "docker") { if (nextStack.database === "sqlite") { notes.dbSetup.notes.push( "Docker setup is not needed for SQLite. It will be set to 'Basic Setup'.", ); notes.dbSetup.hasIssue = true; notes.database.hasIssue = true; nextStack.dbSetup = "none"; changed = true; changes.push({ category: "dbSetup", message: "DB Setup set to 'Basic Setup' (SQLite doesn't need Docker)", }); } if (nextStack.runtime === "workers") { notes.dbSetup.notes.push( "Docker setup is not compatible with Cloudflare Workers runtime. Bun runtime will be selected.", ); notes.runtime.notes.push( "Cloudflare Workers runtime does not support Docker setup. Bun runtime will be selected.", ); notes.dbSetup.hasIssue = true; notes.runtime.hasIssue = true; nextStack.runtime = "bun"; changed = true; changes.push({ category: "dbSetup", message: "Runtime set to 'Bun' (Workers not compatible with Docker)", }); } } if (nextStack.runtime === "workers") { if (nextStack.backend !== "hono") { notes.runtime.notes.push( "Cloudflare Workers runtime requires Hono backend. Hono will be selected.", ); notes.backend.notes.push( "Cloudflare Workers runtime requires Hono backend. It will be selected.", ); notes.runtime.hasIssue = true; notes.backend.hasIssue = true; nextStack.backend = "hono"; changed = true; changes.push({ category: "runtime", message: "Backend set to 'Hono' (required by Cloudflare Workers)", }); } if (nextStack.serverDeploy === "none") { notes.serverDeploy.notes.push( "Cloudflare Workers runtime requires a server deployment. Wrangler will be selected.", ); notes.serverDeploy.hasIssue = true; nextStack.serverDeploy = "wrangler"; changed = true; changes.push({ category: "serverDeploy", message: "Server deployment set to 'Wrangler' (required by Cloudflare Workers)", }); } if (nextStack.orm !== "drizzle" && nextStack.orm !== "none") { notes.runtime.notes.push( "Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.", ); notes.orm.notes.push( "Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.", ); notes.runtime.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "drizzle"; changed = true; changes.push({ category: "runtime", message: "ORM set to 'Drizzle' (required by Cloudflare Workers)", }); } if (nextStack.database === "mongodb") { notes.runtime.notes.push( "Cloudflare Workers runtime is not compatible with MongoDB. SQLite will be selected.", ); notes.database.notes.push( "MongoDB is not compatible with Cloudflare Workers runtime. SQLite will be selected.", ); notes.runtime.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "sqlite"; changed = true; changes.push({ category: "runtime", message: "Database set to 'SQLite' (MongoDB not compatible with Workers)", }); } if (nextStack.dbSetup === "docker") { notes.runtime.notes.push( "Cloudflare Workers runtime does not support Docker setup. D1 will be selected.", ); notes.dbSetup.notes.push( "Docker setup is not compatible with Cloudflare Workers runtime. D1 will be selected.", ); notes.runtime.hasIssue = true; notes.dbSetup.hasIssue = true; nextStack.dbSetup = "d1"; changed = true; changes.push({ category: "runtime", message: "DB Setup set to 'D1' (Docker not compatible with Workers)", }); } } const isNuxt = nextStack.webFrontend.includes("nuxt"); const isSvelte = nextStack.webFrontend.includes("svelte"); const isSolid = nextStack.webFrontend.includes("solid"); if ((isNuxt || isSvelte || isSolid) && nextStack.api === "trpc") { const frontendName = isNuxt ? "Nuxt" : isSvelte ? "Svelte" : "Solid"; notes.api.notes.push( `${frontendName} requires oRPC. It will be selected automatically.`, ); notes.webFrontend.notes.push( `Selected ${frontendName}: API will be set to oRPC.`, ); notes.api.hasIssue = true; notes.webFrontend.hasIssue = true; nextStack.api = "orpc"; changed = true; changes.push({ category: "api", message: `API set to 'oRPC' (required by ${frontendName})`, }); } if (nextStack.auth === "clerk") { const hasClerkCompatibleFrontend = nextStack.webFrontend.some((f) => [ "tanstack-router", "react-router", "tanstack-start", "next", ].includes(f), ) || nextStack.nativeFrontend.some((f) => ["native-nativewind", "native-unistyles"].includes(f), ); if (!hasClerkCompatibleFrontend) { notes.auth.notes.push( "Clerk auth is not compatible with the selected frontends. Auth will be set to 'None'.", ); notes.webFrontend.notes.push( "Selected frontends are not compatible with Clerk auth. Auth will be disabled.", ); notes.auth.hasIssue = true; notes.webFrontend.hasIssue = true; nextStack.auth = "none"; changed = true; changes.push({ category: "auth", message: "Auth set to 'None' (Clerk not compatible with selected frontends)", }); } } if (nextStack.backend === "convex" && nextStack.auth === "better-auth") { notes.auth.notes.push( "Better-Auth is not compatible with Convex backend. Auth will be set to 'None'.", ); notes.backend.notes.push( "Convex backend only supports Clerk auth or no auth. Auth will be disabled.", ); notes.auth.hasIssue = true; notes.backend.hasIssue = true; nextStack.auth = "none"; changed = true; changes.push({ category: "auth", message: "Auth set to 'None' (Better-Auth not compatible with Convex)", }); } const incompatibleAddons: string[] = []; const isPWACompat = hasPWACompatibleFrontend(nextStack.webFrontend); const isTauriCompat = hasTauriCompatibleFrontend(nextStack.webFrontend); if (!isPWACompat && nextStack.addons.includes("pwa")) { incompatibleAddons.push("pwa"); notes.webFrontend.notes.push( "PWA addon requires TanStack Router, React Router, Solid, or Next.js. Addon will be removed.", ); notes.addons.notes.push( "PWA requires TanStack Router, React Router, Solid, or Next.js. It will be removed.", ); notes.webFrontend.hasIssue = true; notes.addons.hasIssue = true; changes.push({ category: "addons", message: "PWA addon removed (requires compatible web frontend)", }); } if (!isTauriCompat && nextStack.addons.includes("tauri")) { incompatibleAddons.push("tauri"); notes.webFrontend.notes.push( "Tauri addon requires TanStack Router, React Router, Nuxt, Svelte, Solid, or Next.js. Addon will be removed.", ); notes.addons.notes.push( "Tauri requires TanStack Router, React Router, Nuxt, Svelte, Solid, or Next.js. It will be removed.", ); notes.webFrontend.hasIssue = true; notes.addons.hasIssue = true; changes.push({ category: "addons", message: "Tauri addon removed (requires compatible web frontend)", }); } const originalAddonsLength = nextStack.addons.length; if (incompatibleAddons.length > 0) { nextStack.addons = nextStack.addons.filter( (addon) => !incompatibleAddons.includes(addon), ); if (nextStack.addons.length !== originalAddonsLength) changed = true; } if ( nextStack.addons.includes("husky") && !nextStack.addons.includes("biome") && !nextStack.addons.includes("oxlint") ) { notes.addons.notes.push( "Husky addon is selected without a linter. Consider adding Biome or Oxlint for lint-staged integration.", ); } if (nextStack.addons.includes("ultracite")) { if (nextStack.addons.includes("biome")) { notes.addons.notes.push( "Ultracite includes Biome setup. Biome addon will be removed.", ); nextStack.addons = nextStack.addons.filter( (addon) => addon !== "biome", ); changed = true; changes.push({ category: "addons", message: "Biome addon removed (included in Ultracite)", }); } } if ( nextStack.addons.includes("oxlint") && nextStack.addons.includes("biome") ) { notes.addons.notes.push( "Both Oxlint and Biome are selected. Consider using only one linter.", ); } const incompatibleExamples: string[] = []; if ( nextStack.database === "none" && nextStack.examples.includes("todo") ) { incompatibleExamples.push("todo"); changes.push({ category: "examples", message: "Todo example removed (requires a database)", }); } if (nextStack.backend === "elysia" && nextStack.examples.includes("ai")) { incompatibleExamples.push("ai"); changes.push({ category: "examples", message: "AI example removed (not compatible with Elysia)", }); } if (isSolid && nextStack.examples.includes("ai")) { incompatibleExamples.push("ai"); changes.push({ category: "examples", message: "AI example removed (not compatible with Solid)", }); } const uniqueIncompatibleExamples = [...new Set(incompatibleExamples)]; if (uniqueIncompatibleExamples.length > 0) { if ( nextStack.database === "none" && uniqueIncompatibleExamples.includes("todo") ) { notes.database.notes.push( "Todo example requires a database. It will be removed.", ); notes.examples.notes.push( "Todo example requires a database. It will be removed.", ); notes.database.hasIssue = true; notes.examples.hasIssue = true; } if ( nextStack.backend === "elysia" && uniqueIncompatibleExamples.includes("ai") ) { notes.backend.notes.push( "AI example is not compatible with Elysia. It will be removed.", ); notes.examples.notes.push( "AI example is not compatible with Elysia. It will be removed.", ); notes.backend.hasIssue = true; notes.examples.hasIssue = true; } if (isSolid && uniqueIncompatibleExamples.includes("ai")) { notes.webFrontend.notes.push( "AI example is not compatible with Solid. It will be removed.", ); notes.examples.notes.push( "AI example is not compatible with Solid. It will be removed.", ); notes.webFrontend.hasIssue = true; notes.examples.hasIssue = true; } const originalExamplesLength = nextStack.examples.length; nextStack.examples = nextStack.examples.filter( (ex) => !uniqueIncompatibleExamples.includes(ex), ); if (nextStack.examples.length !== originalExamplesLength) changed = true; } } } const webFrontendsSelected = nextStack.webFrontend.some((f) => f !== "none"); if (!webFrontendsSelected && nextStack.webDeploy !== "none") { notes.webDeploy.notes.push( "Web deployment requires a web frontend. It will be disabled.", ); notes.webFrontend.notes.push( "No web frontend selected: Deployment has been disabled.", ); notes.webDeploy.hasIssue = true; notes.webFrontend.hasIssue = true; nextStack.webDeploy = "none"; changed = true; changes.push({ category: "webDeploy", message: "Web deployment set to 'none' (requires web frontend)", }); } if ( nextStack.serverDeploy !== "none" && (nextStack.backend === "none" || nextStack.backend === "convex") ) { notes.serverDeploy.notes.push( "Server deployment requires a supported backend. It will be disabled.", ); notes.backend.notes.push( "No compatible backend selected: Server deployment has been disabled.", ); notes.serverDeploy.hasIssue = true; notes.backend.hasIssue = true; nextStack.serverDeploy = "none"; changed = true; changes.push({ category: "serverDeploy", message: "Server deployment set to 'none' (requires backend)", }); } if (nextStack.serverDeploy !== "none" && nextStack.runtime !== "workers") { notes.serverDeploy.notes.push( "Selected server deployment targets Cloudflare Workers. Runtime will be set to 'Cloudflare Workers'.", ); notes.runtime.notes.push( "Server deployment requires Cloudflare Workers runtime. It will be selected.", ); notes.serverDeploy.hasIssue = true; notes.runtime.hasIssue = true; nextStack.runtime = "workers"; changed = true; changes.push({ category: "serverDeploy", message: "Runtime set to 'Cloudflare Workers' (required by server deployment)", }); if (nextStack.backend !== "hono") { notes.runtime.notes.push( "Cloudflare Workers runtime requires Hono backend. Hono will be selected.", ); notes.backend.notes.push( "Cloudflare Workers runtime requires Hono backend. It will be selected.", ); notes.runtime.hasIssue = true; notes.backend.hasIssue = true; nextStack.backend = "hono"; changes.push({ category: "runtime", message: "Backend set to 'Hono' (required by Cloudflare Workers)", }); } if (nextStack.orm !== "drizzle" && nextStack.orm !== "none") { notes.runtime.notes.push( "Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.", ); notes.orm.notes.push( "Cloudflare Workers runtime requires Drizzle ORM or no ORM. Drizzle will be selected.", ); notes.runtime.hasIssue = true; notes.orm.hasIssue = true; nextStack.orm = "drizzle"; changes.push({ category: "runtime", message: "ORM set to 'Drizzle' (required by Cloudflare Workers)", }); } if (nextStack.database === "mongodb") { notes.runtime.notes.push( "Cloudflare Workers runtime is not compatible with MongoDB. SQLite will be selected.", ); notes.database.notes.push( "MongoDB is not compatible with Cloudflare Workers runtime. SQLite will be selected.", ); notes.runtime.hasIssue = true; notes.database.hasIssue = true; nextStack.database = "sqlite"; changes.push({ category: "runtime", message: "Database set to 'SQLite' (MongoDB not compatible with Workers)", }); } if (nextStack.dbSetup === "docker") { notes.runtime.notes.push( "Cloudflare Workers runtime does not support Docker setup. D1 will be selected.", ); notes.dbSetup.notes.push( "Docker setup is not compatible with Cloudflare Workers runtime. D1 will be selected.", ); notes.runtime.hasIssue = true; notes.dbSetup.hasIssue = true; nextStack.dbSetup = "d1"; changes.push({ category: "runtime", message: "DB Setup set to 'D1' (Docker not compatible with Workers)", }); } } const isAlchemyWebDeploy = nextStack.webDeploy === "alchemy"; const isAlchemyServerDeploy = nextStack.serverDeploy === "alchemy"; if (isAlchemyWebDeploy || isAlchemyServerDeploy) { const incompatibleFrontends = nextStack.webFrontend.filter( (f) => f === "next", ); if (incompatibleFrontends.length > 0) { const deployType = isAlchemyWebDeploy && isAlchemyServerDeploy ? "web and server deployment" : isAlchemyWebDeploy ? "web deployment" : "server deployment"; notes.webFrontend.notes.push( `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}. These frontends will be removed.`, ); notes.webDeploy.notes.push( `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`, ); notes.serverDeploy.notes.push( `Alchemy ${deployType} is temporarily not compatible with ${incompatibleFrontends.join(" and ")}.`, ); notes.webFrontend.hasIssue = true; notes.webDeploy.hasIssue = true; notes.serverDeploy.hasIssue = true; nextStack.webFrontend = nextStack.webFrontend.filter((f) => f !== "next"); if (nextStack.webFrontend.length === 0) { nextStack.webFrontend = ["tanstack-router"]; } changed = true; changes.push({ category: "alchemy", message: `Removed ${incompatibleFrontends.join(" and ")} (not compatible with Alchemy ${deployType})`, }); } } return { adjustedStack: changed ? nextStack : null, notes, changes, }; }; const generateCommand = (stackState: StackState): string => { let base: string; switch (stackState.packageManager) { case "npm": base = "npx create-better-t-stack@latest"; break; case "pnpm": base = "pnpm create better-t-stack@latest"; break; default: base = "bun create better-t-stack@latest"; break; } const projectName = stackState.projectName || "my-better-t-app"; const flags: string[] = ["--yes"]; const checkDefault = ( key: K, value: StackState[K], ) => isStackDefault(stackState, key, value); const combinedFrontends = [ ...stackState.webFrontend, ...stackState.nativeFrontend, ].filter((v, _, arr) => v !== "none" || arr.length === 1); if ( !checkDefault("webFrontend", stackState.webFrontend) || !checkDefault("nativeFrontend", stackState.nativeFrontend) ) { if (combinedFrontends.length === 0 || combinedFrontends[0] === "none") { flags.push("--frontend none"); } else { flags.push(`--frontend ${combinedFrontends.join(" ")}`); } } if (!checkDefault("backend", stackState.backend)) { flags.push(`--backend ${stackState.backend}`); } if (stackState.backend !== "convex") { if (!checkDefault("runtime", stackState.runtime)) { flags.push(`--runtime ${stackState.runtime}`); } if (!checkDefault("api", stackState.api)) { flags.push(`--api ${stackState.api}`); } const requiresExplicitDatabase = [ "d1", "turso", "neon", "supabase", "prisma-postgres", "mongodb-atlas", "docker", ].includes(stackState.dbSetup); if ( !checkDefault("database", stackState.database) || requiresExplicitDatabase ) { flags.push(`--database ${stackState.database}`); } if (!checkDefault("orm", stackState.orm)) { flags.push(`--orm ${stackState.orm}`); } if (!checkDefault("auth", stackState.auth)) { flags.push(`--auth ${stackState.auth}`); } if (!checkDefault("dbSetup", stackState.dbSetup)) { flags.push(`--db-setup ${stackState.dbSetup}`); } } else { } if (!checkDefault("packageManager", stackState.packageManager)) { flags.push(`--package-manager ${stackState.packageManager}`); } if (!checkDefault("git", stackState.git)) { if (stackState.git === "false" && DEFAULT_STACK.git === "true") { flags.push("--no-git"); } } if ( stackState.webDeploy && !checkDefault("webDeploy", stackState.webDeploy) ) { flags.push(`--web-deploy ${stackState.webDeploy}`); } if ( stackState.serverDeploy && !checkDefault("serverDeploy", stackState.serverDeploy) ) { flags.push(`--server-deploy ${stackState.serverDeploy}`); } if (!checkDefault("install", stackState.install)) { if (stackState.install === "false" && DEFAULT_STACK.install === "true") { flags.push("--no-install"); } } if (!checkDefault("addons", stackState.addons)) { if (stackState.addons.length > 0) { const validAddons = stackState.addons.filter((addon) => [ "pwa", "tauri", "starlight", "biome", "husky", "turborepo", "ultracite", "fumadocs", "oxlint", "ruler", ].includes(addon), ); if (validAddons.length > 0) { flags.push(`--addons ${validAddons.join(" ")}`); } else { if (DEFAULT_STACK.addons.length > 0) { flags.push("--addons none"); } } } else { if (DEFAULT_STACK.addons.length > 0) { flags.push("--addons none"); } } } if (!checkDefault("examples", stackState.examples)) { if (stackState.examples.length > 0) { flags.push(`--examples ${stackState.examples.join(" ")}`); } else { if (DEFAULT_STACK.examples.length > 0) { flags.push("--examples none"); } } } return `${base} ${projectName}${ flags.length > 0 ? ` ${flags.join(" ")}` : "" }`; }; const StackBuilder = () => { const [stack, setStack] = useQueryStates( stackParsers, stackQueryStatesOptions, ); const [command, setCommand] = useState(""); const [copied, setCopied] = useState(false); const [projectNameError, setProjectNameError] = useState( undefined, ); const [lastSavedStack, setLastSavedStack] = useState(null); const [, setLastChanges] = useState< Array<{ category: string; message: string }> >([]); const sectionRefs = useRef>({}); const contentRef = useRef(null); const compatibilityAnalysis = useMemo( () => analyzeStackCompatibility(stack), [stack], ); const getRandomStack = () => { const randomStack: Partial = {}; for (const category of CATEGORY_ORDER) { const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS] || []; if (options.length === 0) continue; const catKey = category as keyof StackState; if ( ["webFrontend", "nativeFrontend", "addons", "examples"].includes(catKey) ) { const currentValues: string[] = []; randomStack[ catKey as "webFrontend" | "nativeFrontend" | "addons" | "examples" ] = currentValues; if (catKey === "webFrontend" || catKey === "nativeFrontend") { const randomIndex = Math.floor(Math.random() * options.length); const selectedOption = options[randomIndex].id; currentValues.push(selectedOption); if (selectedOption === "none" && currentValues.length > 1) { randomStack[catKey] = ["none"]; } else if (selectedOption !== "none") { randomStack[catKey] = currentValues.filter((id) => id !== "none"); } } else { const numToPick = Math.floor( Math.random() * Math.min(options.length + 1, 4), ); const shuffledOptions = [...options].sort(() => 0.5 - Math.random()); for (let i = 0; i < numToPick; i++) { currentValues.push(shuffledOptions[i].id); } } } else { const randomIndex = Math.floor(Math.random() * options.length); (randomStack[catKey] as string) = options[randomIndex].id; } } setStack(randomStack as StackState); contentRef.current?.scrollTo(0, 0); toast.success("Random stack generated!"); }; const shareToTwitter = () => { const getStackSummary = (): string => { const selectedTechs: string[] = []; for (const category of CATEGORY_ORDER) { const categoryKey = category as keyof StackState; const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS]; const selectedValue = stack[categoryKey]; if (!options) continue; if (Array.isArray(selectedValue)) { if ( selectedValue.length === 0 || (selectedValue.length === 1 && selectedValue[0] === "none") ) { continue; } for (const id of selectedValue) { if (id === "none") continue; const tech = options.find((opt) => opt.id === id); if (tech) { selectedTechs.push(tech.name); } } } else { const tech = options.find((opt) => opt.id === selectedValue); if ( !tech || tech.id === "none" || tech.id === "false" || ((category === "git" || category === "install" || category === "auth") && tech.id === "true") ) { continue; } selectedTechs.push(tech.name); } } return selectedTechs.length > 0 ? selectedTechs.join(" • ") : "Custom stack"; }; const stackSummary = getStackSummary(); const text = encodeURIComponent( `Check out this cool tech stack I configured with Create Better T Stack!\n\nšŸš€ ${stackSummary}\n\n`, ); if (typeof window !== "undefined") { const url = encodeURIComponent(window.location.href); window.open( `https://twitter.com/intent/tweet?text=${text}&url=${url}`, "_blank", ); } else { toast.error("Could not generate share link."); } }; const selectedBadges = (() => { const badges: React.ReactNode[] = []; for (const category of CATEGORY_ORDER) { const categoryKey = category as keyof StackState; const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS]; const selectedValue = stack[categoryKey]; if (!options) continue; if (Array.isArray(selectedValue)) { if ( selectedValue.length === 0 || (selectedValue.length === 1 && selectedValue[0] === "none") ) { continue; } for (const id of selectedValue) { if (id === "none") continue; const tech = options.find((opt) => opt.id === id); if (tech) { badges.push( {tech.icon !== "" && ( )} {tech.name} , ); } } } else { const tech = options.find((opt) => opt.id === selectedValue); if ( !tech || tech.id === "none" || tech.id === "false" || ((category === "git" || category === "install" || category === "auth") && tech.id === "true") ) { continue; } badges.push( {tech.name} , ); } } return badges; })(); useEffect(() => { const savedStack = localStorage.getItem("betterTStackPreference"); if (savedStack) { try { const parsedStack = JSON.parse(savedStack) as StackState; setLastSavedStack(parsedStack); } catch (e) { console.error("Failed to parse saved stack", e); localStorage.removeItem("betterTStackPreference"); } } }, []); useEffect(() => { if (compatibilityAnalysis.adjustedStack) { if (compatibilityAnalysis.changes.length > 0) { if (compatibilityAnalysis.changes.length === 1) { toast.info(compatibilityAnalysis.changes[0].message, { duration: 4000, }); } else if (compatibilityAnalysis.changes.length > 1) { const message = `${ compatibilityAnalysis.changes.length } compatibility adjustments made:\n${compatibilityAnalysis.changes .map((c) => `• ${c.message}`) .join("\n")}`; toast.info(message, { duration: 5000, }); } } setLastChanges(compatibilityAnalysis.changes); setStack(compatibilityAnalysis.adjustedStack); } }, [ compatibilityAnalysis.adjustedStack, setStack, compatibilityAnalysis.changes, ]); useEffect(() => { const cmd = generateCommand(stack); setCommand(cmd); }, [stack]); useEffect(() => { setProjectNameError(validateProjectName(stack.projectName || "")); }, [stack.projectName]); const handleTechSelect = ( category: keyof typeof TECH_OPTIONS, techId: string, ) => { setStack((currentStack) => { const catKey = category as keyof StackState; const update: Partial = {}; const currentValue = currentStack[catKey]; if ( catKey === "webFrontend" || catKey === "nativeFrontend" || catKey === "addons" || catKey === "examples" ) { const currentArray = Array.isArray(currentValue) ? [...currentValue] : []; let nextArray = [...currentArray]; const isSelected = currentArray.includes(techId); if (catKey === "webFrontend") { if (techId === "none") { nextArray = ["none"]; } else if (isSelected) { if (currentArray.length > 1) { nextArray = nextArray.filter((id) => id !== techId); } else { nextArray = ["none"]; } } else { nextArray = [techId]; } } else if (catKey === "nativeFrontend") { if (techId === "none") { nextArray = ["none"]; } else if (isSelected) { nextArray = ["none"]; } else { nextArray = [techId]; } } else { if (isSelected) { nextArray = nextArray.filter((id) => id !== techId); } else { nextArray.push(techId); } if (nextArray.length > 1) { nextArray = nextArray.filter((id) => id !== "none"); } if ( nextArray.length === 0 && (catKey === "addons" || catKey === "examples") ) { } else if (nextArray.length === 0) { nextArray = ["none"]; } } const uniqueNext = [...new Set(nextArray)].sort(); const uniqueCurrent = [...new Set(currentArray)].sort(); if (JSON.stringify(uniqueNext) !== JSON.stringify(uniqueCurrent)) { update[catKey] = uniqueNext; } } else { if (currentValue !== techId) { update[catKey] = techId; } else { if ( (category === "git" || category === "install") && techId === "false" ) { update[catKey] = "true"; } else if ( (category === "git" || category === "install") && techId === "true" ) { update[catKey] = "false"; } } } return Object.keys(update).length > 0 ? update : {}; }); }; const copyToClipboard = () => { navigator.clipboard.writeText(command); setCopied(true); setTimeout(() => setCopied(false), 2000); }; const resetStack = () => { setStack(DEFAULT_STACK); contentRef.current?.scrollTo(0, 0); }; const saveCurrentStack = () => { localStorage.setItem("betterTStackPreference", JSON.stringify(stack)); setLastSavedStack(stack); toast.success("Your stack configuration has been saved"); }; const loadSavedStack = () => { if (lastSavedStack) { setStack(lastSavedStack); contentRef.current?.scrollTo(0, 0); toast.success("Saved configuration loaded"); } }; const applyPreset = (presetId: string) => { const preset = PRESET_TEMPLATES.find( (template) => template.id === presetId, ); if (preset) { setStack(preset.stack); contentRef.current?.scrollTo(0, 0); toast.success(`Applied preset: ${preset.name}`); } }; const isOptionCompatible = ( currentStack: StackState, category: keyof typeof TECH_OPTIONS, optionId: string, ): boolean => { const simulatedStack: StackState = JSON.parse(JSON.stringify(currentStack)); const updateArrayCategory = (arr: string[], cat: string): string[] => { const isAlreadySelected = arr.includes(optionId); if (cat === "webFrontend" || cat === "nativeFrontend") { if (isAlreadySelected) { return optionId === "none" ? arr : ["none"]; } if (optionId === "none") return ["none"]; return [optionId]; } const next: string[] = isAlreadySelected ? arr.filter((id) => id !== optionId) : [...arr.filter((id) => id !== "none"), optionId]; if (next.length === 0) return ["none"]; return [...new Set(next)]; }; if ( category === "webFrontend" || category === "nativeFrontend" || category === "addons" || category === "examples" ) { const currentArr = Array.isArray(simulatedStack[category]) ? [...(simulatedStack[category] as string[])] : []; (simulatedStack[category] as string[]) = updateArrayCategory( currentArr, category, ); } else { (simulatedStack[category] as string) = optionId; } const { adjustedStack } = analyzeStackCompatibility(simulatedStack); const finalStack = adjustedStack ?? simulatedStack; if (category === "webFrontend" && optionId === "next") { const isAlchemyWebDeploy = finalStack.webDeploy === "alchemy"; const isAlchemyServerDeploy = finalStack.serverDeploy === "alchemy"; if (isAlchemyWebDeploy || isAlchemyServerDeploy) { return false; } } if ( category === "webFrontend" || category === "nativeFrontend" || category === "addons" || category === "examples" ) { return (finalStack[category] as string[]).includes(optionId); } return finalStack[category] === optionId; }; return (
{lastSavedStack && ( )}
$ {command}

Selected Stack

{selectedBadges}

Quick Presets

{PRESET_TEMPLATES.map((preset) => ( ))}
{CATEGORY_ORDER.map((categoryKey) => { const categoryOptions = TECH_OPTIONS[categoryKey as keyof typeof TECH_OPTIONS] || []; const categoryDisplayName = getCategoryDisplayName(categoryKey); const filteredOptions = categoryOptions; if (filteredOptions.length === 0) return null; return (
{ sectionRefs.current[categoryKey] = el; }} key={categoryKey} id={`section-${categoryKey}`} className="mb-6 scroll-mt-4 sm:mb-8" >

{categoryDisplayName}

{compatibilityAnalysis.notes[categoryKey]?.hasIssue && (
    {compatibilityAnalysis.notes[ categoryKey ].notes.map((note) => (
  • {note}
  • ))}
)}
{filteredOptions.map((tech) => { let isSelected = false; const category = categoryKey as keyof StackState; const currentValue = stack[category]; if ( category === "addons" || category === "examples" || category === "webFrontend" || category === "nativeFrontend" ) { isSelected = ( (currentValue as string[]) || [] ).includes(tech.id); } else { isSelected = currentValue === tech.id; } const isDisabled = !isOptionCompatible( stack, categoryKey as keyof typeof TECH_OPTIONS, tech.id, ); return ( handleTechSelect( categoryKey as keyof typeof TECH_OPTIONS, tech.id, ) } >
{tech.icon !== "" && ( )} {tech.name}

{tech.description}

{tech.default && !isSelected && ( Default )}
); })}
); })}
); }; export default StackBuilder;