add convex

This commit is contained in:
Aman Varshney
2025-04-28 11:42:11 +05:30
parent 7ef3cfce9e
commit 2a5358a105
70 changed files with 2330 additions and 1139 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
add convex

View File

@@ -88,6 +88,12 @@ export const dependencyVersionMap = {
"@trpc/tanstack-react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@trpc/client": "^11.0.0",
convex: "^1.23.0",
"@convex-dev/react-query": "^0.0.0-alpha.8",
"@tanstack/react-query-devtools": "^5.69.0",
"@tanstack/react-query": "^5.69.0",
} as const;
export type AvailableDependencies = keyof typeof dependencyVersionMap;

View File

@@ -1,80 +1,94 @@
import * as path from "node:path";
import path from "node:path";
import consola from "consola"; // Import consola
import fs from "fs-extra";
import type { ProjectConfig } from "../types";
import type { AvailableDependencies } from "../constants";
import type { ProjectConfig, ProjectFrontend } from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
export async function setupApi(config: ProjectConfig): Promise<void> {
const { api, projectName, frontend } = config;
const { api, projectName, frontend, backend, packageManager } = config;
const projectDir = path.resolve(process.cwd(), projectName);
const isConvex = backend === "convex";
const webDir = path.join(projectDir, "apps/web");
const serverDir = path.join(projectDir, "apps/server");
const nativeDir = path.join(projectDir, "apps/native");
const webDirExists = await fs.pathExists(webDir);
const hasReactWeb = frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = frontend.includes("nuxt");
const hasSvelteWeb = frontend.includes("svelte");
const nativeDirExists = await fs.pathExists(nativeDir);
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/server", "@orpc/client"],
projectDir: serverDir,
});
} else if (api === "trpc") {
await addPackageDependency({
dependencies: ["@trpc/server", "@trpc/client"],
projectDir: serverDir,
});
if (config.backend === "hono") {
await addPackageDependency({
dependencies: ["@hono/trpc-server"],
projectDir: serverDir,
});
} else if (config.backend === "elysia") {
await addPackageDependency({
dependencies: ["@elysiajs/trpc"],
projectDir: serverDir,
});
}
}
if (!isConvex && api !== "none") {
const serverDir = path.join(projectDir, "apps/server");
const serverDirExists = await fs.pathExists(serverDir);
if (webDirExists) {
if (hasReactWeb) {
const hasReactWeb = frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = frontend.includes("nuxt");
const hasSvelteWeb = frontend.includes("svelte");
if (serverDirExists) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/react-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
dependencies: ["@orpc/server", "@orpc/client"],
projectDir: serverDir,
});
} else if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
projectDir: webDir,
dependencies: ["@trpc/server", "@trpc/client"],
projectDir: serverDir,
});
if (config.backend === "hono") {
await addPackageDependency({
dependencies: ["@hono/trpc-server"],
projectDir: serverDir,
});
} else if (config.backend === "elysia") {
await addPackageDependency({
dependencies: ["@elysiajs/trpc"],
projectDir: serverDir,
});
}
}
} else if (hasNuxtWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/vue-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
});
}
} else if (hasSvelteWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/svelte-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
});
} else {
}
if (webDirExists) {
if (hasReactWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/react-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
});
} else if (api === "trpc") {
await addPackageDependency({
dependencies: [
"@trpc/tanstack-react-query",
"@trpc/client",
"@trpc/server",
],
projectDir: webDir,
});
}
} else if (hasNuxtWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: ["@orpc/vue-query", "@orpc/client", "@orpc/server"],
projectDir: webDir,
});
}
} else if (hasSvelteWeb) {
if (api === "orpc") {
await addPackageDependency({
dependencies: [
"@orpc/svelte-query",
"@orpc/client",
"@orpc/server",
],
projectDir: webDir,
});
}
}
}
}
if (frontend.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
if (nativeDirExists) {
if (api === "trpc") {
await addPackageDependency({
dependencies: [
@@ -92,4 +106,131 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
}
}
}
const reactBasedFrontends: ProjectFrontend[] = [
"react-router",
"tanstack-router",
"tanstack-start",
"next",
"native",
];
const needsReactQuery = frontend.some((f) => reactBasedFrontends.includes(f));
if (needsReactQuery && !isConvex) {
const reactQueryDeps: AvailableDependencies[] = ["@tanstack/react-query"];
const reactQueryDevDeps: AvailableDependencies[] = [
"@tanstack/react-query-devtools",
];
const hasReactWeb = frontend.some(
(f) => f !== "native" && reactBasedFrontends.includes(f),
);
const hasNative = frontend.includes("native");
if (hasReactWeb && webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
try {
await addPackageDependency({
dependencies: reactQueryDeps,
devDependencies: reactQueryDevDeps,
projectDir: webDir,
});
} catch (error) {}
} else {
}
}
if (hasNative && nativeDirExists) {
const nativePkgJsonPath = path.join(nativeDir, "package.json");
if (await fs.pathExists(nativePkgJsonPath)) {
try {
await addPackageDependency({
dependencies: reactQueryDeps,
projectDir: nativeDir,
});
} catch (error) {}
} else {
}
}
} else if (needsReactQuery && isConvex) {
}
if (isConvex) {
if (webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
try {
const webDepsToAdd: AvailableDependencies[] = ["convex"];
if (frontend.includes("tanstack-start")) {
webDepsToAdd.push("@convex-dev/react-query");
}
await addPackageDependency({
dependencies: webDepsToAdd,
projectDir: webDir,
});
} catch (error) {}
} else {
}
}
if (nativeDirExists) {
const nativePkgJsonPath = path.join(nativeDir, "package.json");
if (await fs.pathExists(nativePkgJsonPath)) {
try {
await addPackageDependency({
dependencies: ["convex"],
projectDir: nativeDir,
});
} catch (error) {}
} else {
}
}
const backendPackageName = `@${projectName}/backend`;
const backendWorkspaceVersion =
packageManager === "npm" ? "*" : "workspace:*";
const addWorkspaceDepManually = async (
pkgJsonPath: string,
depName: string,
depVersion: string,
) => {
try {
const pkgJson = await fs.readJson(pkgJsonPath);
if (!pkgJson.dependencies) {
pkgJson.dependencies = {};
}
if (pkgJson.dependencies[depName] !== depVersion) {
pkgJson.dependencies[depName] = depVersion;
await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 });
} else {
}
} catch (error) {}
};
if (webDirExists) {
const webPkgJsonPath = path.join(webDir, "package.json");
if (await fs.pathExists(webPkgJsonPath)) {
await addWorkspaceDepManually(
webPkgJsonPath,
backendPackageName,
backendWorkspaceVersion,
);
} else {
}
}
if (nativeDirExists) {
const nativePkgJsonPath = path.join(nativeDir, "package.json");
if (await fs.pathExists(nativePkgJsonPath)) {
await addWorkspaceDepManually(
nativePkgJsonPath,
backendPackageName,
backendWorkspaceVersion,
);
} else {
}
}
}
}

View File

