Skip to content

Repository files navigation

Web App Template

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.

Quick Start

npm install
mv .env.example .env   # rename it, then fill in your Supabase credentials
npm run dev

Environment Variables

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)

Project Structure

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/.

Styling

Tailwind CSS v4 is the styling system. All customization lives in one file: src/styles/theme.css.

This file:

  • Imports Tailwind
  • Overrides theme values (@theme block) -- fonts, brand colors, custom tokens
  • Sets base element defaults (@layer base block)

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.

Database Layer

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%')

Backend API

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 to http://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 (with status, 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).

Auth

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 isAdmin flag read from the JWT (see Admin access below)
  • A surfaced initError so init failures don't silently leave the user on a blank screen

Admin access

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().isAdmin is true
  • 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.

Deployment

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.

Scripts

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

About

Kealy Studio's template for getting started with Web Apps

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages