Skip to content

feat: add Vue Router support #390

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pnpm create better-t-stack@latest

- **Zero-config setup** with interactive CLI wizard
- **End-to-end type safety** from database to frontend via tRPC
- **Modern stack** with React, Hono/Elysia, and TanStack libraries
- **Modern stack** with React, Vue, Nuxt, Svelte, Solid, and more
- **Multi-platform** supporting web, mobile (Expo), and desktop applications
- **Database flexibility** with SQLite (Turso) or PostgreSQL options
- **ORM choice** between Drizzle or Prisma
Expand Down
4 changes: 2 additions & 2 deletions apps/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Follow the prompts to configure your project or use the `--yes` flag for default
| Category | Options |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **TypeScript** | End-to-end type safety across all parts of your application |
| **Frontend** | • React with TanStack Router<br>• React with React Router<br>• React with TanStack Start (SSR)<br>• Next.js<br>• SvelteKit<br>• Nuxt (Vue)<br>• SolidJS<br>• React Native with NativeWind (via Expo)<br>• React Native with Unistyles (via Expo)<br>• None |
| **Frontend** | • React with TanStack Router<br>• React with React Router<br>• React with TanStack Start (SSR)<br>• Next.js<br>• Vue with Vue Router<br>• Nuxt (Vue)<br>• SvelteKit<br>• SolidJS<br>• React Native with NativeWind (via Expo)<br>• React Native with Unistyles (via Expo)<br>• None |
| **Backend** | • Hono<br>• Express<br>• Elysia<br>• Next.js API routes<br>• Convex<br>• Fastify<br>• None |
| **API Layer** | • tRPC (type-safe APIs)<br>• oRPC (OpenAPI-compatible type-safe APIs)<br>• None |
| **Runtime** | • Bun<br>• Node.js<br>• Cloudflare Workers<br>• None |
Expand All @@ -57,7 +57,7 @@ Options:
--orm <type> ORM type (none, drizzle, prisma, mongoose)
--auth Include authentication
--no-auth Exclude authentication
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, svelte, solid, native-nativewind, native-unistyles, none)
--frontend <types...> Frontend types (tanstack-router, react-router, tanstack-start, next, nuxt, vue-router, svelte, solid, native-nativewind, native-unistyles, none)
--addons <types...> Additional addons (pwa, tauri, starlight, biome, husky, turborepo, none)
Comment on lines +60 to 61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Flag outdated alias: native is not a valid --frontend value

The option list correctly shows native-nativewind and native-unistyles, but the sample command later uses --frontend … native (singular).
Leaving the alias will cause runtime validation errors.

Search/replace the sample invocation:

-npx create-better-t-stack my-app --frontend tanstack-router native
+npx create-better-t-stack my-app --frontend tanstack-router native-nativewind

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/cli/README.md around lines 54 to 55, the sample command uses the
outdated alias `native` for the `--frontend` option, which is invalid and causes
runtime errors. Update the sample invocation to replace `native` with one of the
valid options shown in the list, such as `native-nativewind` or
`native-unistyles`, to ensure consistency and prevent validation errors.

