feat(cli): add nuxt + convex support (#458)

This commit is contained in:
Aman Varshney
2025-08-02 11:50:00 +05:30
committed by GitHub
parent cef5840852
commit 430fa41abd
19 changed files with 272 additions and 144 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
Add Nuxt + Convex support

View File

@@ -101,11 +101,17 @@ export const dependencyVersionMap = {
"@trpc/server": "^11.4.2", "@trpc/server": "^11.4.2",
"@trpc/client": "^11.4.2", "@trpc/client": "^11.4.2",
convex: "^1.25.0", convex: "^1.25.4",
"@convex-dev/react-query": "^0.0.0-alpha.8", "@convex-dev/react-query": "^0.0.0-alpha.8",
"convex-svelte": "^0.0.11", "convex-svelte": "^0.0.11",
"convex-nuxt": "0.1.5",
"convex-vue": "^0.1.5",
"@tanstack/svelte-query": "^5.74.4", "@tanstack/svelte-query": "^5.74.4",
"@tanstack/vue-query-devtools": "^5.83.0",
"@tanstack/vue-query": "^5.83.0",
"@tanstack/react-query-devtools": "^5.80.5", "@tanstack/react-query-devtools": "^5.80.5",
"@tanstack/react-query": "^5.80.5", "@tanstack/react-query": "^5.80.5",

View File

@@ -345,5 +345,5 @@ function getBunWebNativeWarning(): string {
} }
function getWorkersDeployInstructions(runCmd?: string): string { function getWorkersDeployInstructions(runCmd?: string): string {
return `\n${pc.bold("Deploy frontend to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd || "bun run"} deploy`}`; return `\n${pc.bold("Deploy frontend to Cloudflare Workers:")}\n${pc.cyan("•")} Deploy: ${`cd apps/web && ${runCmd} run deploy`}`;
} }

View File

@@ -75,6 +75,8 @@ export async function setupApi(config: ProjectConfig) {
if (api === "orpc") { if (api === "orpc") {
await addPackageDependency({ await addPackageDependency({
dependencies: [ dependencies: [
"@tanstack/vue-query",
"@tanstack/vue-query-devtools",
"@orpc/tanstack-query", "@orpc/tanstack-query",
"@orpc/client", "@orpc/client",
"@orpc/server", "@orpc/server",
@@ -219,7 +221,10 @@ export async function setupApi(config: ProjectConfig) {
if (hasSvelteWeb) { if (hasSvelteWeb) {
webDepsToAdd.push("convex-svelte"); webDepsToAdd.push("convex-svelte");
} }
if (hasNuxtWeb) {
webDepsToAdd.push("convex-nuxt");
webDepsToAdd.push("convex-vue");
}
await addPackageDependency({ await addPackageDependency({
dependencies: webDepsToAdd, dependencies: webDepsToAdd,
projectDir: webDir, projectDir: webDir,

View File

@@ -9,9 +9,7 @@ export async function getBackendFrameworkChoice(
): Promise<Backend> { ): Promise<Backend> {
if (backendFramework !== undefined) return backendFramework; if (backendFramework !== undefined) return backendFramework;
const hasIncompatibleFrontend = frontends?.some( const hasIncompatibleFrontend = frontends?.some((f) => f === "solid");
(f) => f === "nuxt" || f === "solid",
);
const backendOptions: Array<{ const backendOptions: Array<{
value: Backend; value: Backend;
@@ -59,15 +57,10 @@ export async function getBackendFrameworkChoice(
hint: "No backend server", hint: "No backend server",
}); });
let initialValue = DEFAULT_CONFIG.backend;
if (hasIncompatibleFrontend && initialValue === "convex") {
initialValue = "hono";
}
const response = await select<Backend>({ const response = await select<Backend>({
message: "Select backend", message: "Select backend",
options: backendOptions, options: backendOptions,
initialValue, initialValue: DEFAULT_CONFIG.backend,
}); });
if (isCancel(response)) { if (isCancel(response)) {

View File

@@ -75,7 +75,7 @@ export async function getFrontendChoice(
const webOptions = allWebOptions.filter((option) => { const webOptions = allWebOptions.filter((option) => {
if (backend === "convex") { if (backend === "convex") {
return option.value !== "nuxt" && option.value !== "solid"; return option.value !== "solid";
} }
return true; return true;
}); });

View File

@@ -205,7 +205,7 @@ export function processAndValidateFlags(
if (providedFlags.has("frontend") && options.frontend) { if (providedFlags.has("frontend") && options.frontend) {
const incompatibleFrontends = options.frontend.filter( const incompatibleFrontends = options.frontend.filter(
(f) => f === "nuxt" || f === "solid", (f) => f === "solid",
); );
if (incompatibleFrontends.length > 0) { if (incompatibleFrontends.length > 0) {
consola.fatal( consola.fatal(

View File

@@ -9,9 +9,9 @@
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"devDependencies": { "devDependencies": {
"typescript": "^5.8.3" "typescript": "^5.9.2"
}, },
"dependencies": { "dependencies": {
"convex": "^1.25.0" "convex": "^1.25.4"
} }
} }

View File

@@ -1,108 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
const { $orpc } = useNuxtApp()
const newTodoText = ref('')
const queryClient = useQueryClient()
const todos = useQuery($orpc.todo.getAll.queryOptions())
const createMutation = useMutation($orpc.todo.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries()
newTodoText.value = ''
}
}))
const toggleMutation = useMutation($orpc.todo.toggle.mutationOptions({
onSuccess: () => queryClient.invalidateQueries()
}))
const deleteMutation = useMutation($orpc.todo.delete.mutationOptions({
onSuccess: () => queryClient.invalidateQueries()
}))
function handleAddTodo() {
if (newTodoText.value.trim()) {
createMutation.mutate({ text: newTodoText.value })
}
}
function handleToggleTodo(id: number, completed: boolean) {
toggleMutation.mutate({ id, completed: !completed })
}
function handleDeleteTodo(id: number) {
deleteMutation.mutate({ id })
}
</script>
<template>
<div class="mx-auto w-full max-w-md py-10">
<UCard>
<template #header>
<div>
<div class="text-xl font-bold">Todo List</div>
<div class="text-muted text-sm">Manage your tasks efficiently</div>
</div>
</template>
<form @submit.prevent="handleAddTodo" class="mb-6 flex items-center gap-2">
<UInput
v-model="newTodoText"
placeholder="Add a new task..."
autocomplete="off"
class="w-full"
/>
<UButton
type="submit"
icon="i-lucide-plus"
>
Add
</UButton>
</form>
<div v-if="todos.status.value === 'pending'" class="flex justify-center py-4">
<UIcon name="i-lucide-loader-2" class="animate-spin w-6 h-6" />
</div>
<p v-else-if="todos.status.value === 'error'" class="py-4 text-center text-red-500">
Error: {{ todos.error.value?.message || 'Failed to load todos' }}
</p>
<p v-else-if="todos.data.value?.length === 0" class="py-4 text-center">
No todos yet. Add one above!
</p>
<ul v-else class="space-y-2">
<li
v-for="todo in todos.data.value"
:key="todo.id"
class="flex items-center justify-between rounded-md border p-2"
>
<div class="flex items-center gap-2">
<UCheckbox
:model-value="todo.completed"
@update:model-value="() => handleToggleTodo(todo.id, todo.completed)"
:id="`todo-${todo.id}`"
/>
<label
:for="`todo-${todo.id}`"
:class="{ 'line-through text-muted': todo.completed }"
class="cursor-pointer"
>
{{ todo.text }}
</label>
</div>
<UButton
color="neutral"
variant="ghost"
size="sm"
square
@click="handleDeleteTodo(todo.id)"
aria-label="Delete todo"
icon="i-lucide-trash-2"
/>
</li>
</ul>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,195 @@
<script setup lang="ts">
import { ref } from 'vue'
{{#if (eq backend "convex")}}
import { api } from "@{{ projectName }}/backend/convex/_generated/api";
import type { Id } from "@{{ projectName }}/backend/convex/_generated/dataModel";
import { useConvexMutation, useConvexQuery } from "convex-vue";
const { data, error, isPending } = useConvexQuery(api.todos.getAll, {});
const newTodoText = ref("");
const { mutate: createTodo, isPending: isCreatePending } = useConvexMutation(api.todos.create);
const { mutate: toggleTodo } = useConvexMutation(api.todos.toggle);
const { mutate: deleteTodo, error: deleteError } = useConvexMutation(
api.todos.deleteTodo,
);
function handleAddTodo() {
const text = newTodoText.value.trim();
if (!text) return;
createTodo({ text });
newTodoText.value = "";
}
function handleToggleTodo(id: Id<"todos">, completed: boolean) {
toggleTodo({ id, completed: !completed });
}
function handleDeleteTodo(id: Id<"todos">) {
deleteTodo({ id });
}
{{else}}
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
const { $orpc } = useNuxtApp()
const newTodoText = ref('')
const queryClient = useQueryClient()
const todos = useQuery($orpc.todo.getAll.queryOptions())
const createMutation = useMutation($orpc.todo.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries()
newTodoText.value = ''
}
}))
const toggleMutation = useMutation($orpc.todo.toggle.mutationOptions({
onSuccess: () => queryClient.invalidateQueries()
}))
const deleteMutation = useMutation($orpc.todo.delete.mutationOptions({
onSuccess: () => queryClient.invalidateQueries()
}))
function handleAddTodo() {
if (newTodoText.value.trim()) {
createMutation.mutate({ text: newTodoText.value })
}
}
function handleToggleTodo(id: number, completed: boolean) {
toggleMutation.mutate({ id, completed: !completed })
}
function handleDeleteTodo(id: number) {
deleteMutation.mutate({ id })
}
{{/if}}
</script>
<template>
<div class="mx-auto w-full max-w-md py-10">
<UCard>
<template #header>
<div>
<div class="text-xl font-bold">Todo List</div>
<div class="text-muted text-sm">Manage your tasks efficiently</div>
</div>
</template>
<form @submit.prevent="handleAddTodo" class="mb-6 flex items-center gap-2">
<UInput
v-model="newTodoText"
placeholder="Add a new task..."
autocomplete="off"
class="w-full"
{{#if (eq backend "convex")}}
:disabled="isCreatePending"
{{/if}}
/>
<UButton
type="submit"
{{#if (eq backend "convex")}}
:disabled="isCreatePending || !newTodoText.trim()"
{{/if}}
>
{{#if (eq backend "convex")}}
<span v-if="isCreatePending">
<UIcon name="i-lucide-loader-2" class="animate-spin" />
</span>
<span v-else>Add</span>
{{else}}
Add
{{/if}}
</UButton>
</form>
{{#if (eq backend "convex")}}
<p v-if="error || deleteError" class="mb-4 text-red-500">
Error: \{{ error?.message || deleteError?.message }}
</p>
<div v-if="isPending" class="flex justify-center py-4">
<UIcon name="i-lucide-loader-2" class="animate-spin w-6 h-6" />
</div>
<p v-else-if="data?.length === 0" class="py-4 text-center">
No todos yet. Add one above!
</p>
<ul v-else-if="data" class="space-y-2">
<li
v-for="todo in data"
:key="todo._id"
class="flex items-center justify-between rounded-md border p-2"
>
<div class="flex items-center gap-2">
<UCheckbox
:model-value="todo.completed"
@update:model-value="() => handleToggleTodo(todo._id, todo.completed)"
:id="`todo-${todo._id}`"
/>
<label
:for="`todo-${todo._id}`"
:class="{ 'line-through text-muted': todo.completed }"
class="cursor-pointer"
>
\{{ todo.text }}
</label>
</div>
<UButton
color="neutral"
variant="ghost"
size="sm"
square
@click="handleDeleteTodo(todo._id)"
aria-label="Delete todo"
icon="i-lucide-trash-2"
/>
</li>
</ul>
{{else}}
<div v-if="todos.status.value === 'pending'" class="flex justify-center py-4">
<UIcon name="i-lucide-loader-2" class="animate-spin w-6 h-6" />
</div>
<p v-else-if="todos.status.value === 'error'" class="py-4 text-center text-red-500">
Error: \{{ todos.error.value?.message || 'Failed to load todos' }}
</p>
<p v-else-if="todos.data.value?.length === 0" class="py-4 text-center">
No todos yet. Add one above!
</p>
<ul v-else class="space-y-2">
<li
v-for="todo in todos.data.value"
:key="todo.id"
class="flex items-center justify-between rounded-md border p-2"
>
<div class="flex items-center gap-2">
<UCheckbox
:model-value="todo.completed"
@update:model-value="() => handleToggleTodo(todo.id, todo.completed)"
:id="`todo-${todo.id}`"
/>
<label
:for="`todo-${todo.id}`"
:class="{ 'line-through text-muted': todo.completed }"
class="cursor-pointer"
>
\{{ todo.text }}
</label>
</div>
<UButton
color="neutral"
variant="ghost"
size="sm"
square
@click="handleDeleteTodo(todo.id)"
aria-label="Delete todo"
icon="i-lucide-trash-2"
/>
</li>
</ul>
{{/if}}
</UCard>
</div>
</template>

View File

@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
{{#if (eq api "orpc")}}
import { VueQueryDevtools } from '@tanstack/vue-query-devtools' import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
{{/if}}
</script> </script>
<template> <template>
@@ -9,5 +11,7 @@ import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</UApp> </UApp>
{{#if (eq api "orpc")}}
<VueQueryDevtools /> <VueQueryDevtools />
{{/if}}
</template> </template>

View File

@@ -1,5 +1,4 @@
<script setup lang="ts"> <script setup lang="ts">
import { USeparator } from '#components';
import ModeToggle from './ModeToggle.vue' import ModeToggle from './ModeToggle.vue'
{{#if auth}} {{#if auth}}
import UserMenu from './UserMenu.vue' import UserMenu from './UserMenu.vue'

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
const colorMode = useColorMode() const colorMode = useColorMode()
const isDark = computed({ const isDark = computed({

View File

@@ -1,8 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
{{#unless (eq api "none")}} {{#if (eq backend "convex")}}
const { $orpc } = useNuxtApp() import { api } from "@{{ projectName }}/backend/convex/_generated/api";
import { useQuery } from '@tanstack/vue-query' import { useConvexQuery } from "convex-vue";
{{/unless}} {{else}}
{{#unless (eq api "none")}}
const { $orpc } = useNuxtApp()
import { useQuery } from '@tanstack/vue-query'
{{/unless}}
{{/if}}
const TITLE_TEXT = ` const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗ ██████╗ ███████╗████████╗████████╗███████╗██████╗
@@ -20,19 +25,34 @@ const TITLE_TEXT = `
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
`; `;
{{#unless (eq api "none")}} {{#if (eq backend "convex")}}
const healthCheck = useQuery($orpc.healthCheck.queryOptions()) const healthCheck = useConvexQuery(api.healthCheck.get, {});
{{/unless}} {{else}}
{{#unless (eq api "none")}}
const healthCheck = useQuery($orpc.healthCheck.queryOptions())
{{/unless}}
{{/if}}
</script> </script>
<template> <template>
<div class="container mx-auto max-w-3xl px-4 py-2"> <div class="container mx-auto max-w-3xl px-4 py-2">
<pre class="overflow-x-auto font-mono text-sm whitespace-pre-wrap">\{{ TITLE_TEXT }}</pre> <pre class="overflow-x-auto font-mono text-sm whitespace-pre-wrap">\{{ TITLE_TEXT }}</pre>
<div class="grid gap-6 mt-4"> <div class="grid gap-6 mt-4">
{{#unless (eq api "none")}}
<section class="rounded-lg border p-4"> <section class="rounded-lg border p-4">
<h2 class="mb-2 font-medium">API Status</h2> <h2 class="mb-2 font-medium">API Status</h2>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{{#if (eq backend "convex")}}
<span class="text-sm text-muted-foreground">
\{{
healthCheck === undefined
? "Checking..."
: healthCheck.data.value === "OK"
? "Connected"
: "Error"
}}
</span>
{{else}}
{{#unless (eq api "none")}}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div <div
class="w-2 h-2 rounded-full" class="w-2 h-2 rounded-full"
@@ -60,9 +80,10 @@ const healthCheck = useQuery($orpc.healthCheck.queryOptions())
</template> </template>
</span> </span>
</div> </div>
</div> {{/unless}}
{{/if}}
</div>
</section> </section>
{{/unless}}
</div> </div>
</div> </div>
</template> </template>

View File

@@ -2,12 +2,22 @@
export default defineNuxtConfig({ export default defineNuxtConfig({
compatibilityDate: 'latest', compatibilityDate: 'latest',
devtools: { enabled: true }, devtools: { enabled: true },
modules: ['@nuxt/ui'], modules: [
'@nuxt/ui'
{{#if (eq backend "convex")}},
'convex-nuxt'
{{/if}}
],
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
devServer: { devServer: {
port: 3001 port: 3001
}, },
ssr: false, ssr: false,
{{#if (eq backend "convex")}}
convex: {
url: process.env.NUXT_PUBLIC_CONVEX_URL,
},
{{/if}}
runtimeConfig: { runtimeConfig: {
public: { public: {
serverURL: process.env.NUXT_PUBLIC_SERVER_URL, serverURL: process.env.NUXT_PUBLIC_SERVER_URL,

View File

@@ -11,7 +11,6 @@
}, },
"dependencies": { "dependencies": {
"@nuxt/ui": "3.3.0", "@nuxt/ui": "3.3.0",
"@tanstack/vue-query": "^5.83.0",
"nuxt": "^4.0.2", "nuxt": "^4.0.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vue": "^3.5.18", "vue": "^3.5.18",
@@ -20,7 +19,6 @@
}, },
"devDependencies": { "devDependencies": {
"tailwindcss": "^4.1.11", "tailwindcss": "^4.1.11",
"@tanstack/vue-query-devtools": "^5.83.0",
"@iconify-json/lucide": "^1.2.57" "@iconify-json/lucide": "^1.2.57"
} }
} }

View File

@@ -233,7 +233,7 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
}); });
} }
} }
const incompatibleConvexFrontends = ["nuxt", "solid"]; const incompatibleConvexFrontends = ["solid"];
const originalWebFrontendLength = nextStack.webFrontend.length; const originalWebFrontendLength = nextStack.webFrontend.length;
nextStack.webFrontend = nextStack.webFrontend.filter( nextStack.webFrontend = nextStack.webFrontend.filter(
(f) => !incompatibleConvexFrontends.includes(f), (f) => !incompatibleConvexFrontends.includes(f),
@@ -241,16 +241,14 @@ const analyzeStackCompatibility = (stack: StackState): CompatibilityResult => {
if (nextStack.webFrontend.length !== originalWebFrontendLength) { if (nextStack.webFrontend.length !== originalWebFrontendLength) {
changed = true; changed = true;
notes.webFrontend.notes.push( notes.webFrontend.notes.push(
"Nuxt and Solid are not compatible with Convex backend and have been removed.", "Solid is not compatible with Convex backend and has been removed.",
);
notes.backend.notes.push(
"Convex backend is not compatible with Nuxt or Solid.",
); );
notes.backend.notes.push("Convex backend is not compatible with Solid.");
notes.webFrontend.hasIssue = true; notes.webFrontend.hasIssue = true;
notes.backend.hasIssue = true; notes.backend.hasIssue = true;
changes.push({ changes.push({
category: "convex", category: "convex",
message: "Removed incompatible web frontends (Nuxt, Solid)", message: "Removed incompatible web frontends (Solid)",
}); });
} }
if (nextStack.nativeFrontend[0] === "none") { if (nextStack.nativeFrontend[0] === "none") {