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.
npm install
cp .env.example .env # 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,guestOnly) - A surfaced
initErrorso init failures don't silently leave the user on a blank screen
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 |