--examples <types...> Examples to include (todo, ai, none)
--git Initialize git repository
Expand Down
5 changes: 3 additions & 2 deletions apps/cli/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ export const dependencyVersionMap = {
export type AvailableDependencies = keyof typeof dependencyVersionMap;

export const ADDON_COMPATIBILITY: Record<Addons, readonly Frontend[]> = {
pwa: ["tanstack-router", "react-router", "solid", "next"],
tauri: ["tanstack-router", "react-router", "nuxt", "svelte", "solid"],
pwa: ["tanstack-router", "react-router", "solid", "next", "vue-router"],
tauri: ["tanstack-router", "react-router", "nuxt", "vue-router", "svelte", "solid"],
biome: [],
husky: [],
turborepo: [],
Expand All @@ -147,6 +147,7 @@ export const WEB_FRAMEWORKS: readonly Frontend[] = [
"tanstack-start",
"next",
"nuxt",
"vue-router",
"svelte",
"solid",
];
45 changes: 43 additions & 2 deletions apps/cli/src/helpers/project-generation/template-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,15 @@ export async function setupFrontendTemplates(
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasVueRouterWeb = context.frontend.includes("vue-router");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasSolidWeb = context.frontend.includes("solid");
const hasNativeWind = context.frontend.includes("native-nativewind");
const hasUnistyles = context.frontend.includes("native-unistyles");
const _hasNative = hasNativeWind || hasUnistyles;
const isConvex = context.backend === "convex";

if (hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) {
if (hasReactWeb || hasNuxtWeb || hasVueRouterWeb || hasSvelteWeb || hasSolidWeb) {
const webAppDir = path.join(projectDir, "apps/web");
await fs.ensureDir(webAppDir);

Expand Down Expand Up @@ -140,6 +141,23 @@ export async function setupFrontendTemplates(
} else {
}
}
} else if (hasVueRouterWeb) {
const vueRouterBaseDir = path.join(PKG_ROOT, "templates/frontend/vue/vue-router");
if (await fs.pathExists(vueRouterBaseDir)) {
await processAndCopyFiles("**/*", vueRouterBaseDir, webAppDir, context);
} else {
}

if (!isConvex && context.api !== "none") {
const apiWebVueDir = path.join(
PKG_ROOT,
`templates/api/${context.api}/web/vue`,
);
if (await fs.pathExists(apiWebVueDir)) {
await processAndCopyFiles("**/*", apiWebVueDir, webAppDir, context);
} else {
}
}
} else if (hasSvelteWeb) {
const svelteBaseDir = path.join(PKG_ROOT, "templates/frontend/svelte");
if (await fs.pathExists(svelteBaseDir)) {
Expand Down Expand Up @@ -372,6 +390,7 @@ export async function setupAuthTemplate(
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasVueRouterWeb = context.frontend.includes("vue-router");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasSolidWeb = context.frontend.includes("solid");
const hasNativeWind = context.frontend.includes("native-nativewind");
Expand Down Expand Up @@ -434,7 +453,7 @@ export async function setupAuthTemplate(
}

if (
(hasReactWeb || hasNuxtWeb || hasSvelteWeb || hasSolidWeb) &&
(hasReactWeb || hasNuxtWeb || hasVueRouterWeb || hasSvelteWeb || hasSolidWeb) &&
webAppDirExists
) {
if (hasReactWeb) {
Expand Down Expand Up @@ -473,6 +492,14 @@ export async function setupAuthTemplate(
await processAndCopyFiles("**/*", authWebNuxtSrc, webAppDir, context);
} else {
}
} else if (hasVueRouterWeb) {
if (context.api !== "none") {
const authWebVueSrc = path.join(PKG_ROOT, "templates/auth/web/vue");
if (await fs.pathExists(authWebVueSrc)) {
await processAndCopyFiles("**/*", authWebVueSrc, webAppDir, context);
} else {
}
}
} else if (hasSvelteWeb) {
const authWebSvelteSrc = path.join(PKG_ROOT, "templates/auth/web/svelte");
if (await fs.pathExists(authWebSvelteSrc)) {
Expand Down Expand Up @@ -588,6 +615,7 @@ export async function setupExamplesTemplate(
["tanstack-router", "react-router", "tanstack-start", "next"].includes(f),
);
const hasNuxtWeb = context.frontend.includes("nuxt");
const hasVueRouterWeb = context.frontend.includes("vue-router");
const hasSvelteWeb = context.frontend.includes("svelte");
const hasSolidWeb = context.frontend.includes("solid");

Expand Down Expand Up @@ -716,6 +744,18 @@ export async function setupExamplesTemplate(
);
} else {
}
} else if (hasVueRouterWeb) {
const exampleWebVueSrc = path.join(exampleBaseDir, "web/vue");
if (await fs.pathExists(exampleWebVueSrc)) {
await processAndCopyFiles(
"**/*",
exampleWebVueSrc,
webAppDir,
context,
false,
);
} else {
}
} else if (hasSvelteWeb) {
const exampleWebSvelteSrc = path.join(exampleBaseDir, "web/svelte");
if (await fs.pathExists(exampleWebSvelteSrc)) {
Expand Down Expand Up @@ -863,6 +903,7 @@ export async function setupDeploymentTemplates(
solid: "solid",
next: "react/next",
nuxt: "nuxt",
"vue-router": "vue",
svelte: "svelte",
};

Expand Down
7 changes: 6 additions & 1 deletion apps/cli/src/prompts/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export async function getFrontendChoice(
label: "Nuxt",
hint: "The Progressive Web Framework for Vue.js",
},
{
value: "vue-router" as const,
label: "Vue Router",
hint: "The official router for Vue.js single-page applications",
},
{
value: "svelte" as const,
label: "Svelte",
Expand All @@ -75,7 +80,7 @@ export async function getFrontendChoice(

const webOptions = allWebOptions.filter((option) => {
if (backend === "convex") {
return option.value !== "solid";
return option.value !== "nuxt" && option.value !== "solid" && option.value !== "vue-router";
}
return true;
});
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const FrontendSchema = z
"tanstack-start",
"next",
"nuxt",
"vue-router",
"native-nativewind",
"native-unistyles",
"svelte",
Expand Down
9 changes: 5 additions & 4 deletions apps/cli/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export function processAndValidateFlags(

if (webFrontends.length > 1) {
consola.fatal(
"Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, svelte, solid",
"Cannot select multiple web frameworks. Choose only one of: tanstack-router, tanstack-start, react-router, next, nuxt, vue-router, svelte, solid",
);
process.exit(1);
}
Expand Down Expand Up @@ -205,7 +205,7 @@ export function processAndValidateFlags(

if (providedFlags.has("frontend") && options.frontend) {
const incompatibleFrontends = options.frontend.filter(
(f) => f === "solid",
(f) => f === "nuxt" || f === "solid" || f === "vue-router",
);
if (incompatibleFrontends.length > 0) {
consola.fatal(
Expand Down Expand Up @@ -632,9 +632,10 @@ export function validateConfigCompatibility(config: Partial<ProjectConfig>) {
process.exit(1);
}

if (config.examples.includes("ai") && includesSolid) {
const includesVueRouter = effectiveFrontend?.includes("vue-router");
if (config.examples.includes("ai") && (includesSolid || includesVueRouter)) {
consola.fatal(
"The 'ai' example is not compatible with the Solid frontend.",
`The 'ai' example is not compatible with the ${includesSolid ? "Solid" : "Vue Router"} frontend.`,
);
process.exit(1);
}
Expand Down
25 changes: 25 additions & 0 deletions apps/cli/templates/api/orpc/web/vue/src/lib/api.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createORPCClient, createORPCFetchHandler } from "@orpc/client";
import { ref } from "vue";
import type { ORPCContext, orpc } from "../../../../server/src/lib/orpc";

export const getAuthToken = () => {
if (typeof window === "undefined") return undefined;
return localStorage.getItem("auth-token") ?? undefined;
};

export const authToken = ref(getAuthToken());

const fetchHandler = createORPCFetchHandler({
baseURL: import.meta.env.VITE_SERVER_URL ?? "http://localhost:5000/api",
headers: () => {
const token = authToken.value;
return token ? { Authorization: `Bearer ${token}` } : undefined;
},
});

export const { api } = createORPCClient<
typeof orpc,
ORPCContext
>({
async: fetchHandler,
});
20 changes: 20 additions & 0 deletions apps/cli/templates/api/trpc/web/vue/src/lib/api.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createTRPCProxyClient, httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import type { AppRouter } from "../../../../server/src/router/root";

export const api = createTRPCProxyClient<AppRouter>({
links: [
httpBatchLink({
transformer: superjson,
url: import.meta.env.VITE_SERVER_URL ?? "http://localhost:5000/api/trpc",
async headers() {
const headers: Record<string, string> = {};
const token = localStorage.getItem("auth-token");
if (token) {
headers.authorization = `Bearer ${token}`;
}
return headers;
},
}),
],
});
89 changes: 89 additions & 0 deletions apps/cli/templates/auth/web/vue/src/lib/auth.ts.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { api{{#if (eq api "trpc")}}, authToken{{/if}} } from './api'

export interface User {
id: string
email: string
name: string | null
}

export interface Session {
user: User
expiresAt: Date
}

const currentUser = ref<User | null>(null)
const isLoading = ref(true)
Comment on lines +16 to +17
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Move reactive state inside the composable function.

The currentUser and isLoading refs are defined outside the useAuth function, making them global singletons. This could cause issues if multiple components use this composable expecting independent state instances.

Move the reactive state inside the composable function:

+export function useAuth() {
+  const currentUser = ref<User | null>(null)
+  const isLoading = ref(true)
+  const router = useRouter()
-const currentUser = ref<User | null>(null)
-const isLoading = ref(true)

-export function useAuth() {
-  const router = useRouter()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currentUser = ref<User | null>(null)
const isLoading = ref(true)
export function useAuth() {
const currentUser = ref<User | null>(null)
const isLoading = ref(true)
const router = useRouter()
// ...rest of composable implementation...
}
🤖 Prompt for AI Agents
In apps/cli/templates/auth/web/vue/src/lib/auth.ts.hbs around lines 16 to 17,
the reactive refs currentUser and isLoading are declared outside the useAuth
function, making them global singletons shared across all components. To fix
this, move the declarations of currentUser and isLoading inside the useAuth
function so each call to useAuth returns independent reactive state instances.


export function useAuth() {
const router = useRouter()

const isAuthenticated = computed(() => !!currentUser.value)

const initialize = async () => {
try {
const token = localStorage.getItem('auth-token')
if (!token) {
isLoading.value = false
return
}

const { data } = await api.auth.getSession{{#if (eq api "orpc")}}(){{/if}}
if (data) {
currentUser.value = data.user
{{#if (eq api "orpc")}} authToken.value = token{{/if}}
}
} catch (error) {
localStorage.removeItem('auth-token')
{{#if (eq api "orpc")}} authToken.value = null{{/if}}
} finally {
isLoading.value = false
}
}

const login = async (email: string, password: string) => {
const { data } = await api.auth.login{{#if (eq api "orpc")}}({email, password}){{else}}({ email, password }){{/if}}

if (data?.token) {
localStorage.setItem('auth-token', data.token)
{{#if (eq api "orpc")}} authToken.value = data.token{{/if}}
currentUser.value = data.user
router.push('/')
}
}
Comment on lines +45 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for authentication failures.

The login function should handle potential authentication failures and provide user feedback.

Add error handling:

 const login = async (email: string, password: string) => {
+  try {
     const { data } = await api.auth.login{{#if (eq api "orpc")}}({email, password}){{else}}({ email, password }){{/if}}
     
     if (data?.token) {
       localStorage.setItem('auth-token', data.token)
 {{#if (eq api "orpc")}}      authToken.value = data.token{{/if}}
       currentUser.value = data.user
       router.push('/')
+    } else {
+      throw new Error('Authentication failed')
     }
+  } catch (error) {
+    console.error('Login error:', error)
+    throw error
+  }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const login = async (email: string, password: string) => {
const { data } = await api.auth.login{{#if (eq api "orpc")}}({email, password}){{else}}({ email, password }){{/if}}
if (data?.token) {
localStorage.setItem('auth-token', data.token)
{{#if (eq api "orpc")}} authToken.value = data.token{{/if}}
currentUser.value = data.user
router.push('/')
}
}
const login = async (email: string, password: string) => {
try {
const { data } = await api.auth.login{{#if (eq api "orpc")}}({email, password}){{else}}({ email, password }){{/if}}
if (data?.token) {
localStorage.setItem('auth-token', data.token)
{{#if (eq api "orpc")}} authToken.value = data.token{{/if}}
currentUser.value = data.user
router.push('/')
} else {
throw new Error('Authentication failed')
}
} catch (error) {
console.error('Login error:', error)
throw error
}
}
🤖 Prompt for AI Agents
In apps/cli/templates/auth/web/vue/src/lib/auth.ts.hbs around lines 45 to 54,
the login function lacks error handling for authentication failures. Wrap the
API call in a try-catch block to catch any errors during login, and handle them
appropriately by providing user feedback or logging the error. Ensure that the
function gracefully handles failed login attempts without breaking the app flow.


const signup = async (email: string, password: string, name: string) => {
const { data } = await api.auth.signup{{#if (eq api "orpc")}}({email, password, name}){{else}}({ email, password, name }){{/if}}

if (data?.token) {
localStorage.setItem('auth-token', data.token)
{{#if (eq api "orpc")}} authToken.value = data.token{{/if}}
currentUser.value = data.user
router.push('/')
}
}
Comment on lines +56 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add error handling for signup failures.

Similar to the login function, signup should handle potential failures.

Apply the same error handling pattern as suggested for the login function.

🤖 Prompt for AI Agents
In apps/cli/templates/auth/web/vue/src/lib/auth.ts.hbs around lines 56 to 65,
the signup function lacks error handling for failures. Add a try-catch block
around the API call to catch any errors during signup, and handle them
appropriately, such as logging the error or showing a user-friendly message.
This should follow the same error handling pattern used in the login function to
ensure consistent behavior.


const logout = async () => {
try {
await api.auth.logout{{#if (eq api "orpc")}}(){{/if}}
} catch (error) {
console.error('Logout error:', error)
} finally {
localStorage.removeItem('auth-token')
{{#if (eq api "orpc")}} authToken.value = null{{/if}}
currentUser.value = null
router.push('/login')
}
}

return {
user: currentUser,
isAuthenticated,
isLoading,
initialize,
login,
signup,
logout,
}
}
24 changes: 24 additions & 0 deletions apps/cli/templates/deploy/web/vue/_worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export default {
async fetch(request, env) {
const url = new URL(request.url);

// Serve static assets
try {
// Check if the path has a file extension
const hasExtension = url.pathname.split('/').pop().includes('.');

if (hasExtension) {
// Try to serve the static asset
return await env.ASSETS.fetch(request);
}

// For paths without extensions, serve index.html (SPA routing)
const indexRequest = new Request(new URL('/index.html', request.url).toString(), request);
return await env.ASSETS.fetch(indexRequest);
} catch (e) {
// If the asset doesn't exist, serve index.html
const indexRequest = new Request(new URL('/index.html', request.url).toString(), request);
return await env.ASSETS.fetch(indexRequest);
}
},
};
8 changes: 8 additions & 0 deletions apps/cli/templates/deploy/web/vue/package.json.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"scripts": {
"deploy": "wrangler pages deploy dist"
},
"devDependencies": {
"wrangler": "^3.101.0"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix version mismatch with constants.ts.

The wrangler version here (^3.101.0) doesn't match the version defined in constants.ts (^4.23.0). This inconsistency could lead to deployment issues.

Apply this diff to use the consistent version:

-    "wrangler": "^3.101.0"
+    "wrangler": "^4.23.0"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"wrangler": "^3.101.0"
- "wrangler": "^3.101.0"
+ "wrangler": "^4.23.0"
🤖 Prompt for AI Agents
In apps/cli/templates/deploy/web/vue/package.json.hbs at line 6, update the
wrangler version from "^3.101.0" to "^4.23.0" to match the version defined in
constants.ts. This ensures consistency and prevents deployment issues caused by
version mismatches.

}
}
Loading