/app/* gated subtree routing
What changed
A minimal public homepage now ships at / (brand + Sign in / Create account buttons; "Go to app" CTA when authed).
All gated routes moved under /app/*:
| Old URL | New URL |
|---|---|
/dashboard | /app |
/settings/* | /app/settings/* |
/chat/* | /app/chat/* |
/activity | /app/activity |
The global middleware (app/middleware/auth.global.ts) now gates the entire /app/* subtree by URL prefix, instead of an allowlist of public paths.
Why
Policy lives in the URL and filesystem instead of a middleware allowlist. Adding a public page = drop a file at the top level. Adding a gated feature = drop a file under app/pages/app/. Structure tells the truth — there's no allowlist to forget when adding a public page, and no way to accidentally ship an authed feature without gating.
Matches the dominant pattern in modern app routing: Next.js (app) / (marketing) route groups, Stripe's /dashboard/*, Linear's /[workspace]/*.
Do I need to run this?
The simplest test:
app/pages/ in your project. If you see a dashboard/ folder, you're on the old structure and this upgrade applies to you. If you see an app/ folder instead (containing index.vue, chat/, settings/, etc.), you're already on the new structure — skip this upgrade.You can also check with git log -1 --format="%ad" — if your last template sync is before 2026-04-25 (or v0.1.0), you need to upgrade.
Upgrade prompt
Paste the entire block below into Claude Code, running from your project's root directory:
Migrate this Vue Starter fork to the new `/app/*` gated subtree routing convention.
**Goal:** public surface (homepage, marketing, auth, invite) sits at the top level. All gated routes (auth + team required) live under `/app/*`. The global middleware gates the entire `/app/*` subtree by URL prefix instead of an allowlist. Adding a public page = drop a file at the top level. Adding a gated feature = drop a file under `app/pages/app/`.
Use `git mv` for every file move to preserve history. Execute steps in order without asking me to confirm — work autonomously and report what you did at the end.
### 0. Bail-out check
If `app/pages/app/` already exists with files in it, the upgrade has already been run — stop and tell me.
### 1. Discover structure
- List `app/pages/` and classify each entry:
- **Template features** (always present in vanilla template): `dashboard/`, `chat.vue` + `chat/`, `settings.vue` + `settings/`, `activity/`
- **Public / forced flow** (stay at top level): `auth/`, `invite/`, `onboarding.vue`, `no-team.vue`, `index.vue`
- **Custom features** (everything else): record this list — you will move these in step 2 and rewrite their URL refs in step 5
- Run a discovery grep for every old path root used in the codebase:
```bash
grep -rEn '/(dashboard|settings|chat|activity)(/|"|'\''|`)' app/ server/ shared/ layers/ 2>/dev/null --include='*.ts' --include='*.vue' --include='*.md'
```
Plus the same grep for each custom feature you found.
### 2. Move pages under `app/pages/app/`
Template pages first:
```bash
mkdir -p app/pages/app/settings app/pages/app/chat app/pages/app/activity
git mv app/pages/dashboard/index.vue app/pages/app/index.vue
git mv app/pages/chat.vue app/pages/app/chat.vue
git mv app/pages/chat/index.vue app/pages/app/chat/index.vue
git mv 'app/pages/chat/[id].vue' 'app/pages/app/chat/[id].vue'
git mv app/pages/settings.vue app/pages/app/settings.vue
git mv app/pages/settings/*.vue app/pages/app/settings/
git mv app/pages/activity/index.vue app/pages/app/activity/index.vue
rmdir app/pages/dashboard app/pages/chat app/pages/settings app/pages/activity
```
Skip any source path that doesn't exist (your fork may have dropped or renamed something).
Then for each custom feature from step 1, mirror the move under `app/pages/app/`. Examples:
- `app/pages/projects/` → `app/pages/app/projects/`
- `app/pages/projects.vue` (parent route) → `app/pages/app/projects.vue`
- `app/pages/reports/[id].vue` → `app/pages/app/reports/[id].vue`
### 3. Replace `app/middleware/auth.global.ts` entirely with:
```ts
export default defineNuxtRouteMiddleware((to) => {
const user = useSupabaseUser();
const isAppRoute = to.path === "/app" || to.path.startsWith("/app/");
const isAuthRoute = to.path.startsWith("/auth/");
const isResetPassword = to.path === "/auth/reset-password";
const isInviteRoute = to.path.startsWith("/invite/");
const isOnboarding = to.path === "/onboarding";
const isNoTeam = to.path === "/no-team";
if (!user.value) {
if (isAppRoute || isOnboarding || isNoTeam) {
return navigateTo("/auth/login");
}
return;
}
const metadata = user.value.user_metadata ?? {};
const onboarded = metadata.onboarded === true;
const hasTeam = metadata.has_team === true;
const noTeamConfirmed = metadata.has_team === false;
if (!onboarded) {
if (isOnboarding || isInviteRoute) return;
return navigateTo("/onboarding");
}
if (noTeamConfirmed) {
if (isNoTeam || isInviteRoute) return;
return navigateTo("/no-team");
}
if ((isAuthRoute && !isResetPassword) || isOnboarding || isNoTeam) {
return navigateTo(hasTeam ? "/app" : "/no-team");
}
});
```
### 4. Replace `app/pages/index.vue` with a public homepage:
```vue
<template>
<div class="min-h-screen flex flex-col items-center justify-center px-4 text-center">
<h1 class="text-4xl sm:text-5xl font-bold tracking-tight">VueStarter</h1>
<p class="mt-4 text-lg text-muted max-w-xl">
A Vue + Nuxt + Supabase starter kit for teams.
</p>
<div class="mt-8 flex flex-wrap justify-center gap-3">
<UButton v-if="user" to="/app" size="lg" color="primary">Go to app</UButton>
<template v-else>
<UButton to="/auth/login" size="lg" color="primary">Sign in</UButton>
<UButton to="/auth/signup" size="lg" color="neutral" variant="outline">Create account</UButton>
</template>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: "plain" });
const user = useSupabaseUser();
useHead({ titleTemplate: "%s", title: "VueStarter — your tagline here" });
</script>
```
(Edit the brand name and tagline to match your app.)
### 5. Update path references everywhere
Grep for every old path root and rewrite to the `/app` prefix. Replacements:
- `/dashboard` → `/app`
- `/settings` and `/settings/*` → `/app/settings` and `/app/settings/*`
- `/chat` and `/chat/*` → `/app/chat` and `/app/chat/*`
- `/activity` → `/app/activity`
- For each custom feature you moved in step 2: `/<feature>` and `/<feature>/*` → `/app/<feature>` and `/app/<feature>/*`
Files commonly affected (check each — skip any that don't exist in your fork):
- `app/components/layout/sidebar/Links.vue` — `to:` props for Dashboard / Chat / Settings nav
- `app/components/layout/sidebar/Footer.vue` — `to:` and `active:` for Settings / Team / Profile / Members / Activity
- `app/components/layout/sidebar/Header.vue` — Team Settings dropdown link
- `app/components/chat/Sidebar.vue` — New chat button + chat link `:to` + `navigateTo` after delete
- `app/components/auth/OnboardingForm.vue` — both `navigateTo` calls (after team create + auto-onboard branch)
- `app/utils/auth.ts` — `navigateTo` in `redirectAfterAuth` AND the JSDoc comment that names the destination
- `app/utils/requirePermission.ts` — change default `fallback = "/dashboard"` to `fallback = "/app"`
- `app/middleware/requireAiChat.ts` — `navigateTo` redirect target
- `app/error.vue` — see step 7
- `app/pages/invite/[token].vue` — "Already accepted" CTA + post-accept `navigateTo`
- `app/pages/no-team.vue` — `navigateTo` in the team-appeared watcher
- `app/pages/app/settings.vue` — every `to:` in the tabs nav
- `app/pages/app/settings/announcements.vue` — `requirePermission` fallback arg
- `app/pages/app/settings/team.vue` — `requirePermission` fallback + delete-team `navigateTo` + the surrounding code comment that names the destination
- `app/pages/app/chat.vue` — `navigateTo` on team switch
- `app/pages/app/chat/index.vue` — `navigateTo` after starting a chat
- Any custom files you added — anywhere they linked to old top-level routes
- Code comments and JSDoc that mention old URLs (e.g., "redirects to /dashboard") — update prose too
DO NOT change:
- `/api/*` paths — server routes don't move
- `/auth/*`, `/invite/*`, `/onboarding`, `/no-team` — these stay where they are
- Display labels like `label: "Dashboard"` in nav items
- Nuxt UI component names like `UDashboardPanel`, `UDashboardSidebar`, `UDashboardNavbar`
- Page titles in `useHead({ title: "Dashboard" })`
- Layouts (`app/layouts/default.vue` and `plain.vue` stay as-is — `default.vue` auto-applies to `pages/app/**` since they don't override)
### 6. Verify with a follow-up grep
```bash
grep -rEn '/(dashboard|chat|activity)(/|"|'\''|`)' app/ server/ shared/ layers/ 2>/dev/null | grep -v '/app/' | grep -v UDashboard | grep -v 'app/components/activity'
grep -rEn '\b/settings(/|"|'\''|`)' app/ server/ shared/ layers/ 2>/dev/null | grep -v '/app/settings'
```
Both should return zero rows. If they don't, fix the remaining references and re-run.
### 7. Update `app/error.vue`
Send the error-page back button to the universally-safe public homepage:
```vue
<UButton label="Back to home" block @click="handleError" />
```
```ts
function handleError() {
clearError({ redirect: "/" });
}
```
### 8. Update `CLAUDE.md`
Find the **Routing** subsection of the Project structure section and replace it with this:
```markdown
Routing: the app is split into a public surface and a gated subtree.
- Public pages live at the top level: `/` (marketing homepage), `/auth/*`,
`/invite/*`, plus future marketing routes (`/pricing`, `/about`,
`/blog/*`, `/legal/*`, etc.). No auth check, no opt-in needed — anything
not under `/app/*` is public by default.
- The gated app surface lives entirely under `/app/*`. `auth.global.ts`
enforces auth + onboarded + team for any path matching `/app` or
`/app/*` (plus the forced-flow pages `/onboarding` and `/no-team`).
Adding a file under `app/pages/app/` automatically inherits gating.
- `/app` is the app's home page (`pages/app/index.vue`), not a parent for
unrelated features. Each feature is a child of `/app` — `/app/chat`,
`/app/settings`, `/app/activity`, etc. — not nested under another feature.
- Use a parent route file (`pages/app/feature.vue` with `<NuxtPage />`) only
when the feature needs shared sub-navigation (like `app/settings.vue` has tabs).
```
Also update the example project tree in CLAUDE.md to show `pages/index.vue` as the public homepage and `pages/app/` as the gated subtree containing `index.vue`, `settings.vue` + `settings/`, `chat.vue` + `chat/`, `activity/`, etc. Update the "Route gating" usage note so the `requirePermission` fallback default reads `/app` instead of `/dashboard`. Update any other CLAUDE.md mention of `/activity` to `/app/activity`.
### 9. Update `README.md` (if present)
Search for hardcoded URL references and rewrite:
- `/activity` → `/app/activity`
- `/chat` → `/app/chat`
- `/dashboard` → `/app`
- `/settings` → `/app/settings`
Skip external URLs (e.g. `supabase.com/dashboard`, `dash.cloudflare.com`) and skip prose descriptions of "the dashboard" as a concept.
### 10. Lint, typecheck, and report
```bash
npm run lint && npm run typecheck
```
Fix any errors. Then summarise for me:
- The list of custom features you discovered and moved (from step 1)
- Total count of files edited
- Any references you intentionally left alone (e.g. display labels) so I can spot-check
- A reminder to run `rm -rf .nuxt && npm run dev` before browser-testing — global middleware changes don't always hot-reload cleanly
- Browser-test checklist:
- Logged-out → `/` renders homepage with Sign in / Create account
- Logged-out → `/app` redirects to `/auth/login`
- Sign in → lands on `/app`
- Sidebar links navigate to `/app/*` and highlight active state correctly
- Settings tabs work
- Delete-team flow ends on `/no-team`
- 404 → "Back to home" → `/`
- Each custom feature you moved is reachable at its new `/app/<feature>` URL
Manual verification
After Claude finishes, delete the dev cache and restart before testing — global middleware changes don't always hot-reload cleanly:
rm -rf .nuxt && npm run dev
Then walk through these in a browser:
- Logged-out →
/renders the homepage with Sign in + Create account - Logged-out →
/appredirects to/auth/login - Sign in → lands on
/app - Sidebar links navigate to
/app/*and highlight active state correctly - Settings tabs (
/app/settings/*) work - Delete-team flow ends on
/no-team - 404 page → Back to home →
/ - Each custom feature you moved is reachable at its new
/app/<feature>URL
What if something breaks?
If the upgrade prompt failed partway:
- Run
git status— check what was moved or modified - Run
git diffto see textual changes - If badly broken,
git reset --hard HEADto roll back, fix the issue noted in Claude's output, and re-run the prompt (it's idempotent thanks to the bail-out check in step 0)