@@ -6,8 +6,9 @@ import type { ProjectConfig } from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
export async function setupAuth(config: ProjectConfig): Promise<void> {
const { projectName, auth, frontend } = config;
if (!auth) {
const { projectName, auth, frontend, backend } = config;
if (backend === "convex" || !auth) {
return;
}
@@ -18,12 +19,15 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
const clientDirExists = await fs.pathExists(clientDir);
const nativeDirExists = await fs.pathExists(nativeDir);
const serverDirExists = await fs.pathExists(serverDir);
try {
await addPackageDependency({
dependencies: ["better-auth"],
projectDir: serverDir,
});
if (serverDirExists) {
await addPackageDependency({
dependencies: ["better-auth"],
projectDir: serverDir,
});
}
const hasWebFrontend = frontend.some((f) =>
[
@@ -48,10 +52,12 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
dependencies: ["better-auth", "@better-auth/expo"],
projectDir: nativeDir,
});
await addPackageDependency({
dependencies: ["@better-auth/expo"],
projectDir: serverDir,
});
if (serverDirExists) {
await addPackageDependency({
dependencies: ["@better-auth/expo"],
projectDir: serverDir,
});
}
}
} catch (error) {
consola.error(pc.red("Failed to configure authentication dependencies"));

View File

@@ -8,6 +8,11 @@ export async function setupBackendDependencies(
config: ProjectConfig,
): Promise<void> {
const { projectName, backend, runtime, api } = config;
if (backend === "convex") {
return;
}
const projectDir = path.resolve(process.cwd(), projectName);
const framework = backend;
const serverDir = path.join(projectDir, "apps/server");
@@ -47,9 +52,11 @@ export async function setupBackendDependencies(
devDependencies.push("@types/bun");
}
await addPackageDependency({
dependencies,
devDependencies,
projectDir: serverDir,
});
if (dependencies.length > 0 || devDependencies.length > 0) {
await addPackageDependency({
dependencies,
devDependencies,
projectDir: serverDir,
});
}
}

View File

@@ -28,44 +28,46 @@ import {
export async function createProject(options: ProjectConfig) {
const projectDir = path.resolve(process.cwd(), options.projectName);
const isConvex = options.backend === "convex";
try {
await fs.ensureDir(projectDir);
await copyBaseTemplate(projectDir, options);
await setupFrontendTemplates(projectDir, options);
await setupBackendFramework(projectDir, options);
await setupBackendDependencies(options);
await setupDbOrmTemplates(projectDir, options);
await setupDatabase(options);
await setupAuthTemplate(projectDir, options);
await setupAuth(options);
if (!isConvex) {
await setupDbOrmTemplates(projectDir, options);
await setupAuthTemplate(projectDir, options);
}
if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamplesTemplate(projectDir, options);
}
await setupAddonsTemplate(projectDir, options);
await setupApi(options);
if (!isConvex) {
await setupBackendDependencies(options);
await setupDatabase(options);
await setupRuntime(options);
if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamples(options);
}
}
if (options.addons.length > 0 && options.addons[0] !== "none") {
await setupAddons(options);
}
await setupExamplesTemplate(projectDir, options);
await handleExtras(projectDir, options);
if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamples(options);
if (!isConvex && options.auth) {
await setupAuth(options);
}
await setupApi(options);
await setupRuntime(options);
await handleExtras(projectDir, options);
await setupEnvironmentVariables(options);
await updatePackageConfigurations(projectDir, options);
await createReadme(projectDir, options);
await initializeGit(projectDir, options.git);
log.success("Project template successfully scaffolded!");
@@ -89,6 +91,10 @@ export async function createProject(options: ProjectConfig) {
cancel(pc.red(`Error during project creation: ${error.message}`));
console.error(error.stack);
process.exit(1);
} else {
cancel(pc.red(`An unexpected error occurred: ${String(error)}`));
console.error(error);
process.exit(1);
}
}
}

View File

@@ -13,13 +13,25 @@ import { setupNeonPostgres } from "./neon-setup";
import type { ProjectConfig } from "../types";
export async function setupDatabase(config: ProjectConfig): Promise<void> {
const { projectName, database, orm, dbSetup } = config;
const { projectName, database, orm, dbSetup, backend } = config;
if (backend === "convex" || database === "none") {
if (backend !== "convex") {
const projectDir = path.resolve(process.cwd(), projectName);
const serverDir = path.join(projectDir, "apps/server");
const serverDbDir = path.join(serverDir, "src/db");
if (await fs.pathExists(serverDbDir)) {
await fs.remove(serverDbDir);
}
}
return;
}
const projectDir = path.resolve(process.cwd(), projectName);
const s = spinner();
const serverDir = path.join(projectDir, "apps/server");
if (database === "none") {
await fs.remove(path.join(serverDir, "src/db"));
if (!(await fs.pathExists(serverDir))) {
return;
}

View File

@@ -5,7 +5,7 @@ import { generateAuthSecret } from "./auth-setup";
interface EnvVariable {
key: string;
value: string;
value: string | null | undefined;
condition: boolean;
}
@@ -21,41 +21,59 @@ async function addEnvVariablesToFile(
}
let modified = false;
let contentToAdd = "";
for (const { key, value, condition } of variables) {
if (condition) {
const regex = new RegExp(`^${key}=.*$`, "m");
const valueToWrite = value ?? "";
if (regex.test(envContent)) {
if (value) {
envContent = envContent.replace(regex, `${key}=${value}`);
const existingMatch = envContent.match(regex);
if (existingMatch && existingMatch[0] !== `${key}=${valueToWrite}`) {
envContent = envContent.replace(regex, `${key}=${valueToWrite}`);
modified = true;
}
} else {
envContent += `\n${key}=${value}`;
contentToAdd += `${key}=${valueToWrite}\n`;
modified = true;
}
}
}
if (contentToAdd) {
if (envContent.length > 0 && !envContent.endsWith("\n")) {
envContent += "\n";
}
envContent += contentToAdd;
}
if (modified) {
await fs.writeFile(filePath, envContent.trim());
await fs.writeFile(filePath, envContent.trimEnd());
}
}
export async function setupEnvironmentVariables(
config: ProjectConfig,
): Promise<void> {
const { projectName } = config;
const {
projectName,
backend,
frontend,
database,
orm,
auth,
examples,
dbSetup,
} = config;
const projectDir = path.resolve(process.cwd(), projectName);
const options = config;
const serverDir = path.join(projectDir, "apps/server");
const envPath = path.join(serverDir, ".env");
const hasReactRouter = options.frontend.includes("react-router");
const hasTanStackRouter = options.frontend.includes("tanstack-router");
const hasTanStackStart = options.frontend.includes("tanstack-start");
const hasNextJs = options.frontend.includes("next");
const hasNuxt = options.frontend.includes("nuxt");
const hasSvelte = options.frontend.includes("svelte");
const hasReactRouter = frontend.includes("react-router");
const hasTanStackRouter = frontend.includes("tanstack-router");
const hasTanStackStart = frontend.includes("tanstack-start");
const hasNextJs = frontend.includes("next");
const hasNuxt = frontend.includes("nuxt");
const hasSvelte = frontend.includes("svelte");
const hasWebFrontend =
hasReactRouter ||
hasTanStackRouter ||
@@ -64,30 +82,99 @@ export async function setupEnvironmentVariables(
hasNuxt ||
hasSvelte;
if (hasWebFrontend) {
const clientDir = path.join(projectDir, "apps/web");
if (await fs.pathExists(clientDir)) {
let envVarName = "VITE_SERVER_URL";
let serverUrl = "http://localhost:3000";
if (hasNextJs) {
envVarName = "NEXT_PUBLIC_SERVER_URL";
} else if (hasNuxt) {
envVarName = "NUXT_PUBLIC_SERVER_URL";
} else if (hasSvelte) {
envVarName = "PUBLIC_SERVER_URL";
}
if (backend === "convex") {
if (hasNextJs) envVarName = "NEXT_PUBLIC_CONVEX_URL";
else if (hasNuxt) envVarName = "NUXT_PUBLIC_CONVEX_URL";
else if (hasSvelte) envVarName = "PUBLIC_CONVEX_URL";
else envVarName = "VITE_CONVEX_URL";
serverUrl = "https://<YOUR_CONVEX_URL>";
}
const clientVars: EnvVariable[] = [
{
key: envVarName,
value: serverUrl,
condition: true,
},
];
await addEnvVariablesToFile(path.join(clientDir, ".env"), clientVars);
}
}
if (frontend.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
let envVarName = "EXPO_PUBLIC_SERVER_URL";
let serverUrl = "http://localhost:3000";
if (backend === "convex") {
envVarName = "EXPO_PUBLIC_CONVEX_URL";
serverUrl = "https://<YOUR_CONVEX_URL>";
}
const nativeVars: EnvVariable[] = [
{
key: envVarName,
value: serverUrl,
condition: true,
},
];
await addEnvVariablesToFile(path.join(nativeDir, ".env"), nativeVars);
}
}
if (backend === "convex") {
return;
}
const serverDir = path.join(projectDir, "apps/server");
if (!(await fs.pathExists(serverDir))) {
return;
}
const envPath = path.join(serverDir, ".env");
let corsOrigin = "http://localhost:3001";
if (hasReactRouter || hasSvelte) {
corsOrigin = "http://localhost:5173";
} else if (hasTanStackRouter || hasTanStackStart || hasNextJs || hasNuxt) {
corsOrigin = "http://localhost:3001";
}
let databaseUrl = "";
let databaseUrl: string | null = null;
const specializedSetup =
options.dbSetup === "turso" ||
options.dbSetup === "prisma-postgres" ||
options.dbSetup === "mongodb-atlas" ||
options.dbSetup === "neon";
dbSetup === "turso" ||
dbSetup === "prisma-postgres" ||
dbSetup === "mongodb-atlas" ||
dbSetup === "neon";
if (!specializedSetup) {
if (options.database === "postgres") {
databaseUrl =
"postgresql://postgres:postgres@localhost:5432/mydb?schema=public";
} else if (options.database === "mysql") {
databaseUrl = "mysql://root:password@localhost:3306/mydb";
} else if (options.database === "mongodb") {
databaseUrl = "mongodb://localhost:27017/mydatabase";
} else if (options.database === "sqlite") {
databaseUrl = "file:./local.db";
if (database !== "none" && !specializedSetup) {
switch (database) {
case "postgres":
databaseUrl =
"postgresql://postgres:postgres@localhost:5432/mydb?schema=public";
break;
case "mysql":
databaseUrl = "mysql://root:password@localhost:3306/mydb";
break;
case "mongodb":
databaseUrl = "mongodb://localhost:27017/mydatabase";
break;
case "sqlite":
databaseUrl = "file:./local.db";
break;
}
}
@@ -100,59 +187,24 @@ export async function setupEnvironmentVariables(
{
key: "BETTER_AUTH_SECRET",
value: generateAuthSecret(),
condition: !!options.auth,
condition: !!auth,
},
{
key: "BETTER_AUTH_URL",
value: "http://localhost:3000",
condition: !!options.auth,
condition: !!auth,
},
{
key: "DATABASE_URL",
value: databaseUrl,
condition:
options.database !== "none" && databaseUrl !== "" && !specializedSetup,
condition: database !== "none" && !specializedSetup,
},
{
key: "GOOGLE_GENERATIVE_AI_API_KEY",
value: "",
condition: options.examples?.includes("ai") || false,
condition: examples?.includes("ai") || false,
},
];
await addEnvVariablesToFile(envPath, serverVars);
if (hasWebFrontend) {
const clientDir = path.join(projectDir, "apps/web");
let envVarName = "VITE_SERVER_URL";
if (hasNextJs) {
envVarName = "NEXT_PUBLIC_SERVER_URL";
} else if (hasNuxt) {
envVarName = "NUXT_PUBLIC_SERVER_URL";
} else if (hasSvelte) {
envVarName = "PUBLIC_SERVER_URL";
}
const clientVars: EnvVariable[] = [
{
key: envVarName,
value: "http://localhost:3000",
condition: true,
},
];
await addEnvVariablesToFile(path.join(clientDir, ".env"), clientVars);
}
if (options.frontend.includes("native")) {
const nativeDir = path.join(projectDir, "apps/native");
const nativeVars: EnvVariable[] = [
{
key: "EXPO_PUBLIC_SERVER_URL",
value: "http://localhost:3000",
condition: true,
},
];
await addEnvVariablesToFile(path.join(nativeDir, ".env"), nativeVars);
}
}

View File

@@ -5,13 +5,24 @@ import type { ProjectConfig } from "../types";
import { addPackageDependency } from "../utils/add-package-deps";
export async function setupExamples(config: ProjectConfig): Promise<void> {
const { projectName, examples, frontend } = config;
const { projectName, examples, frontend, backend } = config;
if (
backend === "convex" ||
!examples ||
examples.length === 0 ||
examples[0] === "none"
) {
return;
}
const projectDir = path.resolve(process.cwd(), projectName);
if (examples.includes("ai")) {
const clientDir = path.join(projectDir, "apps/web");
const serverDir = path.join(projectDir, "apps/server");
const clientDirExists = await fs.pathExists(clientDir);
const serverDirExists = await fs.pathExists(serverDir);
const hasNuxt = frontend.includes("nuxt");
const hasSvelte = frontend.includes("svelte");
@@ -22,6 +33,7 @@ export async function setupExamples(config: ProjectConfig): Promise<void> {
dependencies.push("@ai-sdk/vue");
} else if (hasSvelte) {
dependencies.push("@ai-sdk/svelte");
} else {
}
await addPackageDependency({
dependencies,
@@ -29,9 +41,11 @@ export async function setupExamples(config: ProjectConfig): Promise<void> {
});
}
await addPackageDependency({
dependencies: ["ai", "@ai-sdk/google"],
projectDir: serverDir,
});
if (serverDirExists) {
await addPackageDependency({
dependencies: ["ai", "@ai-sdk/google"],
projectDir: serverDir,
});
}
}
}

View File

@@ -1,6 +1,11 @@
import { consola } from "consola";
import pc from "picocolors";
import type { ProjectDatabase, ProjectOrm, ProjectRuntime } from "../types";
import type {
ProjectBackend,
ProjectDatabase,
ProjectOrm,
ProjectRuntime,
} from "../types";
import { getPackageExecutionCommand } from "../utils/get-package-execution-command";
import type { ProjectConfig } from "../types";
@@ -17,16 +22,20 @@ export function displayPostInstallInstructions(
addons,
runtime,
frontend,
backend,
} = config;
const isConvex = backend === "convex";
const runCmd = packageManager === "npm" ? "npm run" : packageManager;
const cdCmd = `cd ${projectName}`;
const hasHuskyOrBiome =
addons?.includes("husky") || addons?.includes("biome");
const databaseInstructions =
database !== "none"
!isConvex && database !== "none"
? getDatabaseInstructions(database, orm, runCmd, runtime)
: "";
const tauriInstructions = addons?.includes("tauri")
? getTauriInstructions(runCmd)
: "";
@@ -34,7 +43,7 @@ export function displayPostInstallInstructions(
? getLintingInstructions(runCmd)
: "";
const nativeInstructions = frontend?.includes("native")
? getNativeInstructions()
? getNativeInstructions(isConvex)
: "";
const pwaInstructions =
addons?.includes("pwa") &&
@@ -45,6 +54,7 @@ export function displayPostInstallInstructions(
const starlightInstructions = addons?.includes("starlight")
? getStarlightInstructions(runCmd)
: "";
const hasWeb = frontend?.some((f) =>
[
"tanstack-router",
@@ -56,78 +66,86 @@ export function displayPostInstallInstructions(
].includes(f),
);
const hasNative = frontend?.includes("native");
const bunWebNativeWarning =
packageManager === "bun" && hasNative && hasWeb
? getBunWebNativeWarning()
: "";
const noOrmWarning =
database !== "none" && orm === "none" ? getNoOrmWarning() : "";
!isConvex && database !== "none" && orm === "none" ? getNoOrmWarning() : "";
const hasTanstackRouter = frontend?.includes("tanstack-router");
const hasTanstackStart = frontend?.includes("tanstack-start");
const hasReactRouter = frontend?.includes("react-router");
const hasNuxt = frontend?.includes("nuxt");
const hasSvelte = frontend?.includes("svelte");
const hasWebFrontend =
hasTanstackRouter ||
hasReactRouter ||
hasTanstackStart ||
hasNuxt ||
hasSvelte;
const hasNativeFrontend = frontend?.includes("native");
const hasFrontend = hasWebFrontend || hasNativeFrontend;
const webPort = hasReactRouter || hasSvelte ? "5173" : "3001";
const tazeCommand = getPackageExecutionCommand(packageManager, "taze -r");
consola.box(
`${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}
${
!depsInstalled ? `${pc.cyan("2.")} ${packageManager} install\n` : ""
}${pc.cyan(depsInstalled ? "2." : "3.")} ${runCmd} dev
${pc.bold("Your project will be available at:")}
${
hasFrontend
? `${
hasWebFrontend
? `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`
: ""
}`
: `${pc.yellow(
"NOTE:",
)} You are creating a backend-only app (no frontend selected)\n`
}${pc.cyan("•")} Backend: http://localhost:3000
${
addons?.includes("starlight")
? `${pc.cyan("•")} Docs: http://localhost:4321\n`
: ""
}${nativeInstructions ? `\n${nativeInstructions.trim()}` : ""}${
databaseInstructions ? `\n${databaseInstructions.trim()}` : ""
}${tauriInstructions ? `\n${tauriInstructions.trim()}` : ""}${
lintingInstructions ? `\n${lintingInstructions.trim()}` : ""
}${pwaInstructions ? `\n${pwaInstructions.trim()}` : ""}${
starlightInstructions ? `\n${starlightInstructions.trim()}` : ""
}${noOrmWarning ? `\n${noOrmWarning.trim()}` : ""}${
bunWebNativeWarning ? `\n${bunWebNativeWarning.trim()}` : ""
let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`;
let stepCounter = 2;
if (!depsInstalled) {
output += `${pc.cyan(`${stepCounter++}.`)} ${packageManager} install\n`;
}
if (isConvex) {
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim("(this will guide you through Convex project setup)")}\n`;
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
} else {
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
}
output += `${pc.bold("Your project will be available at:")}\n`;
if (hasWeb) {
output += `${pc.cyan("•")} Frontend: http://localhost:${webPort}\n`;
} else if (!hasNative && !addons?.includes("starlight")) {
output += `${pc.yellow("NOTE:")} You are creating a backend-only app (no frontend selected)\n`;
}
if (!isConvex) {
output += `${pc.cyan("•")} Backend API: http://localhost:3000\n`;
}
if (addons?.includes("starlight")) {
output += `${pc.cyan("•")} Docs: http://localhost:4321\n`;
}
if (nativeInstructions) output += `\n${nativeInstructions.trim()}\n`;
if (databaseInstructions) output += `\n${databaseInstructions.trim()}\n`;
if (tauriInstructions) output += `\n${tauriInstructions.trim()}\n`;
if (lintingInstructions) output += `\n${lintingInstructions.trim()}\n`;
if (pwaInstructions) output += `\n${pwaInstructions.trim()}\n`;
if (starlightInstructions) output += `\n${starlightInstructions.trim()}\n`;
if (noOrmWarning) output += `\n${noOrmWarning.trim()}\n`;
if (bunWebNativeWarning) output += `\n${bunWebNativeWarning.trim()}\n`;
output += `\n${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}\n\n`;
output += `${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub:\n`;
output += pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack");
consola.box(output);
}
${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)}
function getNativeInstructions(isConvex: boolean): string {
const envVar = isConvex ? "EXPO_PUBLIC_CONVEX_URL" : "EXPO_PUBLIC_SERVER_URL";
const exampleUrl = isConvex
? "https://<YOUR_CONVEX_URL>"
: "http://<YOUR_LOCAL_IP>:3000";
const envFileName = ".env";
const ipNote = isConvex
? "your Convex deployment URL (find after running 'dev:setup')"
: "your local IP address";
${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub:
${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`,
);
}
function getNativeInstructions(): string {
return `${pc.yellow(
"NOTE:",
)} For Expo connectivity issues, update apps/native/.env \nwith your local IP:\n${"EXPO_PUBLIC_SERVER_URL=http://192.168.0.103:3000"}\n`;
)} For Expo connectivity issues, update apps/native/${envFileName} \nwith ${ipNote}:\n${`${envVar}=${exampleUrl}`}\n`;
}
function getLintingInstructions(runCmd?: string): string {
return `${pc.bold("Linting and formatting:")}\n${pc.cyan(
"•",
)} Format and lint fix: ${`${runCmd} check`}\n\n`;
)} Format and lint fix: ${`${runCmd} check`}\n`;
}
function getDatabaseInstructions(
@@ -161,10 +179,19 @@ function getDatabaseInstructions(
} else if (orm === "drizzle") {
instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`);
if (database === "sqlite") {
instructions.push(
`${pc.cyan("•")} Start local DB (if needed): ${`cd apps/server && ${runCmd} db:local`}`,
);
}
} else if (orm === "none") {
instructions.push(
`${pc.yellow("NOTE:")} Manual database schema setup required.`,
);
}
return instructions.length
? `${pc.bold("Database commands:")}\n${instructions.join("\n")}\n\n`
? `${pc.bold("Database commands:")}\n${instructions.join("\n")}`
: "";
}
@@ -175,31 +202,31 @@ function getTauriInstructions(runCmd?: string): string {
"•",
)} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow(
"NOTE:",
)} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}\n\n`;
)} Tauri requires Rust and platform-specific dependencies.\nSee: ${"https://v2.tauri.app/start/prerequisites/"}`;
}
function getPwaInstructions(): string {
return `${pc.bold("PWA with React Router v7:")}\n${pc.yellow(
return `\n${pc.bold("PWA with React Router v7:")}\n${pc.yellow(
"NOTE:",
)} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809\n`;
)} There is a known compatibility issue between VitePWA and React Router v7.\nSee: https://github.com/vite-pwa/vite-plugin-pwa/issues/809`;
}
function getStarlightInstructions(runCmd?: string): string {
return `${pc.bold("Documentation with Starlight:")}\n${pc.cyan(
return `\n${pc.bold("Documentation with Starlight:")}\n${pc.cyan(
"•",
)} Start docs site: ${`cd apps/docs && ${runCmd} dev`}\n${pc.cyan(
"•",
)} Build docs site: ${`cd apps/docs && ${runCmd} build`}\n`;
)} Build docs site: ${`cd apps/docs && ${runCmd} build`}`;
}
function getNoOrmWarning(): string {
return `\n${pc.yellow(
"WARNING:",
)} Database selected without an ORM. Features requiring database access (e.g., examples, auth) need manual setup.\n`;
)} Database selected without an ORM. Features requiring database access (e.g., examples, auth) need manual setup.`;
}
function getBunWebNativeWarning(): string {
return `\n${pc.yellow(
"WARNING:",
)} 'bun' might cause issues with web + native apps in a monorepo. Use 'pnpm' if problems arise.\n`;
)} 'bun' might cause issues with web + native apps in a monorepo. Use 'pnpm' if problems arise.`;
}

