"use client"; import { ThemeToggle } from "@/components/theme-toggle"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; import { DEFAULT_STACK, PRESET_TEMPLATES, type StackState, TECH_OPTIONS, isStackDefault, } from "@/lib/constant"; import { stackParsers, stackQueryStatesOptions } from "@/lib/stack-url-state"; import { cn } from "@/lib/utils"; import { Check, Circle, CircleCheck, ClipboardCopy, Github, HelpCircle, InfoIcon, RefreshCw, Settings, Share2, Shuffle, Star, Terminal, } from "lucide-react"; import { motion } from "motion/react"; import Image from "next/image"; import Link from "next/link"; import { useQueryStates } from "nuqs"; import type React from "react"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; 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 = [ "frontend", "backend", "runtime", "api", "database", "orm", "dbSetup", "auth", "packageManager", "addons", "examples", "git", "install", ]; const hasWebFrontend = (frontend: string[]) => frontend.some((f) => [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", ].includes(f), ); const checkHasNativeFrontend = (frontend: string[]) => frontend.includes("native-nativewind") || frontend.includes("native-unistyles"); const hasPWACompatibleFrontend = (frontend: string[]) => frontend.some((f) => ["tanstack-router", "react-router", "solid"].includes(f), ); const hasTauriCompatibleFrontend = (frontend: string[]) => frontend.some((f) => [ "tanstack-router", "react-router", "nuxt", "svelte", "solid", "next", ].includes(f), ); const getBadgeColors = (category: string): string => { switch (category) { case "frontend": 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 "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"; } }; const TechIcon: React.FC<{ icon: string; name: string; className?: string; }> = ({ icon, name, className }) => { if (icon.startsWith("/icon/")) { return ( {`${name} ); } return ( {icon} ); }; 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", auth: "false", dbSetup: "none", examples: ["todo"], }; 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 = ["nuxt", "solid"]; const originalFrontendLength = nextStack.frontend.length; nextStack.frontend = nextStack.frontend.filter( (f) => !incompatibleConvexFrontends.includes(f), ); if (nextStack.frontend.length !== originalFrontendLength) { changed = true; notes.frontend.notes.push( "Nuxt and Solid are not compatible with Convex backend and have been removed.", ); notes.backend.notes.push( "Convex backend is not compatible with Nuxt or Solid.", ); notes.frontend.hasIssue = true; notes.backend.hasIssue = true; changes.push({ category: "convex", message: "Removed incompatible frontends (Nuxt, Solid)", }); } if (nextStack.frontend.length === 0) { nextStack.frontend = ["tanstack-router"]; changed = true; changes.push({ category: "convex", message: "Frontend defaulted to TanStack Router", }); } } else if (isBackendNone) { const noneOverrides: Partial = { auth: "false", 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; 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.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 === "true") { notes.database.notes.push( "Database 'None' selected: Auth will be disabled.", ); notes.auth.notes.push( "Authentication requires a database. It will be disabled.", ); notes.database.hasIssue = true; notes.auth.hasIssue = true; nextStack.auth = "false"; changed = true; changes.push({ category: "database", message: "Authentication disabled (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)", }); } if (nextStack.orm !== "prisma") { notes.dbSetup.notes.push("Requires Prisma ORM. It will be selected."); notes.orm.notes.push( "Prisma PostgreSQL setup requires Prisma ORM. It 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' (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)", }); } } const isNuxt = nextStack.frontend.includes("nuxt"); const isSvelte = nextStack.frontend.includes("svelte"); const isSolid = nextStack.frontend.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.frontend.notes.push( `Selected ${frontendName}: API will be set to oRPC.`, ); notes.api.hasIssue = true; notes.frontend.hasIssue = true; nextStack.api = "orpc"; changed = true; changes.push({ category: "api", message: `API set to 'oRPC' (required by ${frontendName})`, }); } const incompatibleAddons: string[] = []; const isPWACompat = hasPWACompatibleFrontend(nextStack.frontend); const isTauriCompat = hasTauriCompatibleFrontend(nextStack.frontend); if (!isPWACompat && nextStack.addons.includes("pwa")) { incompatibleAddons.push("pwa"); notes.frontend.notes.push( "PWA addon requires TanStack/React Router or Solid. Addon will be removed.", ); notes.addons.notes.push( "PWA requires TanStack/React Router/Solid. It will be removed.", ); notes.frontend.hasIssue = true; notes.addons.hasIssue = true; changes.push({ category: "addons", message: "PWA addon removed (requires compatible frontend)", }); } if (!isTauriCompat && nextStack.addons.includes("tauri")) { incompatibleAddons.push("tauri"); notes.frontend.notes.push( "Tauri addon requires TanStack/React Router, Nuxt, Svelte, Solid, or Next.js. Addon will be removed.", ); notes.addons.notes.push( "Tauri requires TanStack/React Router/Nuxt/Svelte/Solid/Next.js. It will be removed.", ); notes.frontend.hasIssue = true; notes.addons.hasIssue = true; changes.push({ category: "addons", message: "Tauri addon removed (requires compatible 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") ) { notes.addons.notes.push( "Husky addon is selected without Biome. Consider adding Biome for lint-staged integration.", ); } const incompatibleExamples: string[] = []; const isWeb = hasWebFrontend(nextStack.frontend); const isNativeOnly = checkHasNativeFrontend(nextStack.frontend) && !isWeb; if (isNativeOnly) { if (nextStack.examples.length > 0) { notes.frontend.notes.push( "Examples are not supported with Native-only frontend. Examples will be removed.", ); notes.examples.notes.push( "Examples require a web frontend. They will be removed.", ); notes.frontend.hasIssue = true; notes.examples.hasIssue = true; incompatibleExamples.push(...nextStack.examples); changes.push({ category: "examples", message: "Examples removed (not supported with Native-only frontend)", }); } } else { if (!isWeb) { if (nextStack.examples.includes("todo")) { incompatibleExamples.push("todo"); changes.push({ category: "examples", message: "Todo example removed (requires web frontend)", }); } if (nextStack.examples.includes("ai")) { incompatibleExamples.push("ai"); changes.push({ category: "examples", message: "AI example removed (requires web frontend)", }); } } 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 (!isWeb && !isNativeOnly) { if ( uniqueIncompatibleExamples.includes("todo") || uniqueIncompatibleExamples.includes("ai") ) { notes.frontend.notes.push( "Examples require a web frontend. Incompatible examples will be removed.", ); notes.examples.notes.push( "Requires a web frontend. Incompatible examples will be removed.", ); notes.frontend.hasIssue = true; notes.examples.hasIssue = true; } } 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.frontend.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.frontend.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; } } } return { adjustedStack: changed ? nextStack : null, notes, changes, }; }; const getCompatibilityRules = (stack: StackState) => { const isConvex = stack.backend === "convex"; const isBackendNone = stack.backend === "none"; const hasWebFrontendSelected = hasWebFrontend(stack.frontend); const hasNativeFrontend = checkHasNativeFrontend(stack.frontend); const hasNativeOnly = hasNativeFrontend && !hasWebFrontendSelected; const hasSolid = stack.frontend.includes("solid"); const hasNuxt = stack.frontend.includes("nuxt"); const hasSvelte = stack.frontend.includes("svelte"); return { isConvex, isBackendNone, hasWebFrontend: hasWebFrontendSelected, hasNativeFrontend, hasNativeOnly, hasPWACompatible: hasPWACompatibleFrontend(stack.frontend), hasTauriCompatible: hasTauriCompatibleFrontend(stack.frontend), hasNuxtOrSvelteOrSolid: hasNuxt || hasSvelte || hasSolid, hasSolid, hasNuxt, hasSvelte, }; }; 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); if (!checkDefault("frontend", stackState.frontend)) { if (stackState.frontend.length === 0 || stackState.frontend[0] === "none") { flags.push("--frontend none"); } else { flags.push(`--frontend ${stackState.frontend.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}`); } if (!checkDefault("database", stackState.database)) { flags.push(`--database ${stackState.database}`); } if (!checkDefault("orm", stackState.orm)) { flags.push(`--orm ${stackState.orm}`); } if (!checkDefault("auth", stackState.auth)) { if (stackState.auth === "false" && DEFAULT_STACK.auth === "true") { flags.push("--no-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 (!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) { flags.push(`--addons ${stackState.addons.join(" ")}`); } 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 [showPresets, setShowPresets] = useState(false); const [showHelp, setShowHelp] = useState(false); const [lastSavedStack, setLastSavedStack] = useState(null); // const [activeCategory, setActiveCategory] = useState( // CATEGORY_ORDER[0], // ); const [, setLastChanges] = useState< Array<{ category: string; message: string }> >([]); const sectionRefs = useRef>({}); const contentRef = useRef(null); const compatibilityAnalysis = useMemo( () => analyzeStackCompatibility(stack), [stack], ); const rules = useMemo(() => getCompatibilityRules(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 (["frontend", "addons", "examples"].includes(catKey)) { const currentValues: string[] = []; randomStack[catKey as "frontend" | "addons" | "examples"] = currentValues; if (catKey === "frontend") { 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); setShowHelp(false); setShowPresets(false); contentRef.current?.scrollTo(0, 0); toast.success("Random stack generated!"); }; const shareToTwitter = () => { const text = encodeURIComponent( "Check out this cool tech stack I configured with Create Better T-Stack!\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 disabledReasons = useMemo(() => { const reasons = new Map(); const addRule = (category: string, techId: string, reason: string) => { reasons.set(`${category}-${techId}`, reason); }; for (const category of CATEGORY_ORDER) { const options = TECH_OPTIONS[category as keyof typeof TECH_OPTIONS] || []; const catKey = category as keyof StackState; for (const tech of options) { const techId = tech.id; if (rules.isConvex) { const convexDefaults: Record = { runtime: "none", database: "none", orm: "none", api: "none", auth: "false", dbSetup: "none", examples: ["todo"], }; if ( ["runtime", "database", "orm", "api", "auth", "dbSetup"].includes( catKey, ) ) { const requiredValue = convexDefaults[catKey]; if (catKey === "auth") { if (techId === "true" && requiredValue === "false") { addRule( category, techId, "Disabled: Convex backend requires Authentication to be disabled.", ); } } else if (String(techId) !== String(requiredValue)) { addRule( category, techId, `Disabled: Convex backend requires ${getCategoryDisplayName( catKey, )} to be '${requiredValue}'.`, ); } } else if (catKey === "examples") { const requiredExamples = convexDefaults.examples as string[]; if ( !requiredExamples.includes(techId) && techId !== "none" && options.find((o) => o.id === techId) ) { addRule( category, techId, "Disabled: Convex backend only supports the 'Todo' example.", ); } } else if ( catKey === "frontend" && (techId === "nuxt" || techId === "solid") ) { addRule( category, techId, `Disabled: Convex backend is not compatible with ${tech.name}.`, ); } continue; } if (rules.isBackendNone) { if (catKey === "auth" && techId === "true") { addRule( category, techId, "Disabled: Authentication requires a backend.", ); } else if ( ["database", "orm", "api", "runtime", "dbSetup"].includes(catKey) && techId !== "none" ) { addRule( category, techId, `Disabled: ${getCategoryDisplayName( catKey, )} cannot be selected when 'No Backend' is chosen (will be 'None').`, ); } else if (catKey === "examples" && techId !== "none") { addRule( category, techId, "Disabled: Examples cannot be selected when 'No Backend' is chosen.", ); } } if (catKey === "runtime" && techId === "none" && !rules.isConvex) { addRule( category, techId, "Disabled: Runtime 'None' is only available with Convex backend.", ); } if (catKey === "api") { if (techId !== "none" && (rules.isConvex || rules.isBackendNone)) { addRule( category, techId, rules.isConvex ? "Disabled: Convex backend requires API to be 'None'." : "Disabled: No backend requires API to be 'None'.", ); } if (techId === "trpc" && rules.hasNuxtOrSvelteOrSolid) { const frontendName = rules.hasNuxt ? "Nuxt" : rules.hasSvelte ? "Svelte" : "Solid"; addRule( category, techId, `Disabled: tRPC is not supported with ${frontendName}. oRPC will be automatically selected.`, ); } } if (catKey === "orm") { if ( stack.database === "none" && techId !== "none" && !rules.isConvex ) { addRule( category, techId, "Disabled: ORM requires a database. Select a database or 'No ORM'.", ); } else if (stack.database === "mongodb") { if ( techId !== "prisma" && techId !== "mongoose" && techId !== "none" ) { addRule( category, techId, "Disabled: With MongoDB, use Prisma, Mongoose, or No ORM.", ); } } else if (["sqlite", "postgres", "mysql"].includes(stack.database)) { if (techId === "mongoose") { addRule( category, techId, "Disabled: Mongoose ORM is for MongoDB. Choose a different ORM for relational databases.", ); } } if (stack.dbSetup === "turso" && techId !== "drizzle") { addRule( category, techId, "Disabled: Turso DB setup requires Drizzle ORM.", ); } else if ( stack.dbSetup === "prisma-postgres" && techId !== "prisma" ) { addRule( category, techId, "Disabled: Prisma PostgreSQL setup requires Prisma ORM.", ); } else if ( stack.dbSetup === "mongodb-atlas" && techId !== "prisma" && techId !== "mongoose" ) { addRule( category, techId, "Disabled: MongoDB Atlas setup requires Prisma or Mongoose ORM.", ); } } if (catKey === "dbSetup" && techId !== "none") { if (stack.database === "none" && !rules.isBackendNone) { addRule( category, techId, "Disabled: A database must be selected to use this DB setup. Select 'Basic Setup' or a database first.", ); } if (techId === "turso") { if (stack.database !== "sqlite" && stack.database !== "none") { addRule( category, techId, "Disabled: Turso requires SQLite. (Will auto-select if chosen)", ); } if (stack.orm !== "drizzle" && stack.orm !== "none") { addRule( category, techId, "Disabled: Turso requires Drizzle ORM. (Will auto-select if chosen)", ); } } else if (techId === "prisma-postgres") { if (stack.database !== "postgres" && stack.database !== "none") { addRule( category, techId, "Disabled: Requires PostgreSQL. (Will auto-select if chosen)", ); } if (stack.orm !== "prisma" && stack.orm !== "none") { addRule( category, techId, "Disabled: Requires Prisma ORM. (Will auto-select if chosen)", ); } } else if (techId === "mongodb-atlas") { if (stack.database !== "mongodb" && stack.database !== "none") { addRule( category, techId, "Disabled: Requires MongoDB. (Will auto-select if chosen)", ); } if ( stack.orm !== "prisma" && stack.orm !== "mongoose" && stack.orm !== "none" ) { addRule( category, techId, "Disabled: Requires Prisma or Mongoose ORM. (Will auto-select Prisma if chosen)", ); } } else if (techId === "neon") { if (stack.database !== "postgres" && stack.database !== "none") { addRule( category, techId, "Disabled: Neon requires PostgreSQL. (Will auto-select if chosen)", ); } } } if (catKey === "auth" && techId === "true") { if (stack.database === "none" && !rules.isBackendNone) { addRule( category, techId, "Disabled: Authentication requires a database.", ); } } if (catKey === "addons") { if (techId === "pwa" && !rules.hasPWACompatible) { addRule( category, techId, "Disabled: PWA addon requires a compatible frontend (e.g., TanStack Router, Solid).", ); } if (techId === "tauri" && !rules.hasTauriCompatible) { addRule( category, techId, "Disabled: Tauri addon requires a compatible frontend (e.g., TanStack Router, Nuxt, Svelte, Solid, Next.js).", ); } } if (catKey === "examples" && techId !== "none") { if (rules.hasNativeOnly) { addRule( category, techId, "Disabled: Examples are not supported with a Native-only frontend.", ); } else { if ( !rules.hasWebFrontend && (techId === "todo" || techId === "ai") ) { addRule( category, techId, "Disabled: This example requires a web frontend.", ); } if ( stack.database === "none" && techId === "todo" && !rules.isConvex ) { addRule( category, techId, "Disabled: The 'Todo' example requires a database.", ); } if (stack.backend === "elysia" && techId === "ai") { addRule( category, techId, "Disabled: The 'AI' example is not compatible with an Elysia backend.", ); } if (rules.hasSolid && techId === "ai") { addRule( category, techId, "Disabled: The 'AI' example is not compatible with a Solid frontend.", ); } } } } } return reasons; }, [stack, rules]); 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[0] === "none") continue; for (const id of selectedValue) { const tech = options.find((opt) => opt.id === id); if (tech) { badges.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; } 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) { } 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 === "frontend" || catKey === "addons" || catKey === "examples" ) { const currentArray = Array.isArray(currentValue) ? [...currentValue] : []; let nextArray = [...currentArray]; const isSelected = currentArray.includes(techId); if (catKey === "frontend") { const webTypes = [ "tanstack-router", "react-router", "tanstack-start", "next", "nuxt", "svelte", "solid", ]; if (techId === "none") { nextArray = ["none"]; } else if (isSelected) { if (currentArray.length > 1) { nextArray = nextArray.filter((id) => id !== techId); } else { nextArray = ["none"]; } } else { nextArray = nextArray.filter((id) => id !== "none"); if (webTypes.includes(techId)) { nextArray = nextArray.filter((id) => !webTypes.includes(id)); } else if (techId.startsWith("native-")) { nextArray = nextArray.filter((id) => !id.startsWith("native-")); } nextArray.push(techId); } if (nextArray.length > 1) { nextArray = nextArray.filter((id) => id !== "none"); } if (nextArray.length === 0) { nextArray = ["none"]; } } else { if (isSelected) { nextArray = nextArray.filter((id) => id !== techId); } else { nextArray.push(techId); } } 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" || category === "auth") && techId === "false" ) { update[catKey] = "true"; } else if ( (category === "git" || category === "install" || category === "auth") && 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); setShowHelp(false); setShowPresets(false); 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); setShowHelp(false); setShowPresets(false); 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); setShowPresets(false); setShowHelp(false); contentRef.current?.scrollTo(0, 0); toast.success(`Applied preset: ${preset.name}`); } }; return (
Home
Create Better T Stack
{showHelp && (

How to Use Stack Architect

  • Use the sidebar to navigate between configuration sections.
  • Select your preferred technologies in the main area.
  • Some selections may disable or automatically change other options based on compatibility (check notes{" "} within each section!).
  • The command below updates automatically based on your selections.
  • Click the copy button ( ) next to the command to copy it.
  • Use presets () for quick setup or reset () to defaults.
  • Save () your preferences to load () them later.
)} {showPresets && (

Quick Start Presets

{PRESET_TEMPLATES.map((preset) => ( ))}
)}
{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.filter(() => { return true; }); if (filteredOptions.length === 0) return null; return (
{ sectionRefs.current[categoryKey] = el; }} key={categoryKey} id={`section-${categoryKey}`} className="mb-8 scroll-mt-4" >

{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 === "frontend" ) { isSelected = ( (currentValue as string[]) || [] ).includes(tech.id); } else { isSelected = currentValue === tech.id; } const disabledReason = disabledReasons.get( `${categoryKey}-${tech.id}`, ); const isDisabled = !!disabledReason; return ( !isDisabled && handleTechSelect( categoryKey as keyof typeof TECH_OPTIONS, tech.id, ) } >
{isSelected ? ( ) : ( )}
{tech.name}
{isDisabled && !isSelected && ( )}

{tech.description}

{tech.default && !isSelected && !isDisabled && ( Default )}
{isDisabled && disabledReason && (

{disabledReason}

)}
); })}
); })}
); }; export default StackBuilder;