A Vue 3 + Vite + TypeScript SPA template with Tailwind CSS, Supabase auth, a database access layer, and an HTTP client for talking to a backend API. It works for user-facing web apps and internal admin panels alike, and is built to sit beside the Kealy Studio mobile-app and Python API templates — same Supabase project, same users, same backend.
npm install
mv .env.example .env # rename it, then fill in your Supabase credentials
npm run dev| Variable | Description |
|---|---|
VITE_SUPABASE_URL |
Your Supabase project URL |
VITE_SUPABASE_PUBLISHABLE_KEY |
Your Supabase publishable key |
VITE_API_URL |
Base URL of the backend API (defaults to http://localhost:8080) |
src/
components/
ui/ # Reusable UI components (Button.vue is a sample)
lib/
supabase.ts # Supabase client
db.ts # Database access layer (Supabase)
api.ts # HTTP client for the backend API
router/
index.ts # Vue Router with auth guards
stores/
auth.ts # Pinia auth store (email, Google OAuth)
styles/
theme.css # Tailwind import + theme overrides (single file)
views/
Home.vue # Authenticated homepage
Login.vue # Login / signup page
App.vue
main.ts
public/
_redirects # SPA fallback for static hosts (Cloudflare Pages, Netlify, etc.)
Imports use the @/ alias (e.g. import { db } from '@/lib/db') — @ maps to src/.
Tailwind CSS v4 is the styling system. All customization lives in one file: src/styles/theme.css.
This file:
- Imports Tailwind
- Overrides theme values (
@themeblock) -- fonts, brand colors, custom tokens - Sets base element defaults (
@layer baseblock)
To change the app's brand color, edit --color-accent in the @theme block. All components reference it via bg-accent, text-accent, etc.
Views and components use Tailwind utility classes directly in templates. No custom CSS framework to learn.
src/lib/db.ts provides a thin wrapper over the Supabase client for CRUD operations. Use it for direct, RLS-protected reads and writes from the browser:
import { db } from '@/lib/db'
interface Post { id: string; title: string; body: string }
const posts = db<Post>('posts')
await posts.getAll({ orderBy: 'created_at', direction: 'desc' })
await posts.getById('some-id')
await posts.getWhere('author_id', userId)
await posts.findOneWhere('slug', 'hello-world')
await posts.create({ title: 'Hello', body: 'World' })
await posts.update('some-id', { title: 'Updated' })
await posts.remove('some-id')
await posts.count()
// For complex queries, drop down to the raw Supabase builder:
const { data } = await posts.query().select('id, title').ilike('title', '%search%')src/lib/api.ts is the HTTP client for talking to a backend service (e.g. a FastAPI server). It:
- Reads its base URL from
VITE_API_URL(defaults tohttp://localhost:8080). - Auto-attaches the current Supabase access token as
Authorization: Bearer <token>on every request — pass{ auth: false }to opt out for public endpoints. - Throws
ApiError(withstatus,statusText,data,url) on non-2xx responses.
import { api, resource, ApiError } from '@/lib/api'
// One-off calls
const hello = await api.get<{ message: string }>('/')
const me = await api.get<{ user_id: string }>('/protected')
await api.post('/things', { name: 'x' })
await api.get('/items', { params: { page: 2, q: 'foo' } })
// REST resource (mirrors db() but for HTTP)
interface Todo { id: string; title: string; done: boolean }
const todos = resource<Todo>('/todos')
const all = await todos.list()
const created = await todos.create({ title: 'New' })
try {
await api.get('/protected', { auth: false })
} catch (e) {
if (e instanceof ApiError && e.status === 401) {
// ...
}
}When to use which? Use db() for reads and writes that go directly to Supabase tables (RLS-protected). Use api() for any custom backend logic (third-party integrations, complex aggregations, anything that needs a server secret).
The auth store (src/stores/auth.ts) supports:
- Email/password sign-in and sign-up
- Google OAuth (gracefully warns if not configured)
- Persistent sessions via Supabase
onAuthStateChange - Router guards (
requiresAuth,requiresAdmin,guestOnly) - An
isAdminflag read from the JWT (see Admin access below) - A surfaced
initErrorso init failures don't silently leave the user on a blank screen
The template ships an admin mechanism built on Supabase's app_metadata — the half of user metadata that only the server can write, which makes it safe to trust in the browser.
Grant the flag manually, per admin, in the Supabase SQL editor:
UPDATE auth.users
SET raw_app_meta_data = raw_app_meta_data || '{"admin": true}'::jsonb
WHERE email = 'admin@example.com';Supabase bakes app_metadata into every JWT it issues, so after the admin's next token refresh (sign out and back in to force it):
useAuthStore().isAdministrue- Routes marked
meta: { requiresAdmin: true }open (non-admins are redirected to/)
The frontend flag only hides doors — Row Level Security is the lock. Give admins real data access with RLS policies that check the same claim, e.g.:
CREATE OR REPLACE FUNCTION public.is_admin()
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
SELECT coalesce((auth.jwt() -> 'app_metadata' ->> 'admin')::boolean, false);
$$;
REVOKE ALL ON FUNCTION public.is_admin() FROM public;
GRANT EXECUTE ON FUNCTION public.is_admin() TO authenticated, service_role;
CREATE POLICY "Admins can view all rows" ON public.your_table
FOR SELECT TO authenticated USING (public.is_admin());Two names, one idea: the claim key is admin, and is_admin() is the function that reads it — never app_metadata.is_admin. Keep the flag in app_metadata rather than a boolean column on your own users table, so the check stays a claim read with no lookup and means the same thing in the browser, the API, and RLS.
If this app is part of a Kealy Studio workspace, those policies belong in the API repo's supabase/migrations/ folder — the schema lives there, not here.
This template is built for static hosting. npm run build produces a dist/ directory you can drop on any static host. The included public/_redirects file enables SPA fallback (so refreshing /login doesn't 404) on:
- Cloudflare Pages
- Netlify
- Render
For Vercel, deep-link rewrites are auto-detected for Vite SPAs — no extra config needed. For other hosts (S3, nginx), configure a fallback to index.html for unknown paths.
| Command | Description |
|---|---|
npm run dev |
Start dev server |
npm run build |
Type-check + production build |
npm run preview |
Preview production build |
npm run lint |
ESLint (Vue + TypeScript) |
npm run format |
Prettier write |