Series: Spec-Driven Development with Claude Code — Part 1 · Part 2 · Part 3
In Part 1, we set up our project structure but left it without logic. Today we implement authentication and data — the two layers that turn a wireframe into a real app.
The pattern stays the same: spec first, code second.
Step 1: Set Up Supabase
Before touching any code, we configure the backend.
- Go to supabase.com and create a new project
- Once created, go to Project Settings → API and copy:
URLanon public key
- Paste them into your
.env:
SUPABASE_URL=https://yourproject.supabase.co
SUPABASE_ANON_KEY=eyJhbGciOiJI...
- In the Supabase dashboard, go to Authentication → Providers and make sure Email is enabled (disable email confirmation for development).
Step 2: The Authentication Spec
We create a dedicated spec for the auth module. File specs/auth.md:
# Spec: Authentication Module
## Dependencies
- @supabase/supabase-js (client)
## Composable: useAuth
### State
- `user: Ref<User | null>` — current user
- `loading: Ref<boolean>` — initial load state
- `error: Ref<string | null>` — error message
### Methods
- `signUp(email, password): Promise<{ user, error }>`
- `signIn(email, password): Promise<{ user, error }>`
- `signOut(): Promise<void>`
- `initAuth(): Promise<void>` — restores session on load
### Behavior
- initAuth() is called once via a Nuxt plugin
- signUp/signIn validate fields before calling Supabase
- All Supabase errors are translated to user-friendly messages
- signOut clears local state + Supabase session
Now we ask Claude Code to implement:
> Read @specs/auth.md and @specs/catalogo-guayoyo.md. Implement the useAuth composable in composables/useAuth.ts exactly following the spec. Also create the Nuxt plugin to initialize the session in plugins/supabase.client.ts.
Claude Code generates:
// composables/useAuth.ts
import type { User, AuthError } from '@supabase/supabase-js'
const user = ref<User | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
export function useAuth() {
const client = useSupabaseClient()
function translateError(err: AuthError): string {
const map: Record<string, string> = {
'Invalid login credentials': 'Invalid email or password',
'User already registered': 'This email is already registered',
'Password should be at least 6 characters': 'Password must be at least 6 characters',
}
return map[err.message] ?? err.message
}
async function signUp(email: string, password: string) {
error.value = null
const { data, error: err } = await client.auth.signUp({ email, password })
if (err) {
error.value = translateError(err)
return { user: null, error: error.value }
}
user.value = data.user
return { user: data.user, error: null }
}
async function signIn(email: string, password: string) {
error.value = null
const { data, error: err } = await client.auth.signInWithPassword({ email, password })
if (err) {
error.value = translateError(err)
return { user: null, error: error.value }
}
user.value = data.user
return { user: data.user, error: null }
}
async function signOut() {
await client.auth.signOut()
user.value = null
}
async function initAuth() {
loading.value = true
const { data: { session } } = await client.auth.getSession()
user.value = session?.user ?? null
loading.value = false
}
return { user, loading, error, signUp, signIn, signOut, initAuth }
}
Notice: the code follows the spec exactly. Error translation, exact method signatures, correct types. Nothing "almost."
Step 3: Implement Auth Pages
We ask Claude to create the forms:
> Read @specs/catalogo-guayoyo.md (US-1 and US-2). Implement /login and /register with validated forms, error messages, and redirect to /products. Use useAuth from the previous step. Tailwind for styling. No logic in the template — everything in <script setup>.
Claude generates pages/login.vue:
<script setup lang="ts">
definePageMeta({ layout: 'auth' })
const { signIn, error, user } = useAuth()
const router = useRouter()
const email = ref('')
const password = ref('')
const submitting = ref(false)
// Redirect if already authenticated
if (user.value) {
await router.replace('/products')
}
async function handleSubmit() {
submitting.value = true
const { error: err } = await signIn(email.value, password.value)
if (!err) {
await router.push('/products')
}
submitting.value = false
}
</script>
And pages/register.vue with similar logic, validating password ≥ 8 characters and match.
⚠️ Key SDD insight: The spec said exactly what to validate and how to behave. Claude Code didn't have to "guess" anything. The result works on the first try.
Step 4: Create the Products Database
In the Supabase Dashboard, go to SQL Editor and run:
-- Create products table
CREATE TABLE products (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
name TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL CHECK (price > 0),
category TEXT NOT NULL,
image_url TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Insert sample data
INSERT INTO products (name, price, category, image_url) VALUES
('Premium Guayoyo Coffee', 12.99, 'Beverages', 'https://images.unsplash.com/photo-1559056199-641a0ac8b33e?w=400'),
('Electric Arepa Maker', 34.50, 'Appliances', 'https://images.unsplash.com/photo-1585937421612-70a008356fbe?w=400'),
('"Shipping Code" T-Shirt', 24.99, 'Clothing', 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400'),
('RGB Mechanical Keyboard', 89.99, 'Technology', 'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400'),
('"No Friday Deploys" Mug', 15.99, 'Accessories', 'https://images.unsplash.com/photo-1514228742587-6b1558fcca3d?w=400'),
('Braided USB-C Cable 2m', 9.99, 'Technology', 'https://images.unsplash.com/photo-1610397704400-33e5573b3a2a?w=400');
-- Enable Row Level Security
ALTER TABLE products ENABLE ROW LEVEL SECURITY;
-- Policy: authenticated users can read products
CREATE POLICY "Authenticated users can read products"
ON products FOR SELECT
TO authenticated
USING (true);
Run this SQL and your database is ready in 30 seconds.
Step 5: Data Access Spec + Implementation
New spec specs/products.md:
# Spec: Products Module
## Composable: useProducts
### State
- `products: Ref<Product[]>` — product list
- `loading: Ref<boolean>` — loading state
- `error: Ref<string | null>` — error message
### Methods
- `fetchProducts(): Promise<void>` — fetches all products
### Behavior
- Ordered by created_at descending (newest first)
- Handles states: loading, error, empty
- Uses Supabase client for queries
- No re-fetch if products already loaded (implicit cache)
### Product Type
- id: string
- name: string
- price: number
- category: string
- image_url: string
- created_at: string
Ask Claude Code:
> Read @specs/products.md. Implement useProducts in composables/useProducts.ts and generate TypeScript types from the spec.
Result:
// composables/useProducts.ts
export interface Product {
id: string
name: string
price: number
category: string
image_url: string
created_at: string
}
export function useProducts() {
const client = useSupabaseClient()
const products = ref<Product[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchProducts() {
// Implicit cache: don't re-fetch if we already have data
if (products.value.length > 0) return
loading.value = true
error.value = null
try {
const { data, error: err } = await client
.from('products')
.select('*')
.order('created_at', { ascending: false })
if (err) throw err
products.value = data as Product[]
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Error loading products'
} finally {
loading.value = false
}
}
return { products, loading, error, fetchProducts }
}
Step 6: Generate TypeScript Types from Supabase
For maximum type safety, generate types directly from the database:
npx supabase login
npx supabase gen types typescript --project-id YOUR_PROJECT_ID > types/supabase.ts
This reads your actual database schema and generates precise TypeScript types. Now Product isn't a manual interface — it's the exact type of your table.
// types/supabase.ts (auto-generated)
export interface Database {
public: {
Tables: {
products: {
Row: {
id: string
name: string
price: number
category: string
image_url: string
created_at: string
}
Insert: { /* ... */ }
Update: { /* ... */ }
}
}
}
}
Now our composable is 100% typed:
import type { Database } from '~/types/supabase'
type Product = Database['public']['Tables']['products']['Row']
// 👆 Type derived from the REAL database, not invented by hand
What We Achieved in Part 2
- ✅ Supabase Auth working: registration, login, logout, session persistence
- ✅ Forms with validation, error messages, and redirects
- ✅
productstable with sample data and Row Level Security - ✅
useProductscomposable with loading/error/empty states - ✅ TypeScript types generated from the actual database
- ✅ Everything implemented from specs — not from loose prompts
In about 45 minutes, you went from an empty scaffold to an app with real authentication and real data.
In Part 3: UI, Deploy & The Living Spec, we build the catalog UI with Tailwind, wire everything together, deploy to production, and learn the most important SDD practice: keeping the spec alive as the project evolves.
References:
- Spec-Driven Development: Structure Beats Vibes — RemyBuilds (dev.to)
- Spec-Driven Development: The Definitive 2026 Guide — BCMS
- Using spec-driven development with Claude Code — Heeki Park (Medium)
- Claude Code Spec-Driven Development Implementation Guide — GitHub
- Spec-Driven Development with Claude Code: Build It Right — SolGuruz
- Supabase Auth Docs
- Nuxt 3 Supabase Module
Top comments (0)