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/tanstack-react-query": "^11.0.0",
"@trpc/server": "^11.0.0", "@trpc/server": "^11.0.0",
"@trpc/client": "^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; } as const;
export type AvailableDependencies = keyof typeof dependencyVersionMap; export type AvailableDependencies = keyof typeof dependencyVersionMap;

View File

@@ -1,20 +1,30 @@
import * as path from "node:path"; import path from "node:path";
import consola from "consola"; // Import consola
import fs from "fs-extra"; 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"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupApi(config: ProjectConfig): Promise<void> { 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 projectDir = path.resolve(process.cwd(), projectName);
const isConvex = backend === "convex";
const webDir = path.join(projectDir, "apps/web"); 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 webDirExists = await fs.pathExists(webDir);
const nativeDirExists = await fs.pathExists(nativeDir);
if (!isConvex && api !== "none") {
const serverDir = path.join(projectDir, "apps/server");
const serverDirExists = await fs.pathExists(serverDir);
const hasReactWeb = frontend.some((f) => const hasReactWeb = frontend.some((f) =>
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f), ["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
); );
const hasNuxtWeb = frontend.includes("nuxt"); const hasNuxtWeb = frontend.includes("nuxt");
const hasSvelteWeb = frontend.includes("svelte"); const hasSvelteWeb = frontend.includes("svelte");
if (serverDirExists) {
if (api === "orpc") { if (api === "orpc") {
await addPackageDependency({ await addPackageDependency({
dependencies: ["@orpc/server", "@orpc/client"], dependencies: ["@orpc/server", "@orpc/client"],
@@ -37,6 +47,8 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
}); });
} }
} }
} else {
}
if (webDirExists) { if (webDirExists) {
if (hasReactWeb) { if (hasReactWeb) {
@@ -65,16 +77,18 @@ export async function setupApi(config: ProjectConfig): Promise<void> {
} else if (hasSvelteWeb) { } else if (hasSvelteWeb) {
if (api === "orpc") { if (api === "orpc") {
await addPackageDependency({ await addPackageDependency({
dependencies: ["@orpc/svelte-query", "@orpc/client", "@orpc/server"], dependencies: [
"@orpc/svelte-query",
"@orpc/client",
"@orpc/server",
],
projectDir: webDir, projectDir: webDir,
}); });
} }
} }
} }
if (frontend.includes("native")) { if (nativeDirExists) {
const nativeDir = path.join(projectDir, "apps/native");
if (await fs.pathExists(nativeDir)) {
if (api === "trpc") { if (api === "trpc") {
await addPackageDependency({ await addPackageDependency({
dependencies: [ 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"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupAuth(config: ProjectConfig): Promise<void> { export async function setupAuth(config: ProjectConfig): Promise<void> {
const { projectName, auth, frontend } = config; const { projectName, auth, frontend, backend } = config;
if (!auth) {
if (backend === "convex" || !auth) {
return; return;
} }
@@ -18,12 +19,15 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
const clientDirExists = await fs.pathExists(clientDir); const clientDirExists = await fs.pathExists(clientDir);
const nativeDirExists = await fs.pathExists(nativeDir); const nativeDirExists = await fs.pathExists(nativeDir);
const serverDirExists = await fs.pathExists(serverDir);
try { try {
if (serverDirExists) {
await addPackageDependency({ await addPackageDependency({
dependencies: ["better-auth"], dependencies: ["better-auth"],
projectDir: serverDir, projectDir: serverDir,
}); });
}
const hasWebFrontend = frontend.some((f) => const hasWebFrontend = frontend.some((f) =>
[ [
@@ -48,11 +52,13 @@ export async function setupAuth(config: ProjectConfig): Promise<void> {
dependencies: ["better-auth", "@better-auth/expo"], dependencies: ["better-auth", "@better-auth/expo"],
projectDir: nativeDir, projectDir: nativeDir,
}); });
if (serverDirExists) {
await addPackageDependency({ await addPackageDependency({
dependencies: ["@better-auth/expo"], dependencies: ["@better-auth/expo"],
projectDir: serverDir, projectDir: serverDir,
}); });
} }
}
} catch (error) { } catch (error) {
consola.error(pc.red("Failed to configure authentication dependencies")); consola.error(pc.red("Failed to configure authentication dependencies"));
if (error instanceof Error) { if (error instanceof Error) {

View File

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

View File

@@ -28,44 +28,46 @@ import {
export async function createProject(options: ProjectConfig) { export async function createProject(options: ProjectConfig) {
const projectDir = path.resolve(process.cwd(), options.projectName); const projectDir = path.resolve(process.cwd(), options.projectName);
const isConvex = options.backend === "convex";
try { try {
await fs.ensureDir(projectDir); await fs.ensureDir(projectDir);
await copyBaseTemplate(projectDir, options); await copyBaseTemplate(projectDir, options);
await setupFrontendTemplates(projectDir, options); await setupFrontendTemplates(projectDir, options);
await setupBackendFramework(projectDir, options); await setupBackendFramework(projectDir, options);
await setupBackendDependencies(options); if (!isConvex) {
await setupDbOrmTemplates(projectDir, options); await setupDbOrmTemplates(projectDir, options);
await setupDatabase(options);
await setupAuthTemplate(projectDir, options); await setupAuthTemplate(projectDir, options);
await setupAuth(options); }
if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamplesTemplate(projectDir, options);
}
await setupAddonsTemplate(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") { if (options.addons.length > 0 && options.addons[0] !== "none") {
await setupAddons(options); await setupAddons(options);
} }
await setupExamplesTemplate(projectDir, options); if (!isConvex && options.auth) {
await handleExtras(projectDir, options); await setupAuth(options);
if (options.examples.length > 0 && options.examples[0] !== "none") {
await setupExamples(options);
} }
await setupApi(options); await handleExtras(projectDir, options);
await setupRuntime(options);
await setupEnvironmentVariables(options); await setupEnvironmentVariables(options);
await updatePackageConfigurations(projectDir, options); await updatePackageConfigurations(projectDir, options);
await createReadme(projectDir, options); await createReadme(projectDir, options);
await initializeGit(projectDir, options.git); await initializeGit(projectDir, options.git);
log.success("Project template successfully scaffolded!"); 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}`)); cancel(pc.red(`Error during project creation: ${error.message}`));
console.error(error.stack); console.error(error.stack);
process.exit(1); 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"; import type { ProjectConfig } from "../types";
export async function setupDatabase(config: ProjectConfig): Promise<void> { 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 projectDir = path.resolve(process.cwd(), projectName);
const s = spinner(); const s = spinner();
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
if (database === "none") { if (!(await fs.pathExists(serverDir))) {
await fs.remove(path.join(serverDir, "src/db"));
return; return;
} }

View File

@@ -5,7 +5,7 @@ import { generateAuthSecret } from "./auth-setup";
interface EnvVariable { interface EnvVariable {
key: string; key: string;
value: string; value: string | null | undefined;
condition: boolean; condition: boolean;
} }
@@ -21,41 +21,59 @@ async function addEnvVariablesToFile(
} }
let modified = false; let modified = false;
let contentToAdd = "";
for (const { key, value, condition } of variables) { for (const { key, value, condition } of variables) {
if (condition) { if (condition) {
const regex = new RegExp(`^${key}=.*$`, "m"); const regex = new RegExp(`^${key}=.*$`, "m");
const valueToWrite = value ?? "";
if (regex.test(envContent)) { if (regex.test(envContent)) {
if (value) { const existingMatch = envContent.match(regex);
envContent = envContent.replace(regex, `${key}=${value}`); if (existingMatch && existingMatch[0] !== `${key}=${valueToWrite}`) {
envContent = envContent.replace(regex, `${key}=${valueToWrite}`);
modified = true; modified = true;
} }
} else { } else {
envContent += `\n${key}=${value}`; contentToAdd += `${key}=${valueToWrite}\n`;
modified = true; modified = true;
} }
} }
} }
if (contentToAdd) {
if (envContent.length > 0 && !envContent.endsWith("\n")) {
envContent += "\n";
}
envContent += contentToAdd;
}
if (modified) { if (modified) {
await fs.writeFile(filePath, envContent.trim()); await fs.writeFile(filePath, envContent.trimEnd());
} }
} }
export async function setupEnvironmentVariables( export async function setupEnvironmentVariables(
config: ProjectConfig, config: ProjectConfig,
): Promise<void> { ): Promise<void> {
const { projectName } = config; const {
projectName,
backend,
frontend,
database,
orm,
auth,
examples,
dbSetup,
} = config;
const projectDir = path.resolve(process.cwd(), projectName); 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 hasReactRouter = frontend.includes("react-router");
const hasTanStackRouter = options.frontend.includes("tanstack-router"); const hasTanStackRouter = frontend.includes("tanstack-router");
const hasTanStackStart = options.frontend.includes("tanstack-start"); const hasTanStackStart = frontend.includes("tanstack-start");
const hasNextJs = options.frontend.includes("next"); const hasNextJs = frontend.includes("next");
const hasNuxt = options.frontend.includes("nuxt"); const hasNuxt = frontend.includes("nuxt");
const hasSvelte = options.frontend.includes("svelte"); const hasSvelte = frontend.includes("svelte");
const hasWebFrontend = const hasWebFrontend =
hasReactRouter || hasReactRouter ||
hasTanStackRouter || hasTanStackRouter ||
@@ -64,30 +82,99 @@ export async function setupEnvironmentVariables(
hasNuxt || hasNuxt ||
hasSvelte; 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"; let corsOrigin = "http://localhost:3001";
if (hasReactRouter || hasSvelte) { if (hasReactRouter || hasSvelte) {
corsOrigin = "http://localhost:5173"; corsOrigin = "http://localhost:5173";
} else if (hasTanStackRouter || hasTanStackStart || hasNextJs || hasNuxt) {
corsOrigin = "http://localhost:3001";
} }
let databaseUrl = ""; let databaseUrl: string | null = null;
const specializedSetup = const specializedSetup =
options.dbSetup === "turso" || dbSetup === "turso" ||
options.dbSetup === "prisma-postgres" || dbSetup === "prisma-postgres" ||
options.dbSetup === "mongodb-atlas" || dbSetup === "mongodb-atlas" ||
options.dbSetup === "neon"; dbSetup === "neon";
if (!specializedSetup) { if (database !== "none" && !specializedSetup) {
if (options.database === "postgres") { switch (database) {
case "postgres":
databaseUrl = databaseUrl =
"postgresql://postgres:postgres@localhost:5432/mydb?schema=public"; "postgresql://postgres:postgres@localhost:5432/mydb?schema=public";
} else if (options.database === "mysql") { break;
case "mysql":
databaseUrl = "mysql://root:password@localhost:3306/mydb"; databaseUrl = "mysql://root:password@localhost:3306/mydb";
} else if (options.database === "mongodb") { break;
case "mongodb":
databaseUrl = "mongodb://localhost:27017/mydatabase"; databaseUrl = "mongodb://localhost:27017/mydatabase";
} else if (options.database === "sqlite") { break;
case "sqlite":
databaseUrl = "file:./local.db"; databaseUrl = "file:./local.db";
break;
} }
} }
@@ -100,59 +187,24 @@ export async function setupEnvironmentVariables(
{ {
key: "BETTER_AUTH_SECRET", key: "BETTER_AUTH_SECRET",
value: generateAuthSecret(), value: generateAuthSecret(),
condition: !!options.auth, condition: !!auth,
}, },
{ {
key: "BETTER_AUTH_URL", key: "BETTER_AUTH_URL",
value: "http://localhost:3000", value: "http://localhost:3000",
condition: !!options.auth, condition: !!auth,
}, },
{ {
key: "DATABASE_URL", key: "DATABASE_URL",
value: databaseUrl, value: databaseUrl,
condition: condition: database !== "none" && !specializedSetup,
options.database !== "none" && databaseUrl !== "" && !specializedSetup,
}, },
{ {
key: "GOOGLE_GENERATIVE_AI_API_KEY", key: "GOOGLE_GENERATIVE_AI_API_KEY",
value: "", value: "",
condition: options.examples?.includes("ai") || false, condition: examples?.includes("ai") || false,
}, },
]; ];
await addEnvVariablesToFile(envPath, serverVars); 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"; import { addPackageDependency } from "../utils/add-package-deps";
export async function setupExamples(config: ProjectConfig): Promise<void> { 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); const projectDir = path.resolve(process.cwd(), projectName);
if (examples.includes("ai")) { if (examples.includes("ai")) {
const clientDir = path.join(projectDir, "apps/web"); const clientDir = path.join(projectDir, "apps/web");
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
const clientDirExists = await fs.pathExists(clientDir); const clientDirExists = await fs.pathExists(clientDir);
const serverDirExists = await fs.pathExists(serverDir);
const hasNuxt = frontend.includes("nuxt"); const hasNuxt = frontend.includes("nuxt");
const hasSvelte = frontend.includes("svelte"); const hasSvelte = frontend.includes("svelte");
@@ -22,6 +33,7 @@ export async function setupExamples(config: ProjectConfig): Promise<void> {
dependencies.push("@ai-sdk/vue"); dependencies.push("@ai-sdk/vue");
} else if (hasSvelte) { } else if (hasSvelte) {
dependencies.push("@ai-sdk/svelte"); dependencies.push("@ai-sdk/svelte");
} else {
} }
await addPackageDependency({ await addPackageDependency({
dependencies, dependencies,
@@ -29,9 +41,11 @@ export async function setupExamples(config: ProjectConfig): Promise<void> {
}); });
} }
if (serverDirExists) {
await addPackageDependency({ await addPackageDependency({
dependencies: ["ai", "@ai-sdk/google"], dependencies: ["ai", "@ai-sdk/google"],
projectDir: serverDir, projectDir: serverDir,
}); });
} }
} }
}

View File

@@ -1,6 +1,11 @@
import { consola } from "consola"; import { consola } from "consola";
import pc from "picocolors"; 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 { getPackageExecutionCommand } from "../utils/get-package-execution-command";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
@@ -17,16 +22,20 @@ export function displayPostInstallInstructions(
addons, addons,
runtime, runtime,
frontend, frontend,
backend,
} = config; } = config;
const isConvex = backend === "convex";
const runCmd = packageManager === "npm" ? "npm run" : packageManager; const runCmd = packageManager === "npm" ? "npm run" : packageManager;
const cdCmd = `cd ${projectName}`; const cdCmd = `cd ${projectName}`;
const hasHuskyOrBiome = const hasHuskyOrBiome =
addons?.includes("husky") || addons?.includes("biome"); addons?.includes("husky") || addons?.includes("biome");
const databaseInstructions = const databaseInstructions =
database !== "none" !isConvex && database !== "none"
? getDatabaseInstructions(database, orm, runCmd, runtime) ? getDatabaseInstructions(database, orm, runCmd, runtime)
: ""; : "";
const tauriInstructions = addons?.includes("tauri") const tauriInstructions = addons?.includes("tauri")
? getTauriInstructions(runCmd) ? getTauriInstructions(runCmd)
: ""; : "";
@@ -34,7 +43,7 @@ export function displayPostInstallInstructions(
? getLintingInstructions(runCmd) ? getLintingInstructions(runCmd)
: ""; : "";
const nativeInstructions = frontend?.includes("native") const nativeInstructions = frontend?.includes("native")
? getNativeInstructions() ? getNativeInstructions(isConvex)
: ""; : "";
const pwaInstructions = const pwaInstructions =
addons?.includes("pwa") && addons?.includes("pwa") &&
@@ -45,6 +54,7 @@ export function displayPostInstallInstructions(
const starlightInstructions = addons?.includes("starlight") const starlightInstructions = addons?.includes("starlight")
? getStarlightInstructions(runCmd) ? getStarlightInstructions(runCmd)
: ""; : "";
const hasWeb = frontend?.some((f) => const hasWeb = frontend?.some((f) =>
[ [
"tanstack-router", "tanstack-router",
@@ -56,78 +66,86 @@ export function displayPostInstallInstructions(
].includes(f), ].includes(f),
); );
const hasNative = frontend?.includes("native"); const hasNative = frontend?.includes("native");
const bunWebNativeWarning = const bunWebNativeWarning =
packageManager === "bun" && hasNative && hasWeb packageManager === "bun" && hasNative && hasWeb
? getBunWebNativeWarning() ? getBunWebNativeWarning()
: ""; : "";
const noOrmWarning = 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 hasReactRouter = frontend?.includes("react-router");
const hasNuxt = frontend?.includes("nuxt");
const hasSvelte = frontend?.includes("svelte"); 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 webPort = hasReactRouter || hasSvelte ? "5173" : "3001";
const tazeCommand = getPackageExecutionCommand(packageManager, "taze -r"); 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:")} let output = `${pc.bold("Next steps")}\n${pc.cyan("1.")} ${cdCmd}\n`;
${ let stepCounter = 2;
hasFrontend
? `${ if (!depsInstalled) {
hasWebFrontend output += `${pc.cyan(`${stepCounter++}.`)} ${packageManager} install\n`;
? `${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()}` : ""
} }
${pc.bold("Update all dependencies:\n")}${pc.cyan(tazeCommand)} if (isConvex) {
output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev:setup ${pc.dim("(this will guide you through Convex project setup)")}\n`;
${pc.bold("Like Better-T Stack?")} Please consider giving us a star on GitHub: output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
${pc.cyan("https://github.com/AmanVarshney01/create-better-t-stack")}`, } else {
); output += `${pc.cyan(`${stepCounter++}.`)} ${runCmd} dev\n\n`;
} }
function getNativeInstructions(): string { 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);
}
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";
return `${pc.yellow( return `${pc.yellow(
"NOTE:", "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 { function getLintingInstructions(runCmd?: string): string {
return `${pc.bold("Linting and formatting:")}\n${pc.cyan( 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( function getDatabaseInstructions(
@@ -161,10 +179,19 @@ function getDatabaseInstructions(
} else if (orm === "drizzle") { } else if (orm === "drizzle") {
instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`); instructions.push(`${pc.cyan("•")} Apply schema: ${`${runCmd} db:push`}`);
instructions.push(`${pc.cyan("•")} Database UI: ${`${runCmd} db:studio`}`); 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 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( )} Build desktop app: ${`cd apps/web && ${runCmd} desktop:build`}\n${pc.yellow(
"NOTE:", "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 { 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:", "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 { 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( )} 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 { function getNoOrmWarning(): string {
return `\n${pc.yellow( return `\n${pc.yellow(
"WARNING:", "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 { function getBunWebNativeWarning(): string {
return `\n${pc.yellow( return `\n${pc.yellow(
"WARNING:", "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 { $, execa } from "execa";
import fs from "fs-extra"; import fs from "fs-extra";
import pc from "picocolors"; import pc from "picocolors";
import { dependencyVersionMap } from "../constants";
import type { ProjectConfig } from "../types"; import type { ProjectConfig } from "../types";
export async function updatePackageConfigurations( export async function updatePackageConfigurations(
@@ -11,7 +10,11 @@ export async function updatePackageConfigurations(
options: ProjectConfig, options: ProjectConfig,
): Promise<void> { ): Promise<void> {
await updateRootPackageJson(projectDir, options); await updateRootPackageJson(projectDir, options);
if (options.backend !== "convex") {
await updateServerPackageJson(projectDir, options); await updateServerPackageJson(projectDir, options);
} else {
await updateConvexPackageJson(projectDir, options);
}
} }
async function updateRootPackageJson( async function updateRootPackageJson(
@@ -19,76 +22,149 @@ async function updateRootPackageJson(
options: ProjectConfig, options: ProjectConfig,
): Promise<void> { ): Promise<void> {
const rootPackageJsonPath = path.join(projectDir, "package.json"); const rootPackageJsonPath = path.join(projectDir, "package.json");
if (await fs.pathExists(rootPackageJsonPath)) { if (!(await fs.pathExists(rootPackageJsonPath))) return;
const packageJson = await fs.readJson(rootPackageJsonPath); const packageJson = await fs.readJson(rootPackageJsonPath);
packageJson.name = options.projectName; packageJson.name = options.projectName;
const turboScripts = { if (!packageJson.scripts) {
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 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",
};
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 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",
};
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 = {}; packageJson.scripts = {};
} }
const scripts = packageJson.scripts;
const backendPackageName =
options.backend === "convex" ? `@${options.projectName}/backend` : "server";
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}`;
} }
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"], { const { stdout } = await execa(options.packageManager, ["-v"], {
cwd: projectDir, cwd: projectDir,
}); });
packageJson.packageManager = `${options.packageManager}@${stdout.trim()}`; packageJson.packageManager = `${options.packageManager}@${stdout.trim()}`;
} 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 }); await fs.writeJson(rootPackageJsonPath, packageJson, { spaces: 2 });
} }
}
async function updateServerPackageJson( async function updateServerPackageJson(
projectDir: string, projectDir: string,
@@ -99,21 +175,26 @@ async function updateServerPackageJson(
"apps/server/package.json", "apps/server/package.json",
); );
if (await fs.pathExists(serverPackageJsonPath)) { if (!(await fs.pathExists(serverPackageJsonPath))) return;
const serverPackageJson = await fs.readJson(serverPackageJsonPath); const serverPackageJson = await fs.readJson(serverPackageJsonPath);
if (!serverPackageJson.scripts) {
serverPackageJson.scripts = {};
}
const scripts = serverPackageJson.scripts;
if (options.database !== "none") { if (options.database !== "none") {
if (options.database === "sqlite" && options.orm === "drizzle") { if (options.database === "sqlite" && options.orm === "drizzle") {
serverPackageJson.scripts["db:local"] = "turso dev --db-file local.db"; scripts["db:local"] = "turso dev --db-file local.db";
} }
if (options.orm === "prisma") { if (options.orm === "prisma") {
serverPackageJson.scripts["db:push"] = scripts["db:push"] = "prisma db push --schema ./prisma/schema.prisma";
"prisma db push --schema ./prisma/schema"; scripts["db:studio"] = "prisma studio";
serverPackageJson.scripts["db:studio"] = "prisma studio";
} else if (options.orm === "drizzle") { } else if (options.orm === "drizzle") {
serverPackageJson.scripts["db:push"] = "drizzle-kit push"; scripts["db:push"] = "drizzle-kit push";
serverPackageJson.scripts["db:studio"] = "drizzle-kit studio"; scripts["db:studio"] = "drizzle-kit studio";
} }
} }
@@ -121,6 +202,26 @@ async function updateServerPackageJson(
spaces: 2, 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( 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> { export async function setupRuntime(config: ProjectConfig): Promise<void> {
const { projectName, runtime, backend } = config; const { projectName, runtime, backend } = config;
const projectDir = path.resolve(process.cwd(), projectName);
if (backend === "next") { if (backend === "convex" || backend === "next" || runtime === "none") {
return; return;
} }
const projectDir = path.resolve(process.cwd(), projectName);
const serverDir = path.join(projectDir, "apps/server"); const serverDir = path.join(projectDir, "apps/server");
if (!(await fs.pathExists(serverDir))) {
return;
}
if (runtime === "bun") { if (runtime === "bun") {
await setupBunRuntime(serverDir, backend); await setupBunRuntime(serverDir, backend);
} else if (runtime === "node") { } else if (runtime === "node") {
@@ -24,6 +29,8 @@ async function setupBunRuntime(
backend: ProjectBackend, backend: ProjectBackend,
): Promise<void> { ): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return;
const packageJson = await fs.readJson(packageJsonPath); const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = { packageJson.scripts = {
@@ -45,6 +52,8 @@ async function setupNodeRuntime(
backend: ProjectBackend, backend: ProjectBackend,
): Promise<void> { ): Promise<void> {
const packageJsonPath = path.join(serverDir, "package.json"); const packageJsonPath = path.join(serverDir, "package.json");
if (!(await fs.pathExists(packageJsonPath))) return;
const packageJson = await fs.readJson(packageJsonPath); const packageJson = await fs.readJson(packageJsonPath);
packageJson.scripts = { packageJson.scripts = {

View File

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

View File

@@ -123,17 +123,17 @@ async function main() {
.option("backend", { .option("backend", {
type: "string", type: "string",
describe: "Backend framework", describe: "Backend framework",
choices: ["hono", "express", "next", "elysia"], choices: ["hono", "express", "next", "elysia", "convex"],
}) })
.option("runtime", { .option("runtime", {
type: "string", type: "string",
describe: "Runtime", describe: "Runtime",
choices: ["bun", "node"], choices: ["bun", "node", "none"],
}) })
.option("api", { .option("api", {
type: "string", type: "string",
describe: "API type", describe: "API type",
choices: ["trpc", "orpc"], choices: ["trpc", "orpc", "none"],
}) })
.completion() .completion()
.recommendCommands() .recommendCommands()
@@ -168,11 +168,17 @@ async function main() {
...flagConfig, ...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.orm = "none";
config.auth = false; config.auth = false;
config.dbSetup = "none"; config.dbSetup = "none";
config.examples = config.examples.filter((ex) => ex !== "todo");
} }
log.info(pc.yellow("Using these default/flag options:")); log.info(pc.yellow("Using these default/flag options:"));
@@ -216,8 +222,10 @@ async function main() {
cancel(pc.red(`Invalid arguments: ${error.message}`)); cancel(pc.red(`Invalid arguments: ${error.message}`));
} else { } else {
consola.error(`An unexpected error occurred: ${error.message}`); consola.error(`An unexpected error occurred: ${error.message}`);
if (!error.message.includes("is only supported with")) {
consola.error(error.stack); consola.error(error.stack);
} }
}
process.exit(1); process.exit(1);
} else { } else {
consola.error("An unexpected error occurred."); consola.error("An unexpected error occurred.");
@@ -232,6 +240,32 @@ function processAndValidateFlags(
projectDirectory?: string, projectDirectory?: string,
): Partial<ProjectConfig> { ): Partial<ProjectConfig> {
const config: 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) { if (options.database) {
config.database = options.database as ProjectDatabase; config.database = options.database as ProjectDatabase;
@@ -248,12 +282,22 @@ function processAndValidateFlags(
if (options.install !== undefined) { if (options.install !== undefined) {
config.install = options.install; config.install = options.install;
} }
if (options.backend) {
config.backend = options.backend as ProjectBackend;
}
if (options.runtime) { if (options.runtime) {
config.runtime = options.runtime as ProjectRuntime; 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 && options.frontend.length > 0) {
if (options.frontend.includes("none")) { if (options.frontend.includes("none")) {
if (options.frontend.length > 1) { if (options.frontend.length > 1) {
@@ -283,9 +327,6 @@ function processAndValidateFlags(
config.frontend = validOptions; config.frontend = validOptions;
} }
} }
if (options.api) {
config.api = options.api as ProjectApi;
}
if (options.addons && options.addons.length > 0) { if (options.addons && options.addons.length > 0) {
if (options.addons.includes("none")) { if (options.addons.includes("none")) {
if (options.addons.length > 1) { if (options.addons.length > 1) {
@@ -310,18 +351,50 @@ function processAndValidateFlags(
config.examples = options.examples.filter( config.examples = options.examples.filter(
(ex): ex is ProjectExamples => ex !== "none", (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;
} }
if (config.backend === "convex") {
const incompatibleFlags: string[] = [];
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(
`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.auth = false;
config.database = "none";
config.orm = "none";
config.api = "none";
config.runtime = "none";
config.dbSetup = "none";
config.examples = ["todo"];
} else {
const effectiveDatabase = const effectiveDatabase =
config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined); config.database ?? (options.yes ? DEFAULT_CONFIG.database : undefined);
const effectiveOrm = const effectiveOrm =
@@ -340,15 +413,15 @@ function processAndValidateFlags(
config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined); config.backend ?? (options.yes ? DEFAULT_CONFIG.backend : undefined);
if (effectiveDatabase === "none") { if (effectiveDatabase === "none") {
if (effectiveOrm && effectiveOrm !== "none") { if (providedFlags.has("orm") && options.orm !== "none") {
consola.fatal( consola.fatal(
`Cannot use ORM '--orm ${effectiveOrm}' when database is 'none'.`, `Cannot use ORM '--orm ${options.orm}' when database is 'none'.`,
); );
process.exit(1); process.exit(1);
} }
config.orm = "none"; config.orm = "none";
if (effectiveAuth === true) { if (providedFlags.has("auth") && options.auth === true) {
consola.fatal( consola.fatal(
"Authentication requires a database. Cannot use --auth when database is 'none'.", "Authentication requires a database. Cannot use --auth when database is 'none'.",
); );
@@ -356,23 +429,13 @@ function processAndValidateFlags(
} }
config.auth = false; config.auth = false;
if (effectiveDbSetup && effectiveDbSetup !== "none") { if (providedFlags.has("dbSetup") && options.dbSetup !== "none") {
consola.fatal( consola.fatal(
`Database setup '--db-setup ${effectiveDbSetup}' requires a database. Cannot use when database is 'none'.`, `Database setup '--db-setup ${options.dbSetup}' requires a database. Cannot use when database is 'none'.`,
); );
process.exit(1); process.exit(1);
} }
config.dbSetup = "none"; config.dbSetup = "none";
if (effectiveExamples?.includes("todo")) {
consola.fatal(
"The 'todo' example requires a database. Cannot use --examples todo when database is 'none'.",
);
process.exit(1);
}
if (config.examples) {
config.examples = config.examples.filter((ex) => ex !== "todo");
}
} }
if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") { if (effectiveDatabase === "mongodb" && effectiveOrm === "drizzle") {
@@ -466,8 +529,10 @@ function processAndValidateFlags(
effectiveApi !== "orpc" && effectiveApi !== "orpc" &&
(!options.api || (options.yes && options.api !== "trpc")) (!options.api || (options.yes && options.api !== "trpc"))
) { ) {
if (config.api !== "none") {
config.api = "orpc"; config.api = "orpc";
} }
}
if (config.addons && config.addons.length > 0) { if (config.addons && config.addons.length > 0) {
const webSpecificAddons = ["pwa", "tauri"]; const webSpecificAddons = ["pwa", "tauri"];
@@ -511,7 +576,39 @@ function processAndValidateFlags(
config.addons = [...new Set(config.addons)]; config.addons = [...new Set(config.addons)];
} }
if (config.examples && config.examples.length > 0) { 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 (
config.examples.includes("todo") &&
effectiveBackend !== "convex" &&
effectiveDatabase === "none"
) {
consola.fatal(
"The 'todo' example requires a database (unless using Convex). Cannot use --examples todo when database is 'none'.",
);
process.exit(1);
}
if (config.examples.includes("ai") && effectiveBackend === "elysia") { if (config.examples.includes("ai") && effectiveBackend === "elysia") {
consola.fatal( consola.fatal(
"The 'ai' example is not compatible with the Elysia backend.", "The 'ai' example is not compatible with the Elysia backend.",
@@ -529,12 +626,8 @@ function processAndValidateFlags(
"svelte", "svelte",
].includes(f), ].includes(f),
); );
const noFrontendSelected =
if (config.examples.length > 0 && !hasWebFrontendForExamples) { !effectiveFrontend || effectiveFrontend.length === 0;
consola.fatal(
"Examples require a web frontend (tanstack-router, react-router, tanstack-start, next, nuxt, or svelte).",
);
process.exit(1);
} }
} }
@@ -544,7 +637,14 @@ function processAndValidateFlags(
main().catch((err) => { main().catch((err) => {
consola.error("Aborting installation due to unexpected error..."); consola.error("Aborting installation due to unexpected error...");
if (err instanceof Error) { if (err instanceof Error) {
if (
!err.message.includes("is only supported with") &&
!err.message.includes("incompatible with")
) {
consola.error(err.message); consola.error(err.message);
consola.error(err.stack);
} else {
}
} else { } else {
console.error(err); 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 pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import type { ProjectApi, ProjectFrontend } from "../types"; import type { ProjectApi, ProjectBackend, ProjectFrontend } from "../types";
export async function getApiChoice( export async function getApiChoice(
Api?: ProjectApi | undefined, Api?: ProjectApi | undefined,
frontend?: ProjectFrontend[], frontend?: ProjectFrontend[],
backend?: ProjectBackend,
): Promise<ProjectApi> { ): Promise<ProjectApi> {
if (backend === "convex") {
return "none";
}
if (Api) return Api; if (Api) return Api;
const includesNuxt = frontend?.includes("nuxt"); 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 pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import type { ProjectBackend } from "../types";
export async function getAuthChoice( export async function getAuthChoice(
auth: boolean | undefined, auth: boolean | undefined,
hasDatabase: boolean, hasDatabase: boolean,
backend?: ProjectBackend,
): Promise<boolean> { ): Promise<boolean> {
if (backend === "convex") {
return false;
}
if (!hasDatabase) return false; if (!hasDatabase) return false;
if (auth !== undefined) return auth; if (auth !== undefined) return auth;

View File

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

View File

@@ -30,19 +30,19 @@ import { getRuntimeChoice } from "./runtime";
type PromptGroupResults = { type PromptGroupResults = {
projectName: string; projectName: string;
frontend: ProjectFrontend[];
backend: ProjectBackend;
runtime: ProjectRuntime;
database: ProjectDatabase; database: ProjectDatabase;
orm: ProjectOrm; orm: ProjectOrm;
api: ProjectApi;
auth: boolean; auth: boolean;
addons: ProjectAddons[]; addons: ProjectAddons[];
examples: ProjectExamples[]; examples: ProjectExamples[];
dbSetup: ProjectDBSetup;
git: boolean; git: boolean;
packageManager: ProjectPackageManager; packageManager: ProjectPackageManager;
install: boolean; install: boolean;
dbSetup: ProjectDBSetup;
backend: ProjectBackend;
runtime: ProjectRuntime;
frontend: ProjectFrontend[];
api: ProjectApi;
}; };
export async function gatherConfig( export async function gatherConfig(
@@ -57,12 +57,19 @@ export async function gatherConfig(
backend: () => getBackendFrameworkChoice(flags.backend), backend: () => getBackendFrameworkChoice(flags.backend),
runtime: ({ results }) => runtime: ({ results }) =>
getRuntimeChoice(flags.runtime, results.backend), getRuntimeChoice(flags.runtime, results.backend),
database: () => getDatabaseChoice(flags.database), database: ({ results }) =>
getDatabaseChoice(flags.database, results.backend),
orm: ({ results }) => orm: ({ results }) =>
getORMChoice(flags.orm, results.database !== "none", results.database), getORMChoice(
api: ({ results }) => getApiChoice(flags.api, results.frontend), flags.orm,
results.database !== "none",
results.database,
results.backend,
),
api: ({ results }) =>
getApiChoice(flags.api, results.frontend, results.backend),
auth: ({ results }) => auth: ({ results }) =>
getAuthChoice(flags.auth, results.database !== "none"), getAuthChoice(flags.auth, results.database !== "none", results.backend),
addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend), addons: ({ results }) => getAddonsChoice(flags.addons, results.frontend),
examples: ({ results }) => examples: ({ results }) =>
getExamplesChoice( getExamplesChoice(
@@ -76,6 +83,7 @@ export async function gatherConfig(
results.database ?? "none", results.database ?? "none",
flags.dbSetup, flags.dbSetup,
results.orm, results.orm,
results.backend,
), ),
git: () => getGitChoice(flags.git), git: () => getGitChoice(flags.git),
packageManager: () => getPackageManagerChoice(flags.packageManager), 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 { return {
projectName: result.projectName, projectName: result.projectName,
frontend: result.frontend, frontend: result.frontend,
backend: result.backend,
runtime: result.runtime,
database: result.database, database: result.database,
orm: result.orm, orm: result.orm,
auth: result.auth, auth: result.auth,
@@ -101,8 +120,6 @@ export async function gatherConfig(
packageManager: result.packageManager, packageManager: result.packageManager,
install: result.install, install: result.install,
dbSetup: result.dbSetup, dbSetup: result.dbSetup,
backend: result.backend,
runtime: result.runtime,
api: result.api, 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 pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import type { ProjectDatabase } from "../types"; import type { ProjectBackend, ProjectDatabase } from "../types";
export async function getDatabaseChoice( export async function getDatabaseChoice(
database?: ProjectDatabase, database?: ProjectDatabase,
backend?: ProjectBackend,
): Promise<ProjectDatabase> { ): Promise<ProjectDatabase> {
if (backend === "convex") {
return "none";
}
if (database !== undefined) return database; if (database !== undefined) return database;
const response = await select<ProjectDatabase>({ 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 pc from "picocolors";
import type { ProjectDBSetup, ProjectOrm } from "../types"; import type { ProjectBackend, ProjectDBSetup, ProjectOrm } from "../types";
export async function getDBSetupChoice( export async function getDBSetupChoice(
databaseType: string, databaseType: string,
dbSetup: ProjectDBSetup | undefined, dbSetup: ProjectDBSetup | undefined,
orm?: ProjectOrm, orm?: ProjectOrm,
backend?: ProjectBackend,
): Promise<ProjectDBSetup> { ): Promise<ProjectDBSetup> {
if (backend === "convex") {
return "none";
}
if (dbSetup !== undefined) return dbSetup as ProjectDBSetup; if (dbSetup !== undefined) return dbSetup as ProjectDBSetup;
if (databaseType === "none") {
return "none";
}
if (databaseType === "sqlite" && orm === "prisma") { if (databaseType === "sqlite" && orm === "prisma") {
return "none"; 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 pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants"; import { DEFAULT_CONFIG } from "../constants";
import type { import type {
@@ -16,17 +16,32 @@ export async function getExamplesChoice(
): Promise<ProjectExamples[]> { ): Promise<ProjectExamples[]> {
if (examples !== undefined) return examples; if (examples !== undefined) return examples;
if (backend === "convex") {
return ["todo"];
}
if (database === "none") return []; if (database === "none") return [];
const hasWebFrontend = const onlyNative =
frontends?.includes("react-router") || frontends && frontends.length === 1 && frontends[0] === "native";
frontends?.includes("tanstack-router") || if (onlyNative) {
frontends?.includes("tanstack-start") || return [];
frontends?.includes("next") || }
frontends?.includes("nuxt") ||
frontends?.includes("svelte");
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 = []; let response: ProjectExamples[] | symbol = [];
const options: { value: ProjectExamples; label: string; hint: string }[] = [ const options: { value: ProjectExamples; label: string; hint: string }[] = [

View File

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

View File

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

View File

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

View File

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

View File

@@ -92,8 +92,6 @@ export async function createContext(opts: any) {
} }
{{else}} {{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() { export async function createContext() {
return { return {
session: null, session: null,

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
// import { authClient } from "~/lib/auth-client";
const {$authClient} = useNuxtApp() const {$authClient} = useNuxtApp()
const session = $authClient.useSession() const session = $authClient.useSession()
const toast = useToast() 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", "name": "better-t-stack",
"private": true, "private": true,
"workspaces": [ "workspaces": [
"apps/*" "apps/*",
"packages/*"
], ],
"scripts": { "scripts": {

View File

@@ -8,6 +8,14 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
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")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
{{/if}} {{/if}}
@@ -15,12 +23,33 @@ import { orpc } from "@/utils/orpc";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}} {{/if}}
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Loader2, Trash2 } from "lucide-react"; {{/if}}
import { useState } from "react";
export default function Todos() { export default function Todos() {
const [newTodoText, setNewTodoText] = useState(""); const [newTodoText, setNewTodoText] = useState("");
{{#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")}} {{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions()); const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation( const createMutation = useMutation(
@@ -78,6 +107,7 @@ export default function Todos() {
const handleDeleteTodo = (id: number) => { const handleDeleteTodo = (id: number) => {
deleteMutation.mutate({ id }); deleteMutation.mutate({ id });
}; };
{{/if}}
return ( return (
<div className="w-full mx-auto max-w-md py-10"> <div className="w-full mx-auto max-w-md py-10">
@@ -95,20 +125,74 @@ export default function Todos() {
value={newTodoText} value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)} onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..." 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} disabled={createMutation.isPending}
{{/if}}
/> />
<Button <Button
type="submit" type="submit"
{{#if (eq backend "convex")}}
disabled={!newTodoText.trim()}
{{else}}
disabled={createMutation.isPending || !newTodoText.trim()} disabled={createMutation.isPending || !newTodoText.trim()}
{{/if}}
> >
{{#if (eq backend "convex")}}
Add
{{else}}
{createMutation.isPending ? ( {createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
"Add" "Add"
)} )}
{{/if}}
</Button> </Button>
</form> </form>
{{#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"
>
<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 ? ( {todos.isLoading ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
@@ -151,6 +235,7 @@ export default function Todos() {
))} ))}
</ul> </ul>
)} )}
{{/if}}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -8,6 +8,15 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
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")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
{{/if}} {{/if}}
@@ -15,9 +24,7 @@ import { orpc } from "@/utils/orpc";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}} {{/if}}
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; {{/if}}
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/todos")({ export const Route = createFileRoute("/todos")({
component: TodosRoute, component: TodosRoute,
@@ -26,6 +33,28 @@ export const Route = createFileRoute("/todos")({
function TodosRoute() { function TodosRoute() {
const [newTodoText, setNewTodoText] = useState(""); const [newTodoText, setNewTodoText] = useState("");
{{#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")}} {{#if (eq api "orpc")}}
const todos = useQuery(orpc.todo.getAll.queryOptions()); const todos = useQuery(orpc.todo.getAll.queryOptions());
const createMutation = useMutation( const createMutation = useMutation(
@@ -83,6 +112,7 @@ function TodosRoute() {
const handleDeleteTodo = (id: number) => { const handleDeleteTodo = (id: number) => {
deleteMutation.mutate({ id }); deleteMutation.mutate({ id });
}; };
{{/if}}
return ( return (
<div className="mx-auto w-full max-w-md py-10"> <div className="mx-auto w-full max-w-md py-10">
@@ -100,26 +130,81 @@ function TodosRoute() {
value={newTodoText} value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)} onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..." placeholder="Add a new task..."
{{#if (eq backend "convex")}}
{{else}}
disabled={createMutation.isPending} disabled={createMutation.isPending}
{{/if}}
/> />
<Button <Button
type="submit" type="submit"
{{#if (eq backend "convex")}}
disabled={!newTodoText.trim()}
{{else}}
disabled={createMutation.isPending || !newTodoText.trim()} disabled={createMutation.isPending || !newTodoText.trim()}
{{/if}}
> >
{{#if (eq backend "convex")}}
Add
{{else}}
{createMutation.isPending ? ( {createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
"Add" "Add"
)} )}
{{/if}}
</Button> </Button>
</form> </form>
{{#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"
>
<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 ? ( {todos.isLoading ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
</div> </div>
) : todos.data?.length === 0 ? ( ) : todos.data?.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p> <p className="py-4 text-center">
No todos yet. Add one above!
</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{todos.data?.map((todo) => ( {todos.data?.map((todo) => (
@@ -154,6 +239,7 @@ function TodosRoute() {
))} ))}
</ul> </ul>
)} )}
{{/if}}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -8,6 +8,17 @@ import {
} from "@/components/ui/card"; } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
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")}} {{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc"; import { useTRPC } from "@/utils/trpc";
{{/if}} {{/if}}
@@ -15,15 +26,53 @@ import { useTRPC } from "@/utils/trpc";
import { useORPC } from "@/utils/orpc"; import { useORPC } from "@/utils/orpc";
{{/if}} {{/if}}
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; {{/if}}
import { Loader2, Trash2 } from "lucide-react";
import { useState } from "react";
export const Route = createFileRoute("/todos")({ export const Route = createFileRoute("/todos")({
component: TodosRoute, component: TodosRoute,
}); });
function TodosRoute() { function TodosRoute() {
const [newTodoText, setNewTodoText] = useState("");
{{#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")}} {{#if (eq api "trpc")}}
const trpc = useTRPC(); const trpc = useTRPC();
{{/if}} {{/if}}
@@ -31,8 +80,6 @@ function TodosRoute() {
const orpc = useORPC(); const orpc = useORPC();
{{/if}} {{/if}}
const [newTodoText, setNewTodoText] = useState("");
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
const todos = useQuery(trpc.todo.getAll.queryOptions()); const todos = useQuery(trpc.todo.getAll.queryOptions());
const createMutation = useMutation( const createMutation = useMutation(
@@ -90,12 +137,13 @@ function TodosRoute() {
const handleDeleteTodo = (id: number) => { const handleDeleteTodo = (id: number) => {
deleteMutation.mutate({ id }); deleteMutation.mutate({ id });
}; };
{{/if}}
return ( return (
<div className="mx-auto w-full max-w-md py-10"> <div className="mx-auto w-full max-w-md py-10">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Todo List</CardTitle> <CardTitle>Todo List{{#if (eq backend "convex")}} (Convex){{/if}}</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription> <CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@@ -107,20 +155,72 @@ function TodosRoute() {
value={newTodoText} value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)} onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..." placeholder="Add a new task..."
{{#unless (eq backend "convex")}}
disabled={createMutation.isPending} disabled={createMutation.isPending}
{{/unless}}
/> />
<Button <Button
type="submit" type="submit"
{{#unless (eq backend "convex")}}
disabled={createMutation.isPending || !newTodoText.trim()} disabled={createMutation.isPending || !newTodoText.trim()}
{{else}}
disabled={!newTodoText.trim()}
{{/unless}}
> >
{{#unless (eq backend "convex")}}
{createMutation.isPending ? ( {createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
"Add" "Add"
)} )}
{{else}}
Add
{{/unless}}
</Button> </Button>
</form> </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 ? ( {todos.isLoading ? (
<div className="flex justify-center py-4"> <div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
@@ -161,6 +261,7 @@ function TodosRoute() {
))} ))}
</ul> </ul>
)} )}
{{/if}}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

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

View File

@@ -1,12 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { View, Text, ScrollView } from "react-native"; import { View, Text, ScrollView } from "react-native";
import { Container } from "@/components/container"; import { Container } from "@/components/container";
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { useQuery } from "@tanstack/react-query";
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
{{/if}} {{/if}}
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
import { useQuery } from "@tanstack/react-query";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}} {{/if}}
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{ projectName }}/backend/convex/_generated/api.js";
{{/if}}
export default function Home() { export default function Home() {
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
@@ -15,6 +20,9 @@ export default function Home() {
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions()); const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}} {{/if}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{/if}}
return ( return (
<Container> <Container>
@@ -28,15 +36,35 @@ export default function Home() {
<View className="flex-row items-center gap-2"> <View className="flex-row items-center gap-2">
<View <View
className={`h-2.5 w-2.5 rounded-full ${ className={`h-2.5 w-2.5 rounded-full ${
{{#if (or (eq api "orpc") (eq api "trpc"))}}
healthCheck.data ? "bg-green-500" : "bg-red-500" healthCheck.data ? "bg-green-500" : "bg-red-500"
{{else}}
healthCheck ? "bg-green-500" : "bg-red-500"
{{/if}}
}`} }`}
/> />
<Text className="text-sm text-foreground"> <Text className="text-sm text-foreground">
{{#if (eq api "orpc")}}
{healthCheck.isLoading {healthCheck.isLoading
? "Checking..." ? "Checking..."
: healthCheck.data : healthCheck.data
? "Connected" ? "Connected"
: "Disconnected"} : "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> </Text>
</View> </View>
</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"; import { QueryClientProvider } from "@tanstack/react-query";
{{/if}}
import { Stack } from "expo-router"; import { Stack } from "expo-router";
import { import {
DarkTheme, DarkTheme,
@@ -35,6 +39,12 @@ export const unstable_settings = {
initialRouteName: "(drawer)", initialRouteName: "(drawer)",
}; };
{{#if (eq backend "convex")}}
const convex = new ConvexReactClient(process.env.EXPO_PUBLIC_CONVEX_URL!, {
unsavedChangesWarning: false,
});
{{/if}}
export default function RootLayout() { export default function RootLayout() {
const hasMounted = useRef(false); const hasMounted = useRef(false);
const { colorScheme, isDarkColorScheme } = useColorScheme(); const { colorScheme, isDarkColorScheme } = useColorScheme();
@@ -58,6 +68,22 @@ export default function RootLayout() {
return null; return null;
} }
return ( 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}> <QueryClientProvider client={queryClient}>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}> <ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} /> <StatusBar style={isDarkColorScheme ? "light" : "dark"} />
@@ -72,6 +98,7 @@ export default function RootLayout() {
</GestureHandlerRootView> </GestureHandlerRootView>
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
{{/if}}
); );
} }

View File

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

View File

@@ -1,4 +1,8 @@
"use client" "use client"
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
{{else}}
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
{{/if}} {{/if}}
@@ -6,6 +10,7 @@ import { orpc } from "@/utils/orpc";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}} {{/if}}
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
{{/if}}
const TITLE_TEXT = ` const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗ ██████╗ ███████╗████████╗████████╗███████╗██████╗
@@ -24,10 +29,11 @@ const TITLE_TEXT = `
`; `;
export default function Home() { 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()); const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}} {{else if (eq api "trpc")}}
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions()); const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}} {{/if}}
@@ -38,6 +44,18 @@ export default function Home() {
<section className="rounded-lg border p-4"> <section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2> <h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2"> <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 <div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`} className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/> />
@@ -48,46 +66,10 @@ export default function Home() {
? "Connected" ? "Connected"
: "Disconnected"} : "Disconnected"}
</span> </span>
{{/if}}
</div> </div>
</section> </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>
</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,4 +1,7 @@
"use client" "use client"
{{#if (eq backend "convex")}}
import { ConvexProvider, ConvexReactClient } from "convex/react";
{{else}}
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "@/utils/orpc"; import { orpc, ORPCContext, queryClient } from "@/utils/orpc";
@@ -6,9 +9,14 @@ import { orpc, ORPCContext, queryClient } from "@/utils/orpc";
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
import { queryClient } from "@/utils/trpc"; import { queryClient } from "@/utils/trpc";
{{/if}} {{/if}}
{{/if}}
import { ThemeProvider } from "./theme-provider"; import { ThemeProvider } from "./theme-provider";
import { Toaster } from "./ui/sonner"; import { Toaster } from "./ui/sonner";
{{#if (eq backend "convex")}}
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
{{/if}}
export default function Providers({ export default function Providers({
children, children,
}: { }: {
@@ -21,6 +29,9 @@ export default function Providers({
enableSystem enableSystem
disableTransitionOnChange disableTransitionOnChange
> >
{{#if (eq backend "convex")}}
<ConvexProvider client={convex}>{children}</ConvexProvider>
{{else}}
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
<ORPCContext.Provider value={orpc}> <ORPCContext.Provider value={orpc}>
@@ -31,6 +42,7 @@ export default function Providers({
{children} {children}
{{/if}} {{/if}}
</QueryClientProvider> </QueryClientProvider>
{{/if}}
<Toaster richColors /> <Toaster richColors />
</ThemeProvider> </ThemeProvider>
) )

View File

@@ -17,7 +17,6 @@
"@react-router/node": "^7.4.1", "@react-router/node": "^7.4.1",
"@react-router/serve": "^7.4.1", "@react-router/serve": "^7.4.1",
"@tanstack/react-form": "^1.2.3", "@tanstack/react-form": "^1.2.3",
"@tanstack/react-query": "^5.71.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"isbot": "^5.1.17", "isbot": "^5.1.17",
@@ -34,7 +33,6 @@
"devDependencies": { "devDependencies": {
"@react-router/dev": "^7.4.1", "@react-router/dev": "^7.4.1",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@tanstack/react-query-devtools": "^5.71.3",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19.0.1", "@types/react": "^19.0.1",
"@types/react-dom": "^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 { import {
isRouteErrorResponse, isRouteErrorResponse,
Links, Links,
@@ -14,12 +12,18 @@ import Header from "./components/header";
import { ThemeProvider } from "./components/theme-provider"; import { ThemeProvider } from "./components/theme-provider";
import { Toaster } from "./components/ui/sonner"; import { Toaster } from "./components/ui/sonner";
{{#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")}} {{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "./utils/orpc"; import { orpc, ORPCContext, queryClient } from "./utils/orpc";
{{/if}} {{/if}}
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
import { queryClient } from "./utils/trpc"; import { queryClient } from "./utils/trpc";
{{/if}} {{/if}}
{{/if}}
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
@@ -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() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
@@ -69,9 +91,7 @@ export default function App() {
</QueryClientProvider> </QueryClientProvider>
); );
} }
{{/if}} {{else if (eq api "trpc")}}
{{#if (eq api "trpc")}}
export default function App() { export default function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View File

@@ -1,4 +1,8 @@
import type { Route } from "./+types/_index"; import type { Route } from "./+types/_index";
{{#if (eq backend "convex")}}
import { useQuery } from "convex/react";
import { api } from "@{{projectName}}/backend/convex/_generated/api.js";
{{else}}
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
{{/if}} {{/if}}
@@ -6,6 +10,7 @@ import { orpc } from "@/utils/orpc";
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}} {{/if}}
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
{{/if}}
const TITLE_TEXT = ` const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗ ██████╗ ███████╗████████╗████████╗███████╗██████╗
@@ -28,11 +33,11 @@ export function meta({}: Route.MetaArgs) {
} }
export default function Home() { export default function Home() {
{{#if (eq backend "convex")}}
{{#if (eq api "orpc")}} const healthCheck = useQuery(api.healthCheck.get);
{{else if (eq api "orpc")}}
const healthCheck = useQuery(orpc.healthCheck.queryOptions()); const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}} {{else if (eq api "trpc")}}
{{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions()); const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}} {{/if}}
@@ -43,6 +48,18 @@ export default function Home() {
<section className="rounded-lg border p-4"> <section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2> <h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2"> <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 <div
className={`h-2 w-2 rounded-full ${ className={`h-2 w-2 rounded-full ${
healthCheck.data ? "bg-green-500" : "bg-red-500" healthCheck.data ? "bg-green-500" : "bg-red-500"
@@ -55,46 +72,10 @@ export default function Home() {
? "Connected" ? "Connected"
: "Disconnected"} : "Disconnected"}
</span> </span>
{{/if}}
</div> </div>
</section> </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>
</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" "check-types": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-query-devtools": "^5.69.0",
"@tanstack/react-router-devtools": "^1.114.27", "@tanstack/react-router-devtools": "^1.114.27",
"@tanstack/router-plugin": "^1.114.27", "@tanstack/router-plugin": "^1.114.27",
"@types/node": "^22.13.13", "@types/node": "^22.13.13",
@@ -30,7 +29,6 @@
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@tanstack/react-form": "^1.0.5", "@tanstack/react-form": "^1.0.5",
"@tailwindcss/vite": "^4.0.15", "@tailwindcss/vite": "^4.0.15",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-router": "^1.114.25", "@tanstack/react-router": "^1.114.25",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.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 { RouterProvider, createRouter } from "@tanstack/react-router";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Loader from "./components/loader"; import Loader from "./components/loader";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { QueryClientProvider } from "@tanstack/react-query";
import { orpc, queryClient } from "./utils/orpc"; import { orpc, queryClient } from "./utils/orpc";
{{/if}} {{/if}}
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
import { QueryClientProvider } from "@tanstack/react-query";
import { queryClient, trpc } from "./utils/trpc"; import { queryClient, trpc } from "./utils/trpc";
{{/if}} {{/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")}} {{#if (eq api "orpc")}}
const router = createRouter({ const router = createRouter({
@@ -36,8 +42,18 @@ const router = createRouter({
}, },
}); });
{{/if}} {{/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" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router; router: typeof router;

View File

@@ -38,6 +38,9 @@ export interface RouterAppContext {
queryClient: QueryClient; queryClient: QueryClient;
} }
{{/if}} {{/if}}
{{#if (eq backend "convex")}}
export interface RouterAppContext {}
{{/if}}
export const Route = createRootRouteWithContext<RouterAppContext>()({ export const Route = createRootRouteWithContext<RouterAppContext>()({
component: RootComponent, component: RootComponent,
@@ -107,3 +110,23 @@ 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")}} {{#if (eq api "orpc")}}
import { orpc } from "@/utils/orpc"; import { orpc } from "@/utils/orpc";
import { useQuery } from "@tanstack/react-query";
{{/if}} {{/if}}
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
import { trpc } from "@/utils/trpc"; import { trpc } from "@/utils/trpc";
{{/if}}
import { useQuery } from "@tanstack/react-query"; 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("/")({ export const Route = createFileRoute("/")({
component: HomeComponent, component: HomeComponent,
@@ -34,6 +39,9 @@ function HomeComponent() {
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
const healthCheck = useQuery(trpc.healthCheck.queryOptions()); const healthCheck = useQuery(trpc.healthCheck.queryOptions());
{{/if}} {{/if}}
{{#if (eq backend "convex")}}
const healthCheck = useQuery(api.healthCheck.get);
{{/if}}
return ( return (
<div className="container mx-auto max-w-3xl px-4 py-2"> <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"> <section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2> <h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{{#if (or (eq api "orpc") (eq api "trpc"))}}
<div <div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`} className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/> />
@@ -52,46 +61,22 @@ function HomeComponent() {
? "Connected" ? "Connected"
: "Disconnected"} : "Disconnected"}
</span> </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> </div>
</section> </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>
</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 { import {
QueryCache, QueryCache,
QueryClient, QueryClient,
@@ -17,7 +27,45 @@ import { TRPCProvider } from "./utils/trpc";
{{#if (eq api "orpc")}} {{#if (eq api "orpc")}}
import { orpc, ORPCContext, queryClient } from "./utils/orpc"; import { orpc, ORPCContext, queryClient } from "./utils/orpc";
{{/if}} {{/if}}
{{/if}}
{{#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")}} {{#if (eq api "trpc")}}
export const queryClient = new QueryClient({ export const queryClient = new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
@@ -38,11 +86,7 @@ export const queryClient = new QueryClient({
const trpcClient = createTRPCClient<AppRouter>({ const trpcClient = createTRPCClient<AppRouter>({
links: [ links: [
httpBatchLink({ httpBatchLink({
{{#if (includes frontend 'next')}}
url: `${process.env.NEXT_PUBLIC_SERVER_URL}/trpc`,
{{else}}
url: `${import.meta.env.VITE_SERVER_URL}/trpc`, url: `${import.meta.env.VITE_SERVER_URL}/trpc`,
{{/if}}
{{#if auth}} {{#if auth}}
fetch(url, options) { fetch(url, options) {
return fetch(url, { return fetch(url, {
@@ -61,7 +105,6 @@ const trpc = createTRPCOptionsProxy({
}); });
{{/if}} {{/if}}
export const createRouter = () => { export const createRouter = () => {
const router = createTanstackRouter({ const router = createTanstackRouter({
routeTree, routeTree,
@@ -93,6 +136,7 @@ export const createRouter = () => {
return router; return router;
}; };
{{/if}}
// Register the router instance for type safety // Register the router instance for type safety
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {

View File

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

View File

@@ -1,3 +1,9 @@
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")}} {{#if (eq api "trpc")}}
import { useTRPC } from "@/utils/trpc"; import { useTRPC } from "@/utils/trpc";
{{/if}} {{/if}}
@@ -5,7 +11,7 @@ import { useTRPC } from "@/utils/trpc";
import { useORPC } from "@/utils/orpc"; import { useORPC } from "@/utils/orpc";
{{/if}} {{/if}}
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router"; {{/if}}
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: HomeComponent, component: HomeComponent,
@@ -28,6 +34,9 @@ const TITLE_TEXT = `
`; `;
function HomeComponent() { function HomeComponent() {
{{#if (eq backend "convex")}}
const healthCheck = useSuspenseQuery(convexQuery(api.healthCheck.get, {}));
{{else}}
{{#if (eq api "trpc")}} {{#if (eq api "trpc")}}
const trpc = useTRPC(); const trpc = useTRPC();
const healthCheck = useQuery(trpc.healthCheck.queryOptions()); const healthCheck = useQuery(trpc.healthCheck.queryOptions());
@@ -36,6 +45,7 @@ function HomeComponent() {
const orpc = useORPC(); const orpc = useORPC();
const healthCheck = useQuery(orpc.healthCheck.queryOptions()); const healthCheck = useQuery(orpc.healthCheck.queryOptions());
{{/if}} {{/if}}
{{/if}}
return ( return (
<div className="container mx-auto max-w-3xl px-4 py-2"> <div className="container mx-auto max-w-3xl px-4 py-2">
@@ -44,6 +54,18 @@ function HomeComponent() {
<section className="rounded-lg border p-4"> <section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2> <h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2"> <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 <div
className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`} className={`h-2 w-2 rounded-full ${healthCheck.data ? "bg-green-500" : "bg-red-500"}`}
/> />
@@ -54,46 +76,10 @@ function HomeComponent() {
? "Connected" ? "Connected"
: "Disconnected"} : "Disconnected"}
</span> </span>
{{/if}}
</div> </div>
</section> </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>
</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>
);
}