View File

@@ -3,7 +3,6 @@ import { log } from "@clack/prompts";
import { $, execa } from "execa";
import fs from "fs-extra";
import pc from "picocolors";
import { dependencyVersionMap } from "../constants";
import type { ProjectConfig } from "../types";
export async function updatePackageConfigurations(
@@ -11,7 +10,11 @@ export async function updatePackageConfigurations(
options: ProjectConfig,
): Promise<void> {
await updateRootPackageJson(projectDir, options);
await updateServerPackageJson(projectDir, options);
if (options.backend !== "convex") {
await updateServerPackageJson(projectDir, options);
} else {
await updateConvexPackageJson(projectDir, options);
}
}
async function updateRootPackageJson(
@@ -19,75 +22,148 @@ async function updateRootPackageJson(
options: ProjectConfig,
): Promise<void> {
const rootPackageJsonPath = path.join(projectDir, "package.json");
if (await fs.pathExists(rootPackageJsonPath)) {
const packageJson = await fs.readJson(rootPackageJsonPath);
packageJson.name = options.projectName;
if (!(await fs.pathExists(rootPackageJsonPath))) return;
const turboScripts = {
dev: "turbo dev",
build: "turbo build",
"check-types": "turbo check-types",
"dev:native": "turbo -F native dev",
"dev:web": "turbo -F web dev",
"dev:server": "turbo -F server dev",
"db:push": "turbo -F server db:push",
"db:studio": "turbo -F server db:studio",
};
const packageJson = await fs.readJson(rootPackageJsonPath);
packageJson.name = options.projectName;
const pnpmScripts = {
dev: "pnpm -r dev",
build: "pnpm -r build",
"check-types": "pnpm -r check-types",
"dev:native": "pnpm --filter native dev",
"dev:web": "pnpm --filter web dev",
"dev:server": "pnpm --filter server dev",
"db:push": "pnpm --filter server db:push",
"db:studio": "pnpm --filter server db:studio",
};
if (!packageJson.scripts) {
packageJson.scripts = {};
}
const scripts = packageJson.scripts;
const npmScripts = {
dev: "npm run dev --workspaces",
build: "npm run build --workspaces",
"check-types": "npm run check-types --workspaces",
"dev:native": "npm run dev --workspace native",
"dev:web": "npm run dev --workspace web",
"dev:server": "npm run dev --workspace server",
"db:push": "npm run db:push --workspace server",
"db:studio": "npm run db:studio --workspace server",
};
const backendPackageName =
options.backend === "convex" ? `@${options.projectName}/backend` : "server";
const bunScripts = {
dev: "bun run --filter '*' dev",
build: "bun run --filter '*' build",
"check-types": "bun run --filter '*' check-types",
"dev:native": "bun run --filter native dev",
"dev:web": "bun run --filter web dev",
"dev:server": "bun run --filter server dev",
"db:push": "bun run --filter server db:push",
"db:studio": "bun run --filter server db:studio",
};
let serverDevScript = "";
if (options.addons.includes("turborepo")) {
serverDevScript = `turbo -F ${backendPackageName} dev`;
} else if (options.packageManager === "bun") {
serverDevScript = `bun run --filter ${backendPackageName} dev`;
} else if (options.packageManager === "pnpm") {
serverDevScript = `pnpm --filter ${backendPackageName} dev`;
} else if (options.packageManager === "npm") {
serverDevScript = `npm run dev --workspace ${backendPackageName}`;
}
if (options.addons.includes("turborepo")) {
packageJson.scripts = turboScripts;
} else {
if (options.packageManager === "pnpm") {
packageJson.scripts = pnpmScripts;
} else if (options.packageManager === "npm") {
packageJson.scripts = npmScripts;
} else if (options.packageManager === "bun") {
packageJson.scripts = bunScripts;
} else {
packageJson.scripts = {};
}
let devScript = "";
if (options.packageManager === "pnpm") {
devScript = "pnpm -r dev";
} else if (options.packageManager === "npm") {
devScript = "npm run dev --workspaces";
} else if (options.packageManager === "bun") {
devScript = "bun run --filter '*' dev";
}
const needsDbScripts =
options.backend !== "convex" &&
options.database !== "none" &&
options.orm !== "none";
if (options.addons.includes("turborepo")) {
scripts.dev = "turbo dev";
scripts.build = "turbo build";
scripts["check-types"] = "turbo check-types";
scripts["dev:native"] = "turbo -F native dev";
scripts["dev:web"] = "turbo -F web dev";
scripts["dev:server"] = serverDevScript;
if (options.backend === "convex") {
scripts["dev:setup"] = `turbo -F ${backendPackageName} setup`;
}
if (needsDbScripts) {
scripts["db:push"] = `turbo -F ${backendPackageName} db:push`;
scripts["db:studio"] = `turbo -F ${backendPackageName} db:studio`;
}
} else if (options.packageManager === "pnpm") {
scripts.dev = devScript;
scripts.build = "pnpm -r build";
scripts["check-types"] = "pnpm -r check-types";
scripts["dev:native"] = "pnpm --filter native dev";
scripts["dev:web"] = "pnpm --filter web dev";
scripts["dev:server"] = serverDevScript;
if (options.backend === "convex") {
scripts["dev:setup"] = `pnpm --filter ${backendPackageName} setup`;
}
if (needsDbScripts) {
scripts["db:push"] = `pnpm --filter ${backendPackageName} db:push`;
scripts["db:studio"] = `pnpm --filter ${backendPackageName} db:studio`;
}
} else if (options.packageManager === "npm") {
scripts.dev = devScript;
scripts.build = "npm run build --workspaces";
scripts["check-types"] = "npm run check-types --workspaces";
scripts["dev:native"] = "npm run dev --workspace native";
scripts["dev:web"] = "npm run dev --workspace web";
scripts["dev:server"] = serverDevScript;
if (options.backend === "convex") {
scripts["dev:setup"] = `npm run setup --workspace ${backendPackageName}`;
}
if (needsDbScripts) {
scripts["db:push"] = `npm run db:push --workspace ${backendPackageName}`;
scripts["db:studio"] =
`npm run db:studio --workspace ${backendPackageName}`;
}
} else if (options.packageManager === "bun") {
scripts.dev = devScript;
scripts.build = "bun run --filter '*' build";
scripts["check-types"] = "bun run --filter '*' check-types";
scripts["dev:native"] = "bun run --filter native dev";
scripts["dev:web"] = "bun run --filter web dev";
scripts["dev:server"] = serverDevScript;
if (options.backend === "convex") {
scripts["dev:setup"] = `bun run --filter ${backendPackageName} setup`;
}
if (needsDbScripts) {
scripts["db:push"] = `bun run --filter ${backendPackageName} db:push`;
scripts["db:studio"] = `bun run --filter ${backendPackageName} db:studio`;
}
}
if (options.addons.includes("biome")) {
scripts.check = "biome check --write .";
}
if (options.addons.includes("husky")) {
scripts.prepare = "husky";
packageJson["lint-staged"] = {
"*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}": [
"biome check --write .",
],
};
}
try {
const { stdout } = await execa(options.packageManager, ["-v"], {
cwd: projectDir,
});
packageJson.packageManager = `${options.packageManager}@${stdout.trim()}`;
await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 });
} catch (e) {
log.warn(`Could not determine ${options.packageManager} version.`);
}
if (!packageJson.workspaces) {
packageJson.workspaces = [];
}
const workspaces = packageJson.workspaces;
if (options.backend === "convex") {
if (!workspaces.includes("packages/*")) {
workspaces.push("packages/*");
}
const needsAppsDir =
options.frontend.length > 0 || options.addons.includes("starlight");
if (needsAppsDir && !workspaces.includes("apps/*")) {
workspaces.push("apps/*");
}
} else {
if (!workspaces.includes("apps/*")) {
workspaces.push("apps/*");
}
if (!workspaces.includes("packages/*")) {
workspaces.push("packages/*");
}
}
await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 });
}
async function updateServerPackageJson(
@@ -99,28 +175,53 @@ async function updateServerPackageJson(
"apps/server/package.json",
);
if (await fs.pathExists(serverPackageJsonPath)) {
const serverPackageJson = await fs.readJson(serverPackageJsonPath);
if (!(await fs.pathExists(serverPackageJsonPath))) return;
if (options.database !== "none") {
if (options.database === "sqlite" && options.orm === "drizzle") {
serverPackageJson.scripts["db:local"] = "turso dev --db-file local.db";
}
const serverPackageJson = await fs.readJson(serverPackageJsonPath);
if (options.orm === "prisma") {
serverPackageJson.scripts["db:push"] =
"prisma db push --schema ./prisma/schema";
serverPackageJson.scripts["db:studio"] = "prisma studio";
} else if (options.orm === "drizzle") {
serverPackageJson.scripts["db:push"] = "drizzle-kit push";
serverPackageJson.scripts["db:studio"] = "drizzle-kit studio";
}
if (!serverPackageJson.scripts) {
serverPackageJson.scripts = {};
}
const scripts = serverPackageJson.scripts;
if (options.database !== "none") {
if (options.database === "sqlite" && options.orm === "drizzle") {
scripts["db:local"] = "turso dev --db-file local.db";
}
await fs.writeJson(serverPackageJsonPath, serverPackageJson, {
spaces: 2,
});
if (options.orm === "prisma") {
scripts["db:push"] = "prisma db push --schema ./prisma/schema.prisma";
scripts["db:studio"] = "prisma studio";
} else if (options.orm === "drizzle") {
scripts["db:push"] = "drizzle-kit push";
scripts["db:studio"] = "drizzle-kit studio";
}
}
await fs.writeJson(serverPackageJsonPath, serverPackageJson, {
spaces: 2,
});
}
async function updateConvexPackageJson(
projectDir: string,
options: ProjectConfig,
): Promise<void> {
const convexPackageJsonPath = path.join(
projectDir,
"packages/backend/package.json",
);
if (!(await fs.pathExists(convexPackageJsonPath))) return;
const convexPackageJson = await fs.readJson(convexPackageJsonPath);
convexPackageJson.name = `@${options.projectName}/backend`;
if (!convexPackageJson.scripts) {
convexPackageJson.scripts = {};
}
await fs.writeJson(convexPackageJsonPath, convexPackageJson, { spaces: 2 });
}
export async function initializeGit(

View File

@@ -5,13 +5,18 @@ import { addPackageDependency } from "../utils/add-package-deps";
export async function setupRuntime(config: ProjectConfig): Promise<void> {
const { projectName, runtime, backend } = config;
const projectDir = path.resolve(process.cwd(), projectName);
if (backend === "next") {
if (backend === "convex" || backend === "next" || runtime === "none") {
return;
}
const projectDir = path.resolve(process.cwd(), projectName);
const serverDir = path.join(projectDir, "apps/server");
if (!(await fs.pathExists(serverDir))) {
return;
}
if (runtime === "bun") {
await setupBunRuntime(serverDir, backend);
} else if (runtime === "node") {
@@ -24,6 +29,8 @@ async function setupBunRuntime(
backend: ProjectBackend,
): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return;
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {
@@ -45,6 +52,8 @@ async function setupNodeRuntime(
backend: ProjectBackend,
): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return;
const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = {

View File

@@ -1,5 +1,4 @@
import path from "node:path";
import consola from "consola";
import fs from "fs-extra";
import { globby } from "globby";
import pc from "picocolors";
@@ -28,26 +27,28 @@ async function processAndCopyFiles(
if (relativeSrcPath.endsWith(".hbs")) {
relativeDestPath = relativeSrcPath.slice(0, -4);
}
if (path.basename(relativeSrcPath) === "_gitignore") {
const basename = path.basename(relativeSrcPath);
if (basename === "_gitignore") {
relativeDestPath = path.join(path.dirname(relativeSrcPath), ".gitignore");
}
if (path.basename(relativeSrcPath) === "_npmrc") {
} else if (basename === "_npmrc") {
relativeDestPath = path.join(path.dirname(relativeSrcPath), ".npmrc");
}
const destPath = path.join(destDir, relativeDestPath);
await fs.ensureDir(path.dirname(destPath));
try {
await fs.ensureDir(path.dirname(destPath));
if (!overwrite && (await fs.pathExists(destPath))) {
continue;
}
if (!overwrite && (await fs.pathExists(destPath))) {
continue;
}
if (srcPath.endsWith(".hbs")) {
await processTemplate(srcPath, destPath, context);
} else {
await fs.copy(srcPath, destPath, { overwrite: true });
}
if (srcPath.endsWith(".hbs")) {
await processTemplate(srcPath, destPath, context);
} else {
await fs.copy(srcPath, destPath, { overwrite: true });
}
} catch (error) {}
}
}
@@ -57,6 +58,7 @@ export async function copyBaseTemplate(
): Promise<void> {
const templateDir = path.join(PKG_ROOT, "templates/base");
await processAndCopyFiles(["**/*"], templateDir, projectDir, context);
await fs.ensureDir(path.join(projectDir, "packages"));
}
export async function setupFrontendTemplates(
@@ -69,6 +71,7 @@ export async function setupFrontendTemplates(
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasNative = context.frontend.includes("native");
const isConvex = context.backend === "convex";
if (hasReactWeb || hasNuxtWeb || hasSvelteWeb) {
const webAppDir = path.join(projectDir, "apps/web");
@@ -81,6 +84,7 @@ export async function setupFrontendTemplates(
);
if (await fs.pathExists(webBaseDir)) {
await processAndCopyFiles("**/*", webBaseDir, webAppDir, context);
} else {
}
const reactFramework = context.frontend.find((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(
@@ -99,33 +103,47 @@ export async function setupFrontendTemplates(
webAppDir,
context,
);
} else {
}
const apiWebBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/react/base`,
);
if (await fs.pathExists(apiWebBaseDir)) {
await processAndCopyFiles("**/*", apiWebBaseDir, webAppDir, context);
if (!isConvex && context.api !== "none") {
const apiWebBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/react/base`,
);
if (await fs.pathExists(apiWebBaseDir)) {
await processAndCopyFiles(
"**/*",
apiWebBaseDir,
webAppDir,
context,
);
} else {
}
}
}
} else if (hasNuxtWeb) {
const nuxtBaseDir = path.join(PKG_ROOT, "templates/frontend/nuxt");
if (await fs.pathExists(nuxtBaseDir)) {
await processAndCopyFiles("**/*", nuxtBaseDir, webAppDir, context);
} else {
}
const apiWebNuxtDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/nuxt`,
);
if (await fs.pathExists(apiWebNuxtDir)) {
await processAndCopyFiles("**/*", apiWebNuxtDir, webAppDir, context);
if (!isConvex && context.api !== "none") {
const apiWebNuxtDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/nuxt`,
);
if (await fs.pathExists(apiWebNuxtDir)) {
await processAndCopyFiles("**/*", apiWebNuxtDir, webAppDir, context);
} else {
}
}
} else if (hasSvelteWeb) {
const svelteBaseDir = path.join(PKG_ROOT, "templates/frontend/svelte");
if (await fs.pathExists(svelteBaseDir)) {
await processAndCopyFiles("**/*", svelteBaseDir, webAppDir, context);
} else {
}
if (context.api === "orpc") {
if (!isConvex && context.api === "orpc") {
const apiWebSvelteDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/svelte`,
@@ -137,6 +155,7 @@ export async function setupFrontendTemplates(
webAppDir,
context,
);
} else {
}
}
}
@@ -149,22 +168,10 @@ export async function setupFrontendTemplates(
const nativeBaseDir = path.join(PKG_ROOT, "templates/frontend/native");
if (await fs.pathExists(nativeBaseDir)) {
await processAndCopyFiles("**/*", nativeBaseDir, nativeAppDir, context);
} else {
}
if (context.api === "trpc") {
const apiNativeSrcDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/native`,
);
if (await fs.pathExists(apiNativeSrcDir)) {
await processAndCopyFiles(
"**/*",
apiNativeSrcDir,
nativeAppDir,
context,
);
}
} else if (context.api === "orpc") {
if (!isConvex && (context.api === "trpc" || context.api === "orpc")) {
const apiNativeSrcDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/native`,
@@ -176,6 +183,7 @@ export async function setupFrontendTemplates(
nativeAppDir,
context,
);
} else {
}
}
}
@@ -185,53 +193,89 @@ export async function setupBackendFramework(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
if ((context.backend as string) === "none") return;
if (context.backend === "convex") {
const convexBackendDestDir = path.join(projectDir, "packages/backend");
const convexSrcDir = path.join(
PKG_ROOT,
"templates/backend/convex/packages/backend",
);
await fs.ensureDir(convexBackendDestDir);
if (await fs.pathExists(convexSrcDir)) {
await processAndCopyFiles(
"**/*",
convexSrcDir,
convexBackendDestDir,
context,
);
} else {
}
const serverAppDir = path.join(projectDir, "apps/server");
if (await fs.pathExists(serverAppDir)) {
await fs.remove(serverAppDir);
}
return;
}
const serverAppDir = path.join(projectDir, "apps/server");
await fs.ensureDir(serverAppDir);
const serverBaseDir = path.join(PKG_ROOT, "templates/backend/server-base");
const serverBaseDir = path.join(
PKG_ROOT,
"templates/backend/server/server-base",
);
if (await fs.pathExists(serverBaseDir)) {
await processAndCopyFiles("**/*", serverBaseDir, serverAppDir, context);
} else {
consola.warn(
pc.yellow(`Warning: server-base template not found at ${serverBaseDir}`),
);
}
const frameworkSrcDir = path.join(
PKG_ROOT,
`templates/backend/${context.backend}`,
`templates/backend/server/${context.backend}`,
);
if (await fs.pathExists(frameworkSrcDir)) {
await processAndCopyFiles("**/*", frameworkSrcDir, serverAppDir, context);
} else {
consola.warn(
pc.yellow(
`Warning: Backend template directory not found, skipping: ${frameworkSrcDir}`,
),
);
}
const apiServerBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/server/base`,
);
if (await fs.pathExists(apiServerBaseDir)) {
await processAndCopyFiles("**/*", apiServerBaseDir, serverAppDir, context);
}
const apiServerFrameworkDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/server/${context.backend}`,
);
if (await fs.pathExists(apiServerFrameworkDir)) {
await processAndCopyFiles(
"**/*",
apiServerFrameworkDir,
frameworkSrcDir,
serverAppDir,
context,
true,
);
} else {
}
if (context.api !== "none") {
const apiServerBaseDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/server/base`,
);
if (await fs.pathExists(apiServerBaseDir)) {
await processAndCopyFiles(
"**/*",
apiServerBaseDir,
serverAppDir,
context,
true,
);
} else {
}
const apiServerFrameworkDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/server/${context.backend}`,
);
if (await fs.pathExists(apiServerFrameworkDir)) {
await processAndCopyFiles(
"**/*",
apiServerFrameworkDir,
serverAppDir,
context,
true,
);
} else {
}
}
}
@@ -239,7 +283,12 @@ export async function setupDbOrmTemplates(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
if (context.orm === "none" || context.database === "none") return;
if (
context.backend === "convex" ||
context.orm === "none" ||
context.database === "none"
)
return;
const serverAppDir = path.join(projectDir, "apps/server");
await fs.ensureDir(serverAppDir);
@@ -252,11 +301,6 @@ export async function setupDbOrmTemplates(
if (await fs.pathExists(dbOrmSrcDir)) {
await processAndCopyFiles("**/*", dbOrmSrcDir, serverAppDir, context);
} else {
consola.warn(
pc.yellow(
`Warning: Database/ORM template directory not found, skipping: ${dbOrmSrcDir}`,
),
);
}
}
@@ -264,7 +308,7 @@ export async function setupAuthTemplate(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
if (!context.auth) return;
if (context.backend === "convex" || !context.auth) return;
const serverAppDir = path.join(projectDir, "apps/server");
const webAppDir = path.join(projectDir, "apps/web");
@@ -290,6 +334,7 @@ export async function setupAuthTemplate(
serverAppDir,
context,
);
} else {
}
if (context.backend === "next") {
@@ -304,6 +349,7 @@ export async function setupAuthTemplate(
serverAppDir,
context,
);
} else {
}
}
@@ -324,12 +370,7 @@ export async function setupAuthTemplate(
}
if (authDbSrc && (await fs.pathExists(authDbSrc))) {
await processAndCopyFiles("**/*", authDbSrc, serverAppDir, context);
} else {
consola.warn(
pc.yellow(
`Warning: Auth template for ${orm}/${db} not found at ${authDbSrc}`,
),
);
} else if (authDbSrc) {
}
}
}
@@ -342,6 +383,7 @@ export async function setupAuthTemplate(
);
if (await fs.pathExists(authWebBaseSrc)) {
await processAndCopyFiles("**/*", authWebBaseSrc, webAppDir, context);
} else {
}
const reactFramework = context.frontend.find((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(
@@ -360,12 +402,14 @@ export async function setupAuthTemplate(
webAppDir,
context,
);
} else {
}
}
} else if (hasNuxtWeb) {
const authWebNuxtSrc = path.join(PKG_ROOT, "templates/auth/web/nuxt");
if (await fs.pathExists(authWebNuxtSrc)) {
await processAndCopyFiles("**/*", authWebNuxtSrc, webAppDir, context);
} else {
}
} else if (hasSvelteWeb) {
if (context.api === "orpc") {
@@ -380,6 +424,7 @@ export async function setupAuthTemplate(
webAppDir,
context,
);
} else {
}
}
}
@@ -390,11 +435,6 @@ export async function setupAuthTemplate(
if (await fs.pathExists(authNativeSrc)) {
await processAndCopyFiles("**/*", authNativeSrc, nativeAppDir, context);
} else {
consola.warn(
pc.yellow(
`Warning: Auth native template not found at ${authNativeSrc}`,
),
);
}
}
}
@@ -430,8 +470,6 @@ export async function setupExamplesTemplate(
projectDir: string,
context: ProjectConfig,
): Promise<void> {
if (!context.examples || context.examples.length === 0) return;
const serverAppDir = path.join(projectDir, "apps/server");
const webAppDir = path.join(projectDir, "apps/web");
@@ -445,6 +483,13 @@ export async function setupExamplesTemplate(
const hasSvelteWeb = context.frontend.includes("svelte");
for (const example of context.examples) {
if (
!context.examples ||
context.examples.length === 0 ||
context.examples[0] === "none"
)
continue;
if (example === "none") continue;
const exampleBaseDir = path.join(PKG_ROOT, `templates/examples/${example}`);
@@ -452,23 +497,23 @@ export async function setupExamplesTemplate(
if (serverAppDirExists) {
const exampleServerSrc = path.join(exampleBaseDir, "server");
if (await fs.pathExists(exampleServerSrc)) {
if (context.orm !== "none") {
const exampleOrmBaseSrc = path.join(
exampleServerSrc,
context.orm,
"base",
);
if (await fs.pathExists(exampleOrmBaseSrc)) {
await processAndCopyFiles(
"**/*",
exampleOrmBaseSrc,
serverAppDir,
context,
false,
if (context.backend !== "convex") {
if (context.orm !== "none" && context.database !== "none") {
const exampleOrmBaseSrc = path.join(
exampleServerSrc,
context.orm,
"base",
);
}
if (await fs.pathExists(exampleOrmBaseSrc)) {
await processAndCopyFiles(
"**/*",
exampleOrmBaseSrc,
serverAppDir,
context,
false,
);
}
if (context.database !== "none") {
const exampleDbSchemaSrc = path.join(
exampleServerSrc,
context.orm,
@@ -484,63 +529,81 @@ export async function setupExamplesTemplate(
);
}
}
const generalServerFiles = await globby(["*.ts", "*.hbs"], {
cwd: exampleServerSrc,
onlyFiles: true,
deep: 1,
ignore: [`${context.orm}/**`],
});
for (const file of generalServerFiles) {
const srcPath = path.join(exampleServerSrc, file);
const destPath = path.join(serverAppDir, file.replace(".hbs", ""));
if (srcPath.endsWith(".hbs")) {
await processTemplate(srcPath, destPath, context);
} else {
await fs.copy(srcPath, destPath, { overwrite: false });
}
}
}
}
}
if (hasReactWeb && webAppDirExists) {
const exampleWebSrc = path.join(exampleBaseDir, "web/react");
if (await fs.pathExists(exampleWebSrc)) {
const reactFramework = context.frontend.find((f) =>
[
"next",
"react-router",
"tanstack-router",
"tanstack-start",
].includes(f),
);
if (reactFramework) {
const exampleWebFrameworkSrc = path.join(
exampleWebSrc,
reactFramework,
if (webAppDirExists) {
if (hasReactWeb) {
const exampleWebSrc = path.join(exampleBaseDir, "web/react");
if (await fs.pathExists(exampleWebSrc)) {
const reactFramework = context.frontend.find((f) =>
[
"next",
"react-router",
"tanstack-router",
"tanstack-start",
].includes(f),
);
if (await fs.pathExists(exampleWebFrameworkSrc)) {
if (reactFramework) {
const exampleWebFrameworkSrc = path.join(
exampleWebSrc,
reactFramework,
);
if (await fs.pathExists(exampleWebFrameworkSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebFrameworkSrc,
webAppDir,
context,
false,
);
} else {
}
}
}
} else if (hasNuxtWeb) {
if (context.api === "orpc") {
const exampleWebNuxtSrc = path.join(exampleBaseDir, "web/nuxt");
if (await fs.pathExists(exampleWebNuxtSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebFrameworkSrc,
exampleWebNuxtSrc,
webAppDir,
context,
false,
);
} else {
}
}
}
} else if (hasNuxtWeb && webAppDirExists) {
if (context.api === "orpc") {
const exampleWebNuxtSrc = path.join(exampleBaseDir, "web/nuxt");
if (await fs.pathExists(exampleWebNuxtSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebNuxtSrc,
webAppDir,
context,
false,
);
} else {
}
}
} else if (hasSvelteWeb && webAppDirExists) {
if (context.api === "orpc") {
const exampleWebSvelteSrc = path.join(exampleBaseDir, "web/svelte");
if (await fs.pathExists(exampleWebSvelteSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebSvelteSrc,
webAppDir,
context,
false,
);
} else {
} else if (hasSvelteWeb) {
if (context.api === "orpc") {
const exampleWebSvelteSrc = path.join(exampleBaseDir, "web/svelte");
if (await fs.pathExists(exampleWebSvelteSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebSvelteSrc,
webAppDir,
context,
false,
);
} else {
}
}
}
}

View File

@@ -123,17 +123,17 @@ async function main() {
.option("backend", {
type: "string",
describe: "Backend framework",
choices: ["hono", "express", "next", "elysia"],
choices: ["hono", "express", "next", "elysia", "convex"],
})
.option("runtime", {
type: "string",
describe: "Runtime",
choices: ["bun", "node"],
choices: ["bun", "node", "none"],
})
.option("api", {
type: "string",
describe: "API type",
choices: ["trpc", "orpc"],
choices: ["trpc", "orpc", "none"],
})
.completion()
.recommendCommands()
@@ -168,11 +168,17 @@ async function main() {
...flagConfig,
};
if (config.database === "none") {
if (config.backend === "convex") {
config.auth = false;
config.database = "none";
config.orm = "none";
config.api = "none";
config.runtime = "none";
config.dbSetup = "none";
} else if (config.database === "none") {
config.orm = "none";
config.auth = false;
config.dbSetup = "none";
config.examples = config.examples.filter((ex) => ex !== "todo");
}
log.info(pc.yellow("Using these default/flag options:"));
@@ -216,7 +222,9 @@ async function main() {
cancel(pc.red(`Invalid arguments: ${error.message}`));
} else {
consola.error(`An unexpected error occurred: ${error.message}`);
consola.error(error.stack);
if (!error.message.includes("is only supported with")) {
consola.error(error.stack);
}
}
process.exit(1);
} else {
@@ -232,6 +240,32 @@ function processAndValidateFlags(
projectDirectory?: string,
): Partial<ProjectConfig> {
const config: Partial<ProjectConfig> = {};
const providedFlags: Set<string> = new Set(
Object.keys(options).filter((key) => key !== "_" && key !== "$0"),
);
if (options.backend) {
config.backend = options.backend as ProjectBackend;
}
if (
providedFlags.has("backend") &&
config.backend &&
config.backend !== "convex"
) {
if (providedFlags.has("api") && options.api === "none") {
consola.fatal(
`'--api none' is only supported with '--backend convex'. Please choose 'trpc', 'orpc', or remove the --api flag.`,
);
process.exit(1);
}
if (providedFlags.has("runtime") && options.runtime === "none") {
consola.fatal(
`'--runtime none' is only supported with '--backend convex'. Please choose 'bun', 'node', or remove the --runtime flag.`,
);
process.exit(1);
}
}
if (options.database) {
config.database = options.database as ProjectDatabase;
@@ -248,12 +282,22 @@ function processAndValidateFlags(
if (options.install !== undefined) {
config.install = options.install;
}
if (options.backend) {
config.backend = options.backend as ProjectBackend;
}
if (options.runtime) {
config.runtime = options.runtime as ProjectRuntime;
}
if (options.api) {
config.api = options.api as ProjectApi;
}
if (options.dbSetup) {
config.dbSetup = options.dbSetup as ProjectDBSetup;
}
if (options.packageManager) {
config.packageManager = options.packageManager as ProjectPackageManager;
}
if (projectDirectory) {
config.projectName = projectDirectory;
}
if (options.frontend && options.frontend.length > 0) {
if (options.frontend.includes("none")) {
if (options.frontend.length > 1) {
@@ -283,9 +327,6 @@ function processAndValidateFlags(
config.frontend = validOptions;
}
}
if (options.api) {
config.api = options.api as ProjectApi;
}
if (options.addons && options.addons.length > 0) {
if (options.addons.includes("none")) {
if (options.addons.length > 1) {
@@ -310,231 +351,283 @@ function processAndValidateFlags(
config.examples = options.examples.filter(
(ex): ex is ProjectExamples => ex !== "none",
);
if (config.backend !== "convex" && options.examples.includes("none")) {
config.examples = [];
} else {
config.examples = ["todo"];
}
}
}
if (options.packageManager) {
config.packageManager = options.packageManager as ProjectPackageManager;
}
if (projectDirectory) {
config.projectName = projectDirectory;
}
if (options.dbSetup) {
config.dbSetup = options.dbSetup as ProjectDBSetup;
}
const effectiveDatabase =
config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined);
const effectiveOrm =
config.orm ?? (options.yes ? DEFAULT_CONFIG.orm : undefined);
const effectiveAuth =
config.auth ?? (options.yes ? DEFAULT_CONFIG.auth : undefined);
const effectiveDbSetup =
config.dbSetup ?? (options.yes ? DEFAULT_CONFIG.dbSetup : undefined);
const effectiveExamples =
config.examples ?? (options.yes ? DEFAULT_CONFIG.examples : undefined);
const effectiveFrontend =
config.frontend ?? (options.yes ? DEFAULT_CONFIG.frontend : undefined);
const effectiveApi =
config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined);
const effectiveBackend =
config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined);
if (config.backend === "convex") {
const incompatibleFlags: string[] = [];
if (effectiveDatabase === "none") {
if (effectiveOrm && effectiveOrm !== "none") {
if (providedFlags.has("auth") && options.auth === true)
incompatibleFlags.push("--auth");
if (providedFlags.has("database") && options.database !== "none")
incompatibleFlags.push(`--database ${options.database}`);
if (providedFlags.has("orm") && options.orm !== "none")
incompatibleFlags.push(`--orm ${options.orm}`);
if (providedFlags.has("api") && options.api !== "none")
incompatibleFlags.push(`--api ${options.api}`);
if (providedFlags.has("runtime") && options.runtime !== "none")
incompatibleFlags.push(`--runtime ${options.runtime}`);
if (providedFlags.has("dbSetup") && options.dbSetup !== "none")
incompatibleFlags.push(`--db-setup ${options.dbSetup}`);
if (providedFlags.has("examples")) {
incompatibleFlags.push("--examples");
}
if (incompatibleFlags.length > 0) {
consola.fatal(
`Cannot use ORM '--orm ${effectiveOrm}' when database is 'none'.`,
`The following flags are incompatible with '--backend convex': ${incompatibleFlags.join(
", ",
)}. Please remove them. The 'todo' example is included automatically with Convex.`,
);
process.exit(1);
}
config.orm = "none";
if (effectiveAuth === true) {
consola.fatal(
"Authentication requires a database. Cannot use --auth when database is 'none'.",
);
process.exit(1);
}
config.auth = false;
if (effectiveDbSetup && effectiveDbSetup !== "none") {
consola.fatal(
`Database setup '--db-setup ${effectiveDbSetup}' requires a database. Cannot use when database is 'none'.`,
);
process.exit(1);
}
config.database = "none";
config.orm = "none";
config.api = "none";
config.runtime = "none";
config.dbSetup = "none";
config.examples = ["todo"];
} else {
const effectiveDatabase =
config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined);
const effectiveOrm =
config.orm ?? (options.yes ? DEFAULT_CONFIG.orm : undefined);
const effectiveAuth =
config.auth ?? (options.yes ? DEFAULT_CONFIG.auth : undefined);
const effectiveDbSetup =
config.dbSetup ?? (options.yes ? DEFAULT_CONFIG.dbSetup : undefined);
const effectiveExamples =
config.examples ?? (options.yes ? DEFAULT_CONFIG.examples : undefined);
const effectiveFrontend =
config.frontend ?? (options.yes ? DEFAULT_CONFIG.frontend : undefined);
const effectiveApi =
config.api ?? (options.yes ? DEFAULT_CONFIG.api : undefined);
const effectiveBackend =
config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined);
if (effectiveExamples?.includes("todo")) {
if (effectiveDatabase === "none") {
if (providedFlags.has("orm") && options.orm !== "none") {
consola.fatal(
`Cannot use ORM '--orm ${options.orm}' when database is 'none'.`,
);
process.exit(1);
}
config.orm = "none";
if (providedFlags.has("auth") && options.auth === true) {
consola.fatal(
"Authentication requires a database. Cannot use --auth when database is 'none'.",
);
process.exit(1);
}
config.auth = false;
if (providedFlags.has("dbSetup") && options.dbSetup !== "none") {
consola.fatal(
`Database setup '--db-setup ${options.dbSetup}' requires a database. Cannot use when database is 'none'.`,
);
process.exit(1);
}
config.dbSetup = "none";
}
if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") {
consola.fatal(
"The 'todo' example requires a database. Cannot use --examples todo when database is 'none'.",
"MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle",
);
process.exit(1);
}
if (config.examples) {
config.examples = config.examples.filter((ex) => ex !== "todo");
if (config.dbSetup && config.dbSetup !== "none") {
const dbSetup = config.dbSetup;
if (dbSetup === "turso") {
if (effectiveDatabase && effectiveDatabase !== "sqlite") {
consola.fatal(
`Turso setup requires SQLite. Cannot use --db-setup turso with --database ${effectiveDatabase}`,
);
process.exit(1);
}
if (effectiveOrm === "prisma") {
consola.fatal(
"Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma",
);
process.exit(1);
}
config.database = "sqlite";
config.orm = "drizzle";
} else if (dbSetup === "prisma-postgres") {
if (effectiveDatabase && effectiveDatabase !== "postgres") {
consola.fatal(
`Prisma PostgreSQL setup requires PostgreSQL. Cannot use --db-setup prisma-postgres with --database ${effectiveDatabase}.`,
);
process.exit(1);
}
if (
effectiveOrm &&
effectiveOrm !== "prisma" &&
effectiveOrm !== "none"
) {
consola.fatal(
`Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with --orm ${effectiveOrm}.`,
);
process.exit(1);
}
config.database = "postgres";
config.orm = "prisma";
} else if (dbSetup === "mongodb-atlas") {
if (effectiveDatabase && effectiveDatabase !== "mongodb") {
consola.fatal(
`MongoDB Atlas setup requires MongoDB. Cannot use --db-setup mongodb-atlas with --database ${effectiveDatabase}.`,
);
process.exit(1);
}
if (
effectiveOrm &&
effectiveOrm !== "prisma" &&
effectiveOrm !== "none"
) {
consola.fatal(
`MongoDB Atlas setup requires Prisma ORM. Cannot use --db-setup mongodb-atlas with --orm ${effectiveOrm}.`,
);
process.exit(1);
}
config.database = "mongodb";
config.orm = "prisma";
} else if (dbSetup === "neon") {
if (effectiveDatabase && effectiveDatabase !== "postgres") {
consola.fatal(
`Neon PostgreSQL setup requires PostgreSQL. Cannot use --db-setup neon with --database ${effectiveDatabase}.`,
);
process.exit(1);
}
config.database = "postgres";
}
}
}
if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") {
consola.fatal(
"MongoDB is only available with Prisma. Cannot use --database mongodb with --orm drizzle",
);
process.exit(1);
}
const includesNuxt = effectiveFrontend?.includes("nuxt");
const includesSvelte = effectiveFrontend?.includes("svelte");
if (config.dbSetup && config.dbSetup !== "none") {
const dbSetup = config.dbSetup;
if (dbSetup === "turso") {
if (effectiveDatabase && effectiveDatabase !== "sqlite") {
if ((includesNuxt || includesSvelte) && effectiveApi === "trpc") {
consola.fatal(
`tRPC API is not supported with '${
includesNuxt ? "nuxt" : "svelte"
}' frontend. Please use --api orpc or remove '${
includesNuxt ? "nuxt" : "svelte"
}' from --frontend.`,
);
process.exit(1);
}
if (
(includesNuxt || includesSvelte) &&
effectiveApi !== "orpc" &&
(!options.api || (options.yes && options.api !== "trpc"))
) {
if (config.api !== "none") {
config.api = "orpc";
}
}
if (config.addons && config.addons.length > 0) {
const webSpecificAddons = ["pwa", "tauri"];
const hasWebSpecificAddons = config.addons.some((addon) =>
webSpecificAddons.includes(addon),
);
const hasCompatibleWebFrontend = effectiveFrontend?.some(
(f) =>
f === "tanstack-router" ||
f === "react-router" ||
(f === "nuxt" &&
config.addons?.includes("tauri") &&
!config.addons?.includes("pwa")) ||
(f === "svelte" &&
config.addons?.includes("tauri") &&
!config.addons?.includes("pwa")),
);
if (hasWebSpecificAddons && !hasCompatibleWebFrontend) {
let incompatibleAddon = "";
if (config.addons.includes("pwa") && includesNuxt) {
incompatibleAddon = "PWA addon is not compatible with Nuxt.";
} else if (
config.addons.includes("pwa") ||
config.addons.includes("tauri")
) {
incompatibleAddon =
"PWA and Tauri addons require tanstack-router, react-router, or Nuxt/Svelte (Tauri only).";
}
consola.fatal(
`Turso setup requires SQLite. Cannot use --db-setup turso with --database ${effectiveDatabase}`,
`${incompatibleAddon} Cannot use these addons with your frontend selection.`,
);
process.exit(1);
}
if (effectiveOrm === "prisma") {
consola.fatal(
"Turso setup is not compatible with Prisma. Cannot use --db-setup turso with --orm prisma",
if (config.addons.includes("husky") && !config.addons.includes("biome")) {
consola.warn(
"Husky addon is recommended to be used with Biome for lint-staged configuration.",
);
process.exit(1);
}
config.database = "sqlite";
config.orm = "drizzle";
} else if (dbSetup === "prisma-postgres") {
if (effectiveDatabase && effectiveDatabase !== "postgres") {
consola.fatal(
`Prisma PostgreSQL setup requires PostgreSQL. Cannot use --db-setup prisma-postgres with --database ${effectiveDatabase}.`,
);
process.exit(1);
}
config.addons = [...new Set(config.addons)];
}
const onlyNativeFrontend =
effectiveFrontend &&
effectiveFrontend.length === 1 &&
effectiveFrontend[0] === "native";
if (
onlyNativeFrontend &&
config.examples &&
config.examples.length > 0 &&
!config.examples.includes("none")
) {
consola.fatal(
"Examples are not supported when only the 'native' frontend is selected.",
);
process.exit(1);
}
if (
config.examples &&
config.examples.length > 0 &&
!config.examples.includes("none")
) {
if (
effectiveOrm &&
effectiveOrm !== "prisma" &&
effectiveOrm !== "none"
config.examples.includes("todo") &&
effectiveBackend !== "convex" &&
effectiveDatabase === "none"
) {
consola.fatal(
`Prisma PostgreSQL setup requires Prisma ORM. Cannot use --db-setup prisma-postgres with --orm ${effectiveOrm}.`,
"The 'todo' example requires a database (unless using Convex). Cannot use --examples todo when database is 'none'.",
);
process.exit(1);
}
config.database = "postgres";
config.orm = "prisma";
} else if (dbSetup === "mongodb-atlas") {
if (effectiveDatabase && effectiveDatabase !== "mongodb") {
if (config.examples.includes("ai") && effectiveBackend === "elysia") {
consola.fatal(
`MongoDB Atlas setup requires MongoDB. Cannot use --db-setup mongodb-atlas with --database ${effectiveDatabase}.`,
"The 'ai' example is not compatible with the Elysia backend.",
);
process.exit(1);
}
if (
effectiveOrm &&
effectiveOrm !== "prisma" &&
effectiveOrm !== "none"
) {
consola.fatal(
`MongoDB Atlas setup requires Prisma ORM. Cannot use --db-setup mongodb-atlas with --orm ${effectiveOrm}.`,
);
process.exit(1);
}
config.database = "mongodb";
config.orm = "prisma";
} else if (dbSetup === "neon") {
if (effectiveDatabase && effectiveDatabase !== "postgres") {
consola.fatal(
`Neon PostgreSQL setup requires PostgreSQL. Cannot use --db-setup neon with --database ${effectiveDatabase}.`,
);
process.exit(1);
}
config.database = "postgres";
}
}
const includesNuxt = effectiveFrontend?.includes("nuxt");
const includesSvelte = effectiveFrontend?.includes("svelte");
if ((includesNuxt || includesSvelte) && effectiveApi === "trpc") {
consola.fatal(
`tRPC API is not supported with '${
includesNuxt ? "nuxt" : "svelte"
}' frontend. Please use --api orpc or remove '${
includesNuxt ? "nuxt" : "svelte"
}' from --frontend.`,
);
process.exit(1);
}
if (
(includesNuxt || includesSvelte) &&
effectiveApi !== "orpc" &&
(!options.api || (options.yes && options.api !== "trpc"))
) {
config.api = "orpc";
}
if (config.addons && config.addons.length > 0) {
const webSpecificAddons = ["pwa", "tauri"];
const hasWebSpecificAddons = config.addons.some((addon) =>
webSpecificAddons.includes(addon),
);
const hasCompatibleWebFrontend = effectiveFrontend?.some(
(f) =>
f === "tanstack-router" ||
f === "react-router" ||
(f === "nuxt" &&
config.addons?.includes("tauri") &&
!config.addons?.includes("pwa")) ||
(f === "svelte" &&
config.addons?.includes("tauri") &&
!config.addons?.includes("pwa")),
);
if (hasWebSpecificAddons && !hasCompatibleWebFrontend) {
let incompatibleAddon = "";
if (config.addons.includes("pwa") && includesNuxt) {
incompatibleAddon = "PWA addon is not compatible with Nuxt.";
} else if (
config.addons.includes("pwa") ||
config.addons.includes("tauri")
) {
incompatibleAddon =
"PWA and Tauri addons require tanstack-router, react-router, or Nuxt/Svelte (Tauri only).";
}
consola.fatal(
`${incompatibleAddon} Cannot use these addons with your frontend selection.`,
const hasWebFrontendForExamples = effectiveFrontend?.some((f) =>
[
"tanstack-router",
"react-router",
"tanstack-start",
"next",
"nuxt",
"svelte",
].includes(f),
);
process.exit(1);
}
if (config.addons.includes("husky") && !config.addons.includes("biome")) {
consola.warn(
"Husky addon is recommended to be used with Biome for lint-staged configuration.",
);
}
config.addons = [...new Set(config.addons)];
}
if (config.examples && config.examples.length > 0) {
if (config.examples.includes("ai") && effectiveBackend === "elysia") {
consola.fatal(
"The 'ai' example is not compatible with the Elysia backend.",
);
process.exit(1);
}
const hasWebFrontendForExamples = effectiveFrontend?.some((f) =>
[
"tanstack-router",
"react-router",
"tanstack-start",
"next",
"nuxt",
"svelte",
].includes(f),
);
if (config.examples.length > 0 && !hasWebFrontendForExamples) {
consola.fatal(
"Examples require a web frontend (tanstack-router, react-router, tanstack-start, next, nuxt, or svelte).",
);
process.exit(1);
const noFrontendSelected =
!effectiveFrontend || effectiveFrontend.length === 0;
}
}
@@ -544,7 +637,14 @@ function processAndValidateFlags(
main().catch((err) => {
consola.error("Aborting installation due to unexpected error...");
if (err instanceof Error) {
consola.error(err.message);
if (
!err.message.includes("is only supported with") &&
!err.message.includes("incompatible with")
) {
consola.error(err.message);
consola.error(err.stack);
} else {
}
} else {
console.error(err);
}

View File

@@ -1,12 +1,17 @@
import { cancel, isCancel, select } from "@clack/prompts";
import { cancel, isCancel, log, select } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectApi, ProjectFrontend } from "../types";
import type { ProjectApi, ProjectBackend, ProjectFrontend } from "../types";
export async function getApiChoice(
Api?: ProjectApi | undefined,
frontend?: ProjectFrontend[],
backend?: ProjectBackend,
): Promise<ProjectApi> {
if (backend === "convex") {
return "none";
}
if (Api) return Api;
const includesNuxt = frontend?.includes("nuxt");

View File

@@ -1,11 +1,17 @@
import { cancel, confirm, isCancel } from "@clack/prompts";
import { cancel, confirm, isCancel, log } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectBackend } from "../types";
export async function getAuthChoice(
auth: boolean | undefined,
hasDatabase: boolean,
backend?: ProjectBackend,
): Promise<boolean> {
if (backend === "convex") {
return false;
}
if (!hasDatabase) return false;
if (auth !== undefined) return auth;

View File

@@ -31,6 +31,11 @@ export async function getBackendFrameworkChoice(
label: "Elysia",
hint: "Ergonomic web framework for building backend servers",
},
{
value: "convex",
label: "Convex",
hint: "Reactive backend-as-a-service platform",
},
],
initialValue: DEFAULT_CONFIG.backend,
});

View File

@@ -30,19 +30,19 @@ import { getRuntimeChoice } from "./runtime";
type PromptGroupResults = {
projectName: string;
frontend: ProjectFrontend[];
backend: ProjectBackend;
runtime: ProjectRuntime;
database: ProjectDatabase;
orm: ProjectOrm;
api: ProjectApi;
auth: boolean;
addons: ProjectAddons[];
examples: ProjectExamples[];
dbSetup: ProjectDBSetup;
git: boolean;
packageManager: ProjectPackageManager;
install: boolean;
dbSetup: ProjectDBSetup;
backend: ProjectBackend;
runtime: ProjectRuntime;
frontend: ProjectFrontend[];
api: ProjectApi;
};
export async function gatherConfig(
@@ -57,12 +57,19 @@ export async function gatherConfig(
backend: () => getBackendFrameworkChoice(flags.backend),
runtime: ({ results }) =>
getRuntimeChoice(flags.runtime, results.backend),
database: () => getDatabaseChoice(flags.database),
database: ({ results }) =>
getDatabaseChoice(flags.database, results.backend),
orm: ({ results }) =>
getORMChoice(flags.orm, results.database !== "none", results.database),
api: ({ results }) => getApiChoice(flags.api, results.frontend),
getORMChoice(
flags.orm,
results.database !== "none",
results.database,
results.backend,
),
api: ({ results }) =>
getApiChoice(flags.api, results.frontend, results.backend),
auth: ({ results }) =>
getAuthChoice(flags.auth, results.database !== "none"),
getAuthChoice(flags.auth, results.database !== "none", results.backend),
addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend),
examples: ({ results }) =>
getExamplesChoice(
@@ -76,6 +83,7 @@ export async function gatherConfig(
results.database ?? "none",
flags.dbSetup,
results.orm,
results.backend,
),
git: () => getGitChoice(flags.git),
packageManager: () => getPackageManagerChoice(flags.packageManager),
@@ -89,9 +97,20 @@ export async function gatherConfig(
},
);
if (result.backend === "convex") {
result.runtime = "none";
result.database = "none";
result.orm = "none";
result.api = "none";
result.auth = false;
result.dbSetup = "none";
}
return {
projectName: result.projectName,
frontend: result.frontend,
backend: result.backend,
runtime: result.runtime,
database: result.database,
orm: result.orm,
auth: result.auth,
@@ -101,8 +120,6 @@ export async function gatherConfig(
packageManager: result.packageManager,
install: result.install,
dbSetup: result.dbSetup,
backend: result.backend,
runtime: result.runtime,
api: result.api,
};
}

View File

@@ -1,11 +1,16 @@
import { cancel, isCancel, select } from "@clack/prompts";
import { cancel, isCancel, log, select } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectDatabase } from "../types";
import type { ProjectBackend, ProjectDatabase } from "../types";
export async function getDatabaseChoice(
database?: ProjectDatabase,
backend?: ProjectBackend,
): Promise<ProjectDatabase> {
if (backend === "convex") {
return "none";
}
if (database !== undefined) return database;
const response = await select<ProjectDatabase>({

View File

@@ -1,14 +1,23 @@
import { cancel, isCancel, select } from "@clack/prompts";
import { cancel, isCancel, log, select } from "@clack/prompts";
import pc from "picocolors";
import type { ProjectDBSetup, ProjectOrm } from "../types";
import type { ProjectBackend, ProjectDBSetup, ProjectOrm } from "../types";
export async function getDBSetupChoice(
databaseType: string,
dbSetup: ProjectDBSetup | undefined,
orm?: ProjectOrm,
backend?: ProjectBackend,
): Promise<ProjectDBSetup> {
if (backend === "convex") {
return "none";
}
if (dbSetup !== undefined) return dbSetup as ProjectDBSetup;
if (databaseType === "none") {
return "none";
}
if (databaseType === "sqlite" && orm === "prisma") {
return "none";
}

View File

@@ -1,4 +1,4 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import { cancel, isCancel, log, multiselect } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type {
@@ -16,17 +16,32 @@ export async function getExamplesChoice(
): Promise<ProjectExamples[]> {
if (examples !== undefined) return examples;
if (backend === "convex") {
return ["todo"];
}
if (database === "none") return [];
const hasWebFrontend =
frontends?.includes("react-router") ||
frontends?.includes("tanstack-router") ||
frontends?.includes("tanstack-start") ||
frontends?.includes("next") ||
frontends?.includes("nuxt") ||
frontends?.includes("svelte");
const onlyNative =
frontends && frontends.length === 1 && frontends[0] === "native";
if (onlyNative) {
return [];
}
if (!hasWebFrontend) return [];
const hasWebFrontend =
frontends?.some((f) =>
[
"react-router",
"tanstack-router",
"tanstack-start",
"next",
"nuxt",
"svelte",
].includes(f),
) ?? false;
const noFrontendSelected = !frontends || frontends.length === 0;
if (!hasWebFrontend && !noFrontendSelected) return [];
let response: ProjectExamples[] | symbol = [];
const options: { value: ProjectExamples; label: string; hint: string }[] = [

View File

@@ -1,13 +1,18 @@
import { cancel, isCancel, log, select } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectDatabase, ProjectOrm } from "../types";
import type { ProjectBackend, ProjectDatabase, ProjectOrm } from "../types";
export async function getORMChoice(
orm: ProjectOrm | undefined,
hasDatabase: boolean,
database?: ProjectDatabase,
backend?: ProjectBackend,
): Promise<ProjectOrm> {
if (backend === "convex") {
return "none";
}
if (!hasDatabase) return "none";
if (orm !== undefined) return orm;

View File

@@ -1,4 +1,4 @@
import { cancel, isCancel, select } from "@clack/prompts";
import { cancel, isCancel, log, select } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectBackend, ProjectRuntime } from "../types";
@@ -7,6 +7,10 @@ export async function getRuntimeChoice(
runtime?: ProjectRuntime,
backend?: ProjectBackend,
): Promise<ProjectRuntime> {
if (backend === "convex") {
return "none";
}
if (runtime !== undefined) return runtime;
if (backend === "next") {

View File

@@ -14,8 +14,8 @@ export type ProjectAddons =
| "starlight"
| "turborepo"
| "none";
export type ProjectBackend = "hono" | "elysia" | "express" | "next";
export type ProjectRuntime = "node" | "bun";
export type ProjectBackend = "hono" | "elysia" | "express" | "next" | "convex";
export type ProjectRuntime = "node" | "bun" | "none";
export type ProjectExamples = "todo" | "ai" | "none";
export type ProjectFrontend =
| "react-router"
@@ -32,7 +32,7 @@ export type ProjectDBSetup =
| "mongodb-atlas"
| "neon"
| "none";
export type ProjectApi = "trpc" | "orpc";
export type ProjectApi = "trpc" | "orpc" | "none";
export interface ProjectConfig {
projectName: string;

View File

@@ -13,7 +13,7 @@ export function displayConfig(config: Partial<ProjectConfig>) {
? config.frontend
: [config.frontend];
const frontendText =
frontend.length > 0 && frontend[0] !== undefined && frontend[0] !== ""
frontend.length > 0 && frontend[0] !== undefined
? frontend.join(", ")
: "none";
configDisplay.push(`${pc.blue("Frontend:")} ${frontendText}`);

View File

@@ -16,7 +16,12 @@
"dev": {
"cache": false,
"persistent": true
},
}{{#if (eq backend "convex")}},
"setup": {
"cache": false,
"persistent": true
}
{{else}}{{#unless (or (eq database "none") (eq orm "none"))}},
"db:push": {
"cache": false,
"persistent": true
@@ -25,5 +30,6 @@
"cache": false,
"persistent": true
}
{{/unless}}{{/if}}
}
}

View File

@@ -92,8 +92,6 @@ export async function createContext(opts: any) {
}
{{else}}
// Default or fallback context if backend is not recognized or none
// This might need adjustment based on your default behavior
export async function createContext() {
return {
session: null,

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
import { z } from 'zod'
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp()
import type { FormSubmitEvent } from '#ui/types'

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { z } from 'zod'
import type { FormSubmitEvent } from '#ui/types'
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp()
const emit = defineEmits(['switchToSignIn'])

View File

@@ -1,6 +1,5 @@
<script setup lang="ts">
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp()
const session = $authClient.useSession()
const toast = useToast()

View File

@@ -0,0 +1,2 @@
.env.local

View File

@@ -0,0 +1,90 @@
# Welcome to your Convex functions directory!
Write your Convex functions here.
See https://docs.convex.dev/functions for more.
A query function that takes two arguments looks like:
```ts
// functions.js
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.functions.myQueryFunction, {
first: 10,
second: "hello",
});
```
A mutation function looks like:
```ts
// functions.js
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation.
return await ctx.db.get(id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.functions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) =>
console.log(result),
);
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.

View File

@@ -0,0 +1,7 @@
import { query } from "./_generated/server";
export const get = query({
handler: async () => {
return "OK";
}
})

View File

@@ -0,0 +1,9 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
todos: defineTable({
text: v.string(),
completed: v.boolean(),
}),
});

View File

@@ -0,0 +1,42 @@
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const getAll = query({
handler: async (ctx) => {
return await ctx.db.query("todos").collect();
},
});
export const create = mutation({
args: {
text: v.string(),
},
handler: async (ctx, args) => {
const newTodoId = await ctx.db.insert("todos", {
text: args.text,
completed: false,
});
return await ctx.db.get(newTodoId);
},
});
export const toggle = mutation({
args: {
id: v.id("todos"),
completed: v.boolean(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { completed: args.completed });
return { success: true };
},
});
export const deleteTodo = mutation({
args: {
id: v.id("todos"),
},
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
return { success: true };
},
});

View File

@@ -0,0 +1,25 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

View File

@@ -0,0 +1,21 @@
{
"name": "@{{projectName}}/backend",
"version": "1.0.0",
"private": true,
"exports": {
"./convex/*": "./convex/*"
},
"scripts": {
"dev": "convex dev",
"setup": "convex dev --until-success"
},
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"typescript": "^5.8.3"
},
"dependencies": {
"convex": "^1.23.0"
}
}

View File

@@ -2,7 +2,8 @@
"name": "better-t-stack",
"private": true,
"workspaces": [
"apps/*"
"apps/*",
"packages/*"
],
"scripts": {

View File

@@ -8,61 +8,90 @@ import {
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
import { useMutation, useQuery } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
{{#if (eq backend "convex")}}
import { useMutation, useQuery } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
import type { Id } from "@{{projectName}}/backend/convex/_generated/dataModel.d.ts";
{{else}}
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
import { useMutation, useQuery } from "@tanstack/react-query";
{{/if}}
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
{{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation(
orpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
})
);
const toggleMutation = useMutation(
orpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
const deleteMutation = useMutation(
orpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
{{/if}}
{{#if (eq api "trpc")}}
const todos = useQuery(trpc.todo.getAll.queryOptions());
const createMutation = useMutation(
trpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
})
);
const toggleMutation = useMutation(
trpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
const deleteMutation = useMutation(
trpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
{{/if}}
{{#if (eq backend "convex")}}
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault();
const text = newTodoText.trim();
if (!text) return;
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodo({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
deleteTodo({ id });
};
{{else}}
{{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation(
orpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
})
);
const toggleMutation = useMutation(
orpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
const deleteMutation = useMutation(
orpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
{{/if}}
{{#if (eq api "trpc")}}
const todos = useQuery(trpc.todo.getAll.queryOptions());
const createMutation = useMutation(
trpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
})
);
const toggleMutation = useMutation(
trpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
const deleteMutation = useMutation(
trpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
})
);
{{/if}}
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
@@ -78,6 +107,7 @@ export default function Todos() {
const handleDeleteTodo = (id: number) => {
deleteMutation.mutate({ id });
};
{{/if}}
return (
<div className="w-full mx-auto max-w-md py-10">
@@ -95,62 +125,117 @@ export default function Todos() {
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
{{#if (eq backend "convex")}}
{{!-- Convex mutations don't have an easy isPending state here, disable based on text --}}
{{else}}
disabled={createMutation.isPending}
{{/if}}
/>
<Button
type="submit"
{{#if (eq backend "convex")}}
disabled={!newTodoText.trim()}
{{else}}
disabled={createMutation.isPending || !newTodoText.trim()}
{{/if}}
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Add"
)}
{{#if (eq backend "convex")}}
Add
{{else}}
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Add"
)}
{{/if}}
</Button>
</form>
{todos.isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.data?.length === 0 ? (
<p className="py-4 text-center">
No todos yet. Add one above!
</p>
) : (
<ul className="space-y-2">
{todos.data?.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo.id, todo.completed)
}
id={`todo-${todo.id}`}
/>
<label
htmlFor={`todo-${todo.id}`}
className={`${todo.completed ? "line-through" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo.id)}
aria-label="Delete todo"
{{#if (eq backend "convex")}}
{todos === undefined ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${todo.completed ? "line-through text-muted-foreground" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
{{else}}
{todos.isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.data?.length === 0 ? (
<p className="py-4 text-center">
No todos yet. Add one above!
</p>
) : (
<ul className="space-y-2">
{todos.data?.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo.id, todo.completed)
}
id={`todo-${todo.id}`}
/>
<label
htmlFor={`todo-${todo.id}`}
className={`${todo.completed ? "line-through" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo.id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
{{/if}}
</CardContent>
</Card>
</div>

View File

@@ -8,17 +8,24 @@ import {
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
{{#if (eq backend "convex")}}
import { useMutation, useQuery } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
import type { Id } from "@{{projectName}}/backend/convex/_generated/dataModel.d.ts";
{{else}}
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
import { useMutation, useQuery } from "@tanstack/react-query";
{{/if}}
export const Route = createFileRoute("/todos")({
component: TodosRoute,
});
@@ -26,48 +33,70 @@ export const Route = createFileRoute("/todos")({
function TodosRoute() {
const [newTodoText, setNewTodoText] = useState("");
{{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation(
orpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
}),
);
const toggleMutation = useMutation(
orpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
const deleteMutation = useMutation(
orpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
{{/if}}
{{#if (eq api "trpc")}}
const todos = useQuery(trpc.todo.getAll.queryOptions());
const createMutation = useMutation(
trpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
}),
);
const toggleMutation = useMutation(
trpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
const deleteMutation = useMutation(
trpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
{{/if}}
{{#if (eq backend "convex")}}
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault();
const text = newTodoText.trim();
if (!text) return;
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodo({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
deleteTodo({ id });
};
{{else}}
{{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation(
orpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
}),
);
const toggleMutation = useMutation(
orpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
const deleteMutation = useMutation(
orpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
{{/if}}
{{#if (eq api "trpc")}}
const todos = useQuery(trpc.todo.getAll.queryOptions());
const createMutation = useMutation(
trpc.todo.create.mutationOptions({
onSuccess: () => {
todos.refetch();
setNewTodoText("");
},
}),
);
const toggleMutation = useMutation(
trpc.todo.toggle.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
const deleteMutation = useMutation(
trpc.todo.delete.mutationOptions({
onSuccess: () => todos.refetch(),
}),
);
{{/if}}
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
@@ -83,6 +112,7 @@ function TodosRoute() {
const handleDeleteTodo = (id: number) => {
deleteMutation.mutate({ id });
};
{{/if}}
return (
<div className="mx-auto w-full max-w-md py-10">
@@ -100,60 +130,116 @@ function TodosRoute() {
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
{{#if (eq backend "convex")}}
{{else}}
disabled={createMutation.isPending}
{{/if}}
/>
<Button
type="submit"
{{#if (eq backend "convex")}}
disabled={!newTodoText.trim()}
{{else}}
disabled={createMutation.isPending || !newTodoText.trim()}
{{/if}}
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Add"
)}
{{#if (eq backend "convex")}}
Add
{{else}}
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Add"
)}
{{/if}}
</Button>
</form>
{todos.isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.data?.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.data?.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo.id, todo.completed)
}
id={`todo-${todo.id}`}
/>
<label
htmlFor={`todo-${todo.id}`}
className={`${todo.completed ? "line-through" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo.id)}
aria-label="Delete todo"
{{#if (eq backend "convex")}}
{todos === undefined ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${todo.completed ? "line-through text-muted-foreground" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
{{else}}
{todos.isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.data?.length === 0 ? (
<p className="py-4 text-center">
No todos yet. Add one above!
</p>
) : (
<ul className="space-y-2">
{todos.data?.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo.id, todo.completed)
}
id={`todo-${todo.id}`}
/>
<label
htmlFor={`todo-${todo.id}`}
className={`${todo.completed ? "line-through" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo.id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
{{/if}}
</CardContent>
</Card>
</div>

View File

@@ -8,32 +8,79 @@ import {
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
{{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { useORPC } from "@/utils/orpc";
{{/if}}
import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
{{#if (eq backend "convex")}}
import { useSuspenseQuery } from "@tanstack/react-query";
import { convexQuery } from "@convex-dev/react-query";
import { useMutation } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
import type { Id } from "@{{projectName}}/backend/convex/_generated/dataModel.js";
{{else}}
{{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { useORPC } from "@/utils/orpc";
{{/if}}
import { useMutation, useQuery } from "@tanstack/react-query";
{{/if}}
export const Route = createFileRoute("/todos")({
component: TodosRoute,
});
function TodosRoute() {
{{#if (eq api "trpc")}}
const trpc = useTRPC();
{{/if}}
{{#if (eq api "orpc")}}
const orpc = useORPC();
{{/if}}
const [newTodoText, setNewTodoText] = useState("");
{{#if (eq api "trpc")}}
{{#if (eq backend "convex")}}
const todosQuery = useSuspenseQuery(convexQuery(api.todos.getAll, {}));
const todos = todosQuery.data;
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const removeTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: React.FormEvent) => {
e.preventDefault();
const text = newTodoText.trim();
if (text) {
setNewTodoText("");
try {
await createTodo({ text });
} catch (error) {
console.error("Failed to add todo:", error);
setNewTodoText(text);
}
}
};
const handleToggleTodo = async (id: Id<"todos">, completed: boolean) => {
try {
await toggleTodo({ id, completed: !completed });
} catch (error) {
console.error("Failed to toggle todo:", error);
}
};
const handleDeleteTodo = async (id: Id<"todos">) => {
try {
await removeTodo({ id });
} catch (error) {
console.error("Failed to delete todo:", error);
}
};
{{else}}
{{#if (eq api "trpc")}}
const trpc = useTRPC();
{{/if}}
{{#if (eq api "orpc")}}
const orpc = useORPC();
{{/if}}
{{#if (eq api "trpc")}}
const todos = useQuery(trpc.todo.getAll.queryOptions());
const createMutation = useMutation(
trpc.todo.create.mutationOptions({
@@ -53,8 +100,8 @@ function TodosRoute() {
onSuccess: () => todos.refetch(),
}),
);
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
{{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation(
orpc.todo.create.mutationOptions({
@@ -74,7 +121,7 @@ function TodosRoute() {
onSuccess: () => todos.refetch(),
}),
);
{{/if}}
{{/if}}
const handleAddTodo = (e: React.FormEvent) => {
e.preventDefault();
@@ -90,12 +137,13 @@ function TodosRoute() {
const handleDeleteTodo = (id: number) => {
deleteMutation.mutate({ id });
};
{{/if}}
return (
<div className="mx-auto w-full max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardTitle>Todo List{{#if (eq backend "convex")}} (Convex){{/if}}</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
@@ -107,20 +155,72 @@ function TodosRoute() {
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
{{#unless (eq backend "convex")}}
disabled={createMutation.isPending}
{{/unless}}
/>
<Button
type="submit"
{{#unless (eq backend "convex")}}
disabled={createMutation.isPending || !newTodoText.trim()}
{{else}}
disabled={!newTodoText.trim()}
{{/unless}}
>
{{#unless (eq backend "convex")}}
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Add"
)}
{{else}}
Add
{{/unless}}
</Button>
</form>
{{#if (eq backend "convex")}}
{todos?.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos?.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() =>
handleToggleTodo(todo._id, todo.completed)
}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${
todo.completed
? "text-muted-foreground line-through"
: ""
}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
{{else}}
{todos.isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
@@ -161,6 +261,7 @@ function TodosRoute() {
))}
</ul>
)}
{{/if}}
</CardContent>
</Card>
</div>

View File

@@ -1,2 +1,3 @@
packages:
- "apps/*"
- "packages/*"

View File

@@ -1,12 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { View, Text, ScrollView } from "react-native";
import { Container } from "@/components/container";
{{#if (eq api "orpc")}}
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc";
{{/if}}
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{ projectName }}/backend/convex/_generated/api.js";
{{/if}}
export default function Home() {
{{#if (eq api "orpc")}}
@@ -15,6 +20,9 @@ export default function Home() {
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{/if}}
return (
<Container>
@@ -28,15 +36,35 @@ export default function Home() {
<View className="flex-row items-center gap-2">
<View
className={`h-2.5 w-2.5 rounded-full ${
healthCheck.data ? "bg-green-500" : "bg-red-500"
{{#if (or (eq api "orpc") (eq api "trpc"))}}
healthCheck.data ? "bg-green-500" : "bg-red-500"
{{else}}
healthCheck ? "bg-green-500" : "bg-red-500"
{{/if}}
}`}
/>
<Text className="text-sm text-foreground">
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected"
: "Disconnected"}
{{#if (eq api "orpc")}}
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected"
: "Disconnected"}
{{/if}}
{{#if (eq api "trpc")}}
{healthCheck.isLoading
? "Checking..."
: healthCheck.data
? "Connected"
: "Disconnected"}
{{/if}}
{{#if (eq backend "convex")}}
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
{{/if}}
</Text>
</View>
</View>

View File

@@ -1,4 +1,8 @@
{{#if (eq backend "convex")}}
import { ConvexProvider, ConvexReactClient } from "convex/react";
{{else}}
import { QueryClientProvider } from "@tanstack/react-query";
{{/if}}
import { Stack } from "expo-router";
import {
DarkTheme,
@@ -35,6 +39,12 @@ export const unstable_settings = {
initialRouteName: "(drawer)",
};
{{#if (eq backend "convex")}}
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
{{/if}}
export default function RootLayout() {
const hasMounted = useRef(false);
const { colorScheme, isDarkColorScheme } = useColorScheme();
@@ -58,6 +68,22 @@ export default function RootLayout() {
return null;
}
return (
{{#if (eq backend "convex")}}
<ConvexProvider client={convex}>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<GestureHandlerRootView style=\{{ flex: 1 }}>
<Stack>
<Stack.Screen name="(drawer)" options=\{{ headerShown: false }} />
<Stack.Screen
name="modal"
options=\{{ title: "Modal", presentation: "modal" }}
/>
</Stack>
</GestureHandlerRootView>
</ThemeProvider>
</ConvexProvider>
{{else}}
<QueryClientProvider client={queryClient}>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
@@ -72,6 +98,7 @@ export default function RootLayout() {
</GestureHandlerRootView>
</ThemeProvider>
</QueryClientProvider>
{{/if}}
);
}

View File

@@ -14,7 +14,6 @@
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-slot": "^1.2.0",
"@tanstack/react-form": "^1.3.2",
"@tanstack/react-query": "^5.72.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.487.0",
@@ -29,7 +28,6 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.72.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",

View File

@@ -1,11 +1,16 @@
"use client"
{{#if (eq api "orpc")}}
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
{{else}}
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
{{/if}}
import { useQuery } from "@tanstack/react-query";
{{/if}}
const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗
@@ -24,10 +29,11 @@ const TITLE_TEXT = `
`;
export default function Home() {
{{#if (eq api "orpc")}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{else if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
{{else if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
@@ -38,6 +44,18 @@ export default function Home() {
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
{{#if (eq backend "convex")}}
<div
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-green-500" : healthCheck === undefined ? "bg-orange-400" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
</span>
{{else}}
<div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/>
@@ -48,46 +66,10 @@ export default function Home() {
? "Connected"
: "Disconnected"}
</span>
{{/if}}
</div>
</section>
<section>
<h2 className="mb-3 font-medium">Core Features</h2>
<ul className="grid grid-cols-2 gap-3">
<FeatureItem
title="Type-Safe API"
description="End-to-end type safety with tRPC"
/>
<FeatureItem
title="Modern React"
description="TanStack Router + TanStack Query"
/>
<FeatureItem
title="Fast Backend"
description="Lightweight Hono server"
/>
<FeatureItem
title="Beautiful UI"
description="TailwindCSS + shadcn/ui components"
/>
</ul>
</section>
</div>
</div>
);
}
function FeatureItem({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<li className="border-l-2 border-primary py-1 pl-3">
<h3 className="font-medium">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</li>
);
}

View File

@@ -1,14 +1,22 @@
"use client"
{{#if (eq backend "convex")}}
import { ConvexProvider, ConvexReactClient } from "convex/react";
{{else}}
import { QueryClientProvider } from "@tanstack/react-query";
{{#if (eq api "orpc")}}
{{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
{{/if}}
{{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc";
{{/if}}
{{/if}}
import { ThemeProvider } from "./theme-provider";
import { Toaster } from "./ui/sonner";
{{#if (eq backend "convex")}}
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
{{/if}}
export default function Providers({
children,
}: {
@@ -21,6 +29,9 @@ export default function Providers({
enableSystem
disableTransitionOnChange
>
{{#if (eq backend "convex")}}
<ConvexProvider client={convex}>{children}</ConvexProvider>
{{else}}
<QueryClientProvider client={queryClient}>
{{#if (eq api "orpc")}}
<ORPCContext.Provider value={orpc}>
@@ -31,6 +42,7 @@ export default function Providers({
{children}
{{/if}}
</QueryClientProvider>
{{/if}}
<Toaster richColors />
</ThemeProvider>
)

View File

@@ -17,7 +17,6 @@
"@react-router/node": "^7.4.1",
"@react-router/serve": "^7.4.1",
"@tanstack/react-form": "^1.2.3",
"@tanstack/react-query": "^5.71.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"isbot": "^5.1.17",
@@ -34,7 +33,6 @@
"devDependencies": {
"@react-router/dev": "^7.4.1",
"@tailwindcss/vite": "^4.0.0",
"@tanstack/react-query-devtools": "^5.71.3",
"@types/node": "^20",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.1",

View File

@@ -1,5 +1,3 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import {
isRouteErrorResponse,
Links,
@@ -14,11 +12,17 @@ import Header from "./components/header";
import { ThemeProvider } from "./components/theme-provider";
import { Toaster } from "./components/ui/sonner";
{{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "./utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { queryClient } from "./utils/trpc";
{{#if (eq backend "convex")}}
import { ConvexProvider, ConvexReactClient } from "convex/react";
{{else}}
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
{{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "./utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { queryClient } from "./utils/trpc";
{{/if}}
{{/if}}
export const links: Route.LinksFunction = () => [
@@ -52,7 +56,25 @@ export function Layout({ children }: { children: React.ReactNode }) {
);
}
{{#if (eq api "orpc")}}
{{#if (eq backend "convex")}}
export default function App() {
const convex = new ConvexReactClient(
import.meta.env.VITE_CONVEX_URL as string,
);
return (
<ConvexProvider client={convex}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<div className="grid grid-rows-[auto_1fr] h-svh">
<Header />
<Outlet />
</div>
<Toaster richColors />
</ThemeProvider>
</ConvexProvider>
);
}
{{else if (eq api "orpc")}}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
@@ -69,9 +91,7 @@ export default function App() {
</QueryClientProvider>
);
}
{{/if}}
{{#if (eq api "trpc")}}
{{else if (eq api "trpc")}}
export default function App() {
return (
<QueryClientProvider client={queryClient}>

View File

@@ -1,11 +1,16 @@
import type { Route } from "./+types/_index";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
{{else}}
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
import { useQuery } from "@tanstack/react-query";
{{/if}}
const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗
@@ -28,11 +33,11 @@ export function meta({}: Route.MetaArgs) {
}
export default function Home() {
{{#if (eq api "orpc")}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{else if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq api "trpc")}}
{{else if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
@@ -43,6 +48,18 @@ export default function Home() {
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
{{#if (eq backend "convex")}}
<div
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-green-500" : healthCheck === undefined ? "bg-orange-400" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
</span>
{{else}}
<div
className={`h-2 w-2 rounded-full ${
healthCheck.data ? "bg-green-500" : "bg-red-500"
@@ -55,46 +72,10 @@ export default function Home() {
? "Connected"
: "Disconnected"}
</span>
{{/if}}
</div>
</section>
<section>
<h2 className="mb-3 font-medium">Core Features</h2>
<ul className="grid grid-cols-2 gap-3">
<FeatureItem
title="Type-Safe API"
description="End-to-end type safety with tRPC"
/>
<FeatureItem
title="Modern React"
description="TanStack Router + TanStack Query"
/>
<FeatureItem
title="Fast Backend"
description="Lightweight Hono server"
/>
<FeatureItem
title="Beautiful UI"
description="TailwindCSS + shadcn/ui components"
/>
</ul>
</section>
</div>
</div>
);
}
function FeatureItem({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<li className="border-l-2 border-primary py-1 pl-3">
<h3 className="font-medium">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</li>
);
}

View File

@@ -11,7 +11,6 @@
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@tanstack/react-query-devtools": "^5.69.0",
"@tanstack/react-router-devtools": "^1.114.27",
"@tanstack/router-plugin": "^1.114.27",
"@types/node": "^22.13.13",
@@ -30,7 +29,6 @@
"@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-form": "^1.0.5",
"@tailwindcss/vite": "^4.0.15",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-router": "^1.114.25",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -1,14 +1,20 @@
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createRouter } from "@tanstack/react-router";
import ReactDOM from "react-dom/client";
import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen";
{{#if (eq api "orpc")}}
import { QueryClientProvider } from "@tanstack/react-query";
import { orpc, queryClient } from "./utils/orpc";
{{/if}}
{{#if (eq api "trpc")}}
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient, trpc } from "./utils/trpc";
{{/if}}
{{#if (eq backend "convex")}}
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
{{/if}}
{{#if (eq api "orpc")}}
const router = createRouter({
@@ -36,8 +42,18 @@ const router = createRouter({
},
});
{{/if}}
{{#if (eq backend "convex")}}
const router = createRouter({
routeTree,
defaultPreload: "intent",
defaultPendingComponent: () => <Loader />,
context: {},
Wrap: function WrapComponent({ children }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
},
});
{{/if}}
// Register things for typesafety
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;

View File

@@ -38,6 +38,9 @@ export interface RouterAppContext {
queryClient: QueryClient;
}
{{/if}}
{{#if (eq backend "convex")}}
export interface RouterAppContext {}
{{/if}}
export const Route = createRootRouteWithContext<RouterAppContext>()({
component: RootComponent,
@@ -106,4 +109,24 @@ function RootComponent() {
</>
);
}
{{/if}}
{{/if}}
{{#if (eq backend "convex")}}
function RootComponent() {
const isFetching = useRouterState({
select: (s) => s.isLoading,
});
return (
<>
<HeadContent />
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
<div className="grid grid-rows-[auto_1fr] h-svh">
<Header />
{isFetching ? <Loader /> : <Outlet />}
</div>
<Toaster richColors />
</ThemeProvider>
<TanStackRouterDevtools position="bottom-left" />
</>
);
}
{{/if}}

View File

@@ -1,11 +1,16 @@
import { createFileRoute } from "@tanstack/react-router";
{{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc";
import { useQuery } from "@tanstack/react-query";
{{/if}}
{{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc";
{{/if}}
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
{{/if}}
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{ projectName }}/backend/convex/_generated/api.js";
{{/if}}
export const Route = createFileRoute("/")({
component: HomeComponent,
@@ -34,6 +39,9 @@ function HomeComponent() {
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{/if}}
return (
<div className="container mx-auto max-w-3xl px-4 py-2">
@@ -42,6 +50,7 @@ function HomeComponent() {
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
{{#if (or (eq api "orpc") (eq api "trpc"))}}
<div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/>
@@ -52,46 +61,22 @@ function HomeComponent() {
? "Connected"
: "Disconnected"}
</span>
{{/if}}
{{#if (eq backend "convex")}}
<div
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-green-500" : healthCheck === undefined ? "bg-orange-400" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
</span>
{{/if}}
</div>
</section>
<section>
<h2 className="mb-3 font-medium">Core Features</h2>
<ul className="grid grid-cols-2 gap-3">
<FeatureItem
title="Type-Safe API"
description="End-to-end type safety with tRPC"
/>
<FeatureItem
title="Modern React"
description="TanStack Router + TanStack Query"
/>
<FeatureItem
title="Fast Backend"
description="Lightweight Hono server"
/>
<FeatureItem
title="Beautiful UI"
description="TailwindCSS + shadcn/ui components"
/>
</ul>
</section>
</div>
</div>
);
}
function FeatureItem({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<li className="border-l-2 border-primary py-1 pl-3">
<h3 className="font-medium">{title}</h3>
<p className="text-sm text-muted-foreground">{description}</p>
</li>
);
}

View File

@@ -1,3 +1,13 @@
{{#if (eq backend "convex")}}
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { QueryClient } from "@tanstack/react-query";
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
import { ConvexQueryClient } from "@convex-dev/react-query";
import { ConvexProvider } from "convex/react";
import { routeTree } from "./routeTree.gen";
import Loader from "./components/loader";
import "./index.css";
{{else}}
import {
QueryCache,
QueryClient,
@@ -7,18 +17,56 @@ import { createRouter as createTanstackRouter } from "@tanstack/react-router";
import Loader from "./components/loader";
import "./index.css";
import { routeTree } from "./routeTree.gen";
{{#if (eq api "trpc")}}
{{#if (eq api "trpc")}}
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import { toast } from "sonner";
import type { AppRouter } from "../../server/src/routers";
import { TRPCProvider } from "./utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
{{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "./utils/orpc";
{{/if}}
{{/if}}
{{#if (eq api "trpc")}}
{{#if (eq backend "convex")}}
export function createRouter() {
const CONVEX_URL = (import.meta as any).env.VITE_CONVEX_URL!;
if (!CONVEX_URL) {
console.error("missing envar VITE_CONVEX_URL");
}
const convexQueryClient = new ConvexQueryClient(CONVEX_URL);
const queryClient: QueryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: convexQueryClient.hashFn(),
queryFn: convexQueryClient.queryFn(),
},
},
});
convexQueryClient.connect(queryClient);
const router = routerWithQueryClient(
createTanStackRouter({
routeTree,
defaultPreload: "intent",
defaultPendingComponent: () => <Loader />,
defaultNotFoundComponent: () => <div>Not Found</div>,
context: { queryClient },
Wrap: ({ children }) => (
<ConvexProvider client={convexQueryClient.convexClient}>
{children}
</ConvexProvider>
),
}),
queryClient,
);
return router;
}
{{else}}
{{#if (eq api "trpc")}}
export const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
@@ -38,11 +86,7 @@ export const queryClient = new QueryClient({
const trpcClient = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
{{#if (includes frontend 'next')}}
url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trpc`,
{{else}}
url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
{{/if}}
{{#if auth}}
fetch(url, options) {
return fetch(url, {
@@ -59,8 +103,7 @@ const trpc = createTRPCOptionsProxy({
client: trpcClient,
queryClient: queryClient,
});
{{/if}}
{{/if}}
export const createRouter = () => {
const router = createTanstackRouter({
@@ -69,10 +112,10 @@ export const createRouter = () => {
defaultPreloadStaleTime: 0,
{{#if (eq api "trpc")}}
context: { trpc, queryClient },
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
{{#if (eq api "orpc")}}
context: { orpc, queryClient },
{{/if}}
{{/if}}
defaultPendingComponent: () => <Loader />,
defaultNotFoundComponent: () => <div>Not Found</div>,
Wrap: ({ children }) => (
@@ -93,6 +136,7 @@ export const createRouter = () => {
return router;
};
{{/if}}
// Register the router instance for type safety
declare module "@tanstack/react-router" {

View File

@@ -11,26 +11,28 @@ import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import Header from "../components/header";
import appCss from "../index.css?url";
import type { QueryClient } from "@tanstack/react-query";
{{#if (eq api "trpc")}}
import type { TRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../../server/src/routers";
{{/if}}
{{#if (eq api "orpc")}}
import type { orpc } from "@/utils/orpc";
{{/if}}
import Loader from "@/components/loader";
{{#if (eq api "trpc")}}
{{#if (eq backend "convex")}}
export interface RouterAppContext {
queryClient: QueryClient;
}
{{else}}
{{#if (eq api "trpc")}}
import type { TRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "../../../server/src/routers";
export interface RouterAppContext {
trpc: TRPCOptionsProxy<AppRouter>;
queryClient: QueryClient;
}
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
{{#if (eq api "orpc")}}
import type { orpc } from "@/utils/orpc";
export interface RouterAppContext {
orpc: typeof orpc;
queryClient: QueryClient;
}
{{/if}}
{{/if}}
export const Route = createRootRouteWithContext<RouterAppContext>()({

View File

@@ -1,11 +1,17 @@
{{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { useORPC } from "@/utils/orpc";
{{/if}}
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
{{#if (eq backend "convex")}}
import { convexQuery } from "@convex-dev/react-query";
import { useSuspenseQuery } from "@tanstack/react-query";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
{{else}}
{{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc";
{{/if}}
{{#if (eq api "orpc")}}
import { useORPC } from "@/utils/orpc";
{{/if}}
import { useQuery } from "@tanstack/react-query";
{{/if}}
export const Route = createFileRoute("/")({
component: HomeComponent,
@@ -28,13 +34,17 @@ const TITLE_TEXT = `
`;
function HomeComponent() {
{{#if (eq api "trpc")}}
{{#if (eq backend "convex")}}
const healthCheck = useSuspenseQuery(convexQuery(api.healthCheck.get, {}));
{{else}}
{{#if (eq api "trpc")}}
const trpc = useTRPC();
const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}}
{{#if (eq api "orpc")}}
{{/if}}
{{#if (eq api "orpc")}}
const orpc = useORPC();
const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}}
{{/if}}
return (
@@ -44,6 +54,18 @@ function HomeComponent() {
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
{{#if (eq backend "convex")}}
<div
className={`h-2 w-2 rounded-full ${healthCheck.data === "OK" ? "bg-green-500" : healthCheck.isLoading ? "bg-orange-400" : "bg-red-500"}`}
/>
<span className="text-muted-foreground text-sm">
{healthCheck.isLoading
? "Checking..."
: healthCheck.data === "OK"
? "Connected"
: "Error"}
</span>
{{else}}
<div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/>
@@ -54,46 +76,10 @@ function HomeComponent() {
? "Connected"
: "Disconnected"}
</span>
{{/if}}
</div>
</section>
<section>
<h2 className="mb-3 font-medium">Core Features</h2>
<ul className="grid grid-cols-2 gap-3">
<FeatureItem
title="Type-Safe API"
description="End-to-end type safety with tRPC"
/>
<FeatureItem
title="Modern React"
description="TanStack Router + TanStack Query"
/>
<FeatureItem
title="Fast Backend"
description="Lightweight Hono server"
/>
<FeatureItem
title="Beautiful UI"
description="TailwindCSS + shadcn/ui components"
/>
</ul>
</section>
</div>
</div>
);
}
function FeatureItem({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<li className="border-primary border-l-2 py-1 pl-3">
<h3 className="font-medium">{title}</h3>
<p className="text-muted-foreground text-sm">{description}</p>
</li>
);
}