Direct DB host is IPv6-only for new Supabase projects and unreachable from IPv4-only environments. The session pooler (Supavisor, port 5432) is the correct entry point and keeps prepared statements working with EF Core.
40 KiB
Supabase + .NET + React To-Do App Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a working minimal per-user to-do list where a React (Vite+TS) SPA authenticates against Supabase (email + password) and does all CRUD through an ASP.NET Core Web API that validates Supabase JWTs and talks directly to Supabase Postgres.
Architecture: React SPA uses @supabase/supabase-js only for auth; every /api/todos* call goes to the .NET backend with Authorization: Bearer <access_token>. The backend validates JWTs against Supabase's OpenID discovery document (which itself points at the JWKS), extracts the sub claim, and uses it as user_id in every query against a single public.todos table. Row Level Security stays off — .NET is the only writer.
Tech Stack: ASP.NET Core 9.0 Web API, EF Core 9 with Npgsql, React 18 + TypeScript, Vite 5, @supabase/supabase-js v2.
Spec: docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md
Note on testing: The spec deliberately excludes automated tests in this first pass. Each task uses manual verification (curl for backend endpoints, the browser for frontend UI) instead. Every task ends with a commit.
Task 1: Repo baseline (.gitignore + README skeleton)
Files:
-
Create:
.gitignore -
Create:
README.md(overwrites the placeholder from the initial commit) -
Step 1: Write
.gitignorecovering .NET, Node, and local env files
Create .gitignore:
# .NET
bin/
obj/
*.user
*.suo
.vs/
# ASP.NET local secrets
backend/appsettings.Development.json
backend/appsettings.Local.json
# Node
node_modules/
dist/
.vite/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor / OS
.idea/
.vscode/
.DS_Store
# Frontend env
frontend/.env
frontend/.env.local
frontend/.env.*.local
- Step 2: Write
README.mdskeleton
Overwrite README.md:
# supabase_test
Minimal to-do list. React (Vite+TypeScript) frontend, ASP.NET Core Web API backend, Supabase for auth and Postgres.
See [`docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md`](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the design.
Setup instructions land here after the app is wired up.
- Step 3: Commit
git add .gitignore README.md
git commit -m "Add gitignore and README skeleton"
Task 2: Database migration for todos table
Files:
-
Create:
migrations/001_create_todos.sql -
Step 1: Write the migration SQL
Create migrations/001_create_todos.sql:
create table if not exists public.todos (
id bigserial primary key,
user_id uuid not null,
title text not null check (length(title) between 1 and 500),
completed boolean not null default false,
created_at timestamptz not null default now()
);
create index if not exists todos_user_id_created_at_idx
on public.todos (user_id, created_at desc);
- Step 2: Apply the migration to Supabase
The engineer running this plan needs to apply the SQL against the Supabase project. Two options:
Option A — Supabase SQL editor (no local tooling needed):
- Open the Supabase dashboard → SQL Editor → New query.
- Paste the contents of
migrations/001_create_todos.sql. - Click Run. Expect "Success. No rows returned."
Option B — psql locally:
sudo apt-get install -y postgresql-client
PGPASSWORD='<db-password>' psql \
"host=db.jrbqfctqhjttxobtoqts.supabase.co port=5432 dbname=postgres user=postgres sslmode=require" \
-f migrations/001_create_todos.sql
Expected: CREATE TABLE then CREATE INDEX.
- Step 3: Verify the table exists
Run this one-liner in the SQL editor (or via psql -c):
select column_name, data_type from information_schema.columns
where table_schema = 'public' and table_name = 'todos'
order by ordinal_position;
Expected columns: id (bigint), user_id (uuid), title (text), completed (boolean), created_at (timestamp with time zone).
- Step 4: Commit
git add migrations/001_create_todos.sql
git commit -m "Add todos table migration"
Task 3: Scaffold the ASP.NET Core Web API
Files:
-
Create:
backend/backend.csproj -
Create:
backend/Program.cs -
Create:
backend/appsettings.json -
Create:
backend/appsettings.Development.example.json -
Create:
backend/appsettings.Development.json(gitignored) -
Create:
backend/Properties/launchSettings.json -
Step 1: Create the project
From the repo root:
dotnet new webapi --name backend --framework net9.0 --no-https --use-controllers --output backend
--no-https keeps local dev on plain http://localhost:5000 (matches CORS config in Task 7). --use-controllers gives us Controllers/-based routing instead of minimal APIs.
Delete the sample files the template generates:
rm -f backend/Controllers/WeatherForecastController.cs backend/WeatherForecast.cs
If --use-controllers is rejected on your SDK version, run without it and then create backend/Controllers/ manually — the rest of the plan uses controller classes regardless.
- Step 2: Verify the scaffold builds
cd backend
dotnet build
Expected: Build succeeded. 0 Warning(s). 0 Error(s).
- Step 3: Configure ports and env in
Properties/launchSettings.json
Replace backend/Properties/launchSettings.json with:
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"backend": {
"commandName": "Project",
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
- Step 4: Create
appsettings.Development.example.json(committed template)
The connection string uses Supabase's session pooler (Supavisor), not the direct DB host. Rationale: the direct host (db.<ref>.supabase.co) is IPv6-only for new Supabase projects; the pooler is IPv4-reachable and is Supabase's recommended entry point. Session mode (port 5432) keeps prepared statements working with EF Core; transaction mode (6543) would break them.
Pooler URL shape:
Host=aws-<n>-<region>.pooler.supabase.com;Port=5432;Username=postgres.<project-ref>;Password=…
Get the exact n and region from your Supabase dashboard → Project Settings → Database → Connection string → "Session pooler" tab.
Create backend/appsettings.Development.example.json:
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=aws-N-REGION.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.YOURPROJECTREF;Password=REPLACE_ME;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://YOURPROJECTREF.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://YOURPROJECTREF.supabase.co/auth/v1",
"Audience": "authenticated"
}
}
- Step 5: Create
appsettings.Development.json(gitignored, real values)
Create backend/appsettings.Development.json. Substitute the DB password provided out of band. Do not paste the DB password into any committed file:
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"ConnectionStrings": {
"Postgres": "Host=aws-1-eu-west-1.pooler.supabase.com;Port=5432;Database=postgres;Username=postgres.jrbqfctqhjttxobtoqts;Password=REPLACE_WITH_DB_PASSWORD;SSL Mode=Require;Trust Server Certificate=true"
},
"Supabase": {
"MetadataAddress": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1/.well-known/openid-configuration",
"Issuer": "https://jrbqfctqhjttxobtoqts.supabase.co/auth/v1",
"Audience": "authenticated"
}
}
- Step 6: Trim
appsettings.json(leave only shared, non-secret defaults)
Replace backend/appsettings.json:
{
"Logging": {
"LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" }
},
"AllowedHosts": "*"
}
- Step 7: Verify the project still builds and runs
cd backend
dotnet build
dotnet run &
sleep 3
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/openapi/v1.json # 200 if webapi template's OpenAPI is on
kill %1
The exact status may be 200 or 404 depending on template; the important check is that the server started without an unhandled exception.
- Step 8: Commit
git add backend/ .gitignore # .gitignore already covers appsettings.Development.json
git commit -m "Scaffold ASP.NET Core Web API backend"
Confirm git status does NOT list backend/appsettings.Development.json.
Task 4: EF Core + Npgsql + Todo entity + AppDbContext
Files:
-
Modify:
backend/backend.csproj(add EF Core + Npgsql packages) -
Create:
backend/Models/Todo.cs -
Create:
backend/Data/AppDbContext.cs -
Modify:
backend/Program.cs(register DbContext) -
Step 1: Add EF Core + Npgsql packages
cd backend
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
No --version — the CLI resolves the latest stable compatible with the SDK (9.x on this project).
- Step 2: Create the
Todoentity
Create backend/Models/Todo.cs:
namespace Backend.Models;
public class Todo
{
public long Id { get; set; }
public Guid UserId { get; set; }
public string Title { get; set; } = string.Empty;
public bool Completed { get; set; }
public DateTimeOffset CreatedAt { get; set; }
}
- Step 3: Create
AppDbContext
Create backend/Data/AppDbContext.cs:
using Backend.Models;
using Microsoft.EntityFrameworkCore;
namespace Backend.Data;
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<Todo> Todos => Set<Todo>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Todo>(e =>
{
e.ToTable("todos", schema: "public");
e.HasKey(x => x.Id);
e.Property(x => x.Id).HasColumnName("id").ValueGeneratedOnAdd();
e.Property(x => x.UserId).HasColumnName("user_id").IsRequired();
e.Property(x => x.Title).HasColumnName("title").IsRequired().HasMaxLength(500);
e.Property(x => x.Completed).HasColumnName("completed").IsRequired();
e.Property(x => x.CreatedAt).HasColumnName("created_at").IsRequired();
e.HasIndex(x => new { x.UserId, x.CreatedAt })
.HasDatabaseName("todos_user_id_created_at_idx");
});
}
}
- Step 4: Replace
Program.cswith a known-good baseline
Overwrite backend/Program.cs entirely (the template output varies between SDK versions, so we replace it wholesale). Marker comments here are load-bearing — later tasks replace them with real code:
using Backend.Data;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
// MARKER: CORS SERVICES
// MARKER: AUTH SERVICES
builder.Services.AddControllers();
var app = builder.Build();
// MARKER: CORS MIDDLEWARE
// MARKER: AUTH MIDDLEWARE
app.MapControllers();
app.Run();
- Step 5: Verify build + startup does not throw when connecting
cd backend
dotnet build
dotnet run &
sleep 4
kill %1 2>/dev/null
Expected: no exception in the startup log about Postgres. DbContext is registered but nothing queries it yet, so a connection isn't actually opened at startup.
- Step 6: Commit
git add backend/
git commit -m "Add EF Core, Npgsql, Todo entity, and AppDbContext"
Task 5: JWT authentication (JWKS via OpenID metadata)
Files:
-
Modify:
backend/backend.csproj(add JwtBearer package) -
Modify:
backend/Program.cs(add auth services + middleware) -
Step 1: Add the JwtBearer package
cd backend
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer
- Step 2: Wire authentication in
Program.cs
Open backend/Program.cs. Add these two using directives at the top (below the existing using lines):
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
Replace the line // MARKER: AUTH SERVICES with:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.MetadataAddress = builder.Configuration["Supabase:MetadataAddress"]
?? throw new InvalidOperationException("Supabase:MetadataAddress not configured");
options.RequireHttpsMetadata = true;
options.MapInboundClaims = false;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidIssuer = builder.Configuration["Supabase:Issuer"],
ValidateAudience = true,
ValidAudience = builder.Configuration["Supabase:Audience"],
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = "sub"
};
});
builder.Services.AddAuthorization();
Replace the line // MARKER: AUTH MIDDLEWARE with:
app.UseAuthentication();
app.UseAuthorization();
- Step 3: Verify startup fetches the JWKS
cd backend
dotnet run &
sleep 5
kill %1 2>/dev/null
Expected: no exceptions. The JwtBearer middleware defers metadata fetching until first request, so absence of errors is enough.
- Step 4: Commit
git add backend/
git commit -m "Configure JWT bearer auth against Supabase JWKS via OpenID metadata"
Task 6: TodosController — GET, POST, PATCH, DELETE
Files:
-
Create:
backend/Controllers/TodosController.cs -
Create:
backend/Dtos/TodoDto.cs -
Create:
backend/Dtos/CreateTodoRequest.cs -
Create:
backend/Dtos/UpdateTodoRequest.cs -
Step 1: Create DTOs
Create backend/Dtos/TodoDto.cs:
namespace Backend.Dtos;
public record TodoDto(long Id, string Title, bool Completed, DateTimeOffset CreatedAt);
Create backend/Dtos/CreateTodoRequest.cs:
using System.ComponentModel.DataAnnotations;
namespace Backend.Dtos;
public record CreateTodoRequest([Required, StringLength(500, MinimumLength = 1)] string Title);
Create backend/Dtos/UpdateTodoRequest.cs:
namespace Backend.Dtos;
public record UpdateTodoRequest(bool? Completed);
- Step 2: Create
TodosController
Create backend/Controllers/TodosController.cs:
using System.Security.Claims;
using Backend.Data;
using Backend.Dtos;
using Backend.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Backend.Controllers;
[ApiController]
[Route("api/todos")]
[Authorize]
public class TodosController(AppDbContext db) : ControllerBase
{
private Guid CurrentUserId()
{
var sub = User.FindFirstValue("sub")
?? throw new InvalidOperationException("Authenticated request missing 'sub' claim");
return Guid.Parse(sub);
}
[HttpGet]
public async Task<ActionResult<IEnumerable<TodoDto>>> List()
{
var userId = CurrentUserId();
var todos = await db.Todos
.Where(t => t.UserId == userId)
.OrderByDescending(t => t.CreatedAt)
.Select(t => new TodoDto(t.Id, t.Title, t.Completed, t.CreatedAt))
.ToListAsync();
return Ok(todos);
}
[HttpPost]
public async Task<ActionResult<TodoDto>> Create([FromBody] CreateTodoRequest body)
{
if (!ModelState.IsValid) return ValidationProblem(ModelState);
var userId = CurrentUserId();
var todo = new Todo
{
UserId = userId,
Title = body.Title,
Completed = false,
CreatedAt = DateTimeOffset.UtcNow
};
db.Todos.Add(todo);
await db.SaveChangesAsync();
var dto = new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt);
return CreatedAtAction(nameof(List), new { id = todo.Id }, dto);
}
[HttpPatch("{id:long}")]
public async Task<ActionResult<TodoDto>> Update(long id, [FromBody] UpdateTodoRequest body)
{
var userId = CurrentUserId();
var todo = await db.Todos.FirstOrDefaultAsync(t => t.Id == id && t.UserId == userId);
if (todo is null) return NotFound();
if (body.Completed.HasValue) todo.Completed = body.Completed.Value;
await db.SaveChangesAsync();
return Ok(new TodoDto(todo.Id, todo.Title, todo.Completed, todo.CreatedAt));
}
[HttpDelete("{id:long}")]
public async Task<IActionResult> Delete(long id)
{
var userId = CurrentUserId();
var affected = await db.Todos
.Where(t => t.Id == id && t.UserId == userId)
.ExecuteDeleteAsync();
return affected == 0 ? NotFound() : NoContent();
}
}
Note: CreatedAt is stamped by the app (not the DB default) so the returned DTO matches what's stored without a re-read.
- Step 3: Verify build
cd backend
dotnet build
Expected: Build succeeded. 0 Warning(s). 0 Error(s).
- Step 4: Commit
git add backend/
git commit -m "Add TodosController with GET/POST/PATCH/DELETE endpoints"
Task 7: CORS for the Vite dev server
Files:
-
Modify:
backend/Program.cs -
Step 1: Register and apply the CORS policy
Open backend/Program.cs. Replace the line // MARKER: CORS SERVICES with:
const string DevCorsPolicy = "DevCors";
builder.Services.AddCors(options =>
{
options.AddPolicy(DevCorsPolicy, policy => policy
.WithOrigins("http://localhost:5173")
.AllowAnyHeader()
.WithMethods("GET", "POST", "PATCH", "DELETE"));
});
Replace the line // MARKER: CORS MIDDLEWARE with:
if (app.Environment.IsDevelopment())
{
app.UseCors(DevCorsPolicy);
}
UseCors must come before UseAuthentication in the middleware chain, which the marker order in Task 4 guarantees.
- Step 2: Verify startup
cd backend
dotnet run &
sleep 4
kill %1 2>/dev/null
No exceptions. Full CORS behavior gets verified during end-to-end testing.
- Step 3: Commit
git add backend/
git commit -m "Enable CORS for the Vite dev origin"
Task 8: End-to-end verify the backend with a real Supabase token
Files: none
This task uses only shell commands to prove the backend works. Do not commit anything here.
- Step 1: Start the backend
cd backend
dotnet run &
sleep 5
- Step 2: Confirm unauthenticated requests are rejected
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5000/api/todos
Expected: 401.
- Step 3: Create a Supabase user + get an access token via the Supabase auth REST API
Use a throwaway email (e.g. dev+$(date +%s)@example.com). The apikey header is the publishable key.
SB_URL="https://jrbqfctqhjttxobtoqts.supabase.co"
SB_PUB="sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy"
EMAIL="dev+$(date +%s)@example.com"
PASS="testpassword123"
# Sign up (returns a session if email confirmation is off)
SIGNUP=$(curl -s -X POST "$SB_URL/auth/v1/signup" \
-H "apikey: $SB_PUB" -H "Content-Type: application/json" \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}")
echo "$SIGNUP" | head -c 200; echo
# Extract access_token (grep-based; no jq assumed)
TOKEN=$(echo "$SIGNUP" | sed -E 's/.*"access_token":"([^"]+)".*/\1/')
echo "TOKEN length: ${#TOKEN}"
If TOKEN length is under 100 chars, sign-up did NOT return a session — email confirmation is on. Fix: in the Supabase dashboard, Authentication → Providers → Email, toggle Confirm email off, then re-run this step with a new email.
- Step 4: GET (empty list)
curl -s -w "\nHTTP %{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:5000/api/todos
Expected: [] then HTTP 200.
- Step 5: POST (create)
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:5000/api/todos \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"title":"buy milk"}'
Expected: a JSON body with id, title:"buy milk", completed:false, createdAt, then HTTP 201. Note the id for the next step (call it TID).
- Step 6: PATCH (mark complete)
TID=<id from previous step>
curl -s -w "\nHTTP %{http_code}\n" -X PATCH "http://localhost:5000/api/todos/$TID" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"completed":true}'
Expected: JSON with completed:true, HTTP 200.
- Step 7: DELETE
curl -s -w "\nHTTP %{http_code}\n" -X DELETE "http://localhost:5000/api/todos/$TID" \
-H "Authorization: Bearer $TOKEN"
Expected: empty body, HTTP 204.
- Step 8: Confirm cross-user isolation (optional but recommended)
Sign up a second user (repeat Step 3 with a different email), grab their token, try PATCH/DELETE on TID. Expect HTTP 404 for both — no leakage.
- Step 9: Stop the backend
kill %1 2>/dev/null
If any step fails, stop and debug before proceeding to the frontend.
Task 9: Scaffold the Vite + React + TypeScript frontend
Files:
-
Create:
frontend/package.json -
Create:
frontend/vite.config.ts -
Create:
frontend/tsconfig.json -
Create:
frontend/index.html -
Create:
frontend/src/main.tsx -
Create:
frontend/src/App.tsx -
Create:
frontend/src/index.css -
Step 1: Scaffold with
create-vite
From the repo root:
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
- Step 2: Install runtime dependencies
cd frontend
npm install @supabase/supabase-js
- Step 3: Verify the scaffold builds and dev server runs
cd frontend
npm run build # tsc + vite build
npm run dev &
sleep 3
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5173/
kill %1 2>/dev/null
Expected: build succeeds; curl returns 200.
- Step 4: Commit
git add frontend/
git commit -m "Scaffold Vite + React + TypeScript frontend"
Task 10: Frontend env + Supabase client
Files:
-
Create:
frontend/.env.example -
Create:
frontend/.env.local(gitignored) -
Create:
frontend/src/lib/supabase.ts -
Step 1: Create the env template
Create frontend/.env.example:
VITE_SUPABASE_URL=https://YOURPROJECT.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_YOURKEY
VITE_API_URL=http://localhost:5000
- Step 2: Create
frontend/.env.localwith real values
VITE_SUPABASE_URL=https://jrbqfctqhjttxobtoqts.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_QajbbpfedzxWEhCPBKTZqg_M-8qyCUy
VITE_API_URL=http://localhost:5000
- Step 3: Create the Supabase client module
Create frontend/src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js';
const url = import.meta.env.VITE_SUPABASE_URL;
const publishableKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY;
if (!url || !publishableKey) {
throw new Error(
'Missing VITE_SUPABASE_URL or VITE_SUPABASE_PUBLISHABLE_KEY. Copy .env.example to .env.local and fill it in.'
);
}
export const supabase = createClient(url, publishableKey);
- Step 4: Verify build still passes
cd frontend
npm run build
Expected: success.
- Step 5: Confirm
.env.localis gitignored
git status --short frontend/.env.local
Expected: no output (ignored).
- Step 6: Commit
git add frontend/.env.example frontend/src/lib/supabase.ts
git commit -m "Add Supabase client and env template"
Task 11: AuthProvider (session state via onAuthStateChange)
Files:
-
Create:
frontend/src/auth/AuthProvider.tsx -
Step 1: Create the provider
Create frontend/src/auth/AuthProvider.tsx:
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import type { Session } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
type AuthContextValue = {
session: Session | null;
loading: boolean;
};
const AuthContext = createContext<AuthContextValue>({ session: null, loading: true });
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const { data } = supabase.auth.onAuthStateChange((_event, s) => {
setSession(s);
setLoading(false);
});
return () => {
data.subscription.unsubscribe();
};
}, []);
return <AuthContext.Provider value={{ session, loading }}>{children}</AuthContext.Provider>;
}
export function useAuth() {
return useContext(AuthContext);
}
Notes:
-
onAuthStateChangefires anINITIAL_SESSIONevent immediately after subscribing, so a separategetSession()call is redundant. -
Supabase JS auto-refreshes the access token before expiry; the callback re-fires with the fresh session.
-
Step 2: Verify build
cd frontend
npm run build
Expected: success (the file is unused so far, but TypeScript compiles it).
- Step 3: Commit
git add frontend/src/auth/AuthProvider.tsx
git commit -m "Add AuthProvider tracking Supabase session"
Task 12: SignIn — sign-in / sign-up form
Files:
-
Create:
frontend/src/auth/SignIn.tsx -
Step 1: Create the component
Create frontend/src/auth/SignIn.tsx:
import { useState, type FormEvent } from 'react';
import { supabase } from '../lib/supabase';
type Mode = 'signIn' | 'signUp';
export function SignIn() {
const [mode, setMode] = useState<Mode>('signIn');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const { error } =
mode === 'signIn'
? await supabase.auth.signInWithPassword({ email, password })
: await supabase.auth.signUp({ email, password });
if (error) setError(error.message);
// On success, AuthProvider's onAuthStateChange updates session and the UI switches.
} finally {
setBusy(false);
}
}
return (
<div style={{ maxWidth: 320, margin: '4rem auto', fontFamily: 'sans-serif' }}>
<h1>{mode === 'signIn' ? 'Sign in' : 'Sign up'}</h1>
<form onSubmit={onSubmit}>
<label style={{ display: 'block', marginBottom: 8 }}>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
style={{ width: '100%', padding: 6 }}
/>
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
style={{ width: '100%', padding: 6 }}
/>
</label>
<button type="submit" disabled={busy} style={{ width: '100%', padding: 8 }}>
{busy ? 'Working…' : mode === 'signIn' ? 'Sign in' : 'Sign up'}
</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
<p>
<button
type="button"
onClick={() => setMode(mode === 'signIn' ? 'signUp' : 'signIn')}
style={{ background: 'none', border: 'none', color: '#06f', cursor: 'pointer' }}
>
{mode === 'signIn' ? "Don't have an account? Sign up" : 'Have an account? Sign in'}
</button>
</p>
</div>
);
}
- Step 2: Verify build
cd frontend
npm run build
Expected: success.
- Step 3: Commit
git add frontend/src/auth/SignIn.tsx
git commit -m "Add sign-in / sign-up form"
Task 13: API fetch wrapper (attaches Bearer token)
Files:
-
Create:
frontend/src/lib/api.ts -
Step 1: Create the API module
Create frontend/src/lib/api.ts:
import { supabase } from './supabase';
const API_URL = import.meta.env.VITE_API_URL;
if (!API_URL) throw new Error('Missing VITE_API_URL');
export type Todo = {
id: number;
title: string;
completed: boolean;
createdAt: string;
};
async function authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data } = await supabase.auth.getSession();
const token = data.session?.access_token;
if (!token) throw new Error('Not signed in');
const headers = new Headers(init.headers);
headers.set('Authorization', `Bearer ${token}`);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
return fetch(`${API_URL}${path}`, { ...init, headers });
}
async function assertOk(res: Response): Promise<Response> {
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`API ${res.status}: ${text || res.statusText}`);
}
return res;
}
export const api = {
async list(): Promise<Todo[]> {
const res = await assertOk(await authedFetch('/api/todos'));
return res.json();
},
async create(title: string): Promise<Todo> {
const res = await assertOk(
await authedFetch('/api/todos', { method: 'POST', body: JSON.stringify({ title }) })
);
return res.json();
},
async setCompleted(id: number, completed: boolean): Promise<Todo> {
const res = await assertOk(
await authedFetch(`/api/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed })
})
);
return res.json();
},
async remove(id: number): Promise<void> {
await assertOk(await authedFetch(`/api/todos/${id}`, { method: 'DELETE' }));
}
};
- Step 2: Verify build
cd frontend
npm run build
Expected: success.
- Step 3: Commit
git add frontend/src/lib/api.ts
git commit -m "Add authenticated fetch wrapper for the todos API"
Task 14: TodoList component (list + add + toggle + delete)
Files:
-
Create:
frontend/src/todos/TodoList.tsx -
Step 1: Create the component
Create frontend/src/todos/TodoList.tsx:
import { useEffect, useState, type FormEvent } from 'react';
import { api, type Todo } from '../lib/api';
import { supabase } from '../lib/supabase';
export function TodoList({ userEmail }: { userEmail: string }) {
const [todos, setTodos] = useState<Todo[]>([]);
const [newTitle, setNewTitle] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
async function refresh() {
try {
setTodos(await api.list());
} catch (e) {
setError(String(e));
}
}
useEffect(() => {
refresh().finally(() => setLoading(false));
}, []);
async function onAdd(e: FormEvent) {
e.preventDefault();
const title = newTitle.trim();
if (!title) return;
setError(null);
try {
const created = await api.create(title);
setTodos((prev) => [created, ...prev]);
setNewTitle('');
} catch (e) {
setError(String(e));
}
}
async function onToggle(todo: Todo) {
setError(null);
try {
const updated = await api.setCompleted(todo.id, !todo.completed);
setTodos((prev) => prev.map((t) => (t.id === updated.id ? updated : t)));
} catch (e) {
setError(String(e));
}
}
async function onDelete(todo: Todo) {
setError(null);
try {
await api.remove(todo.id);
setTodos((prev) => prev.filter((t) => t.id !== todo.id));
} catch (e) {
setError(String(e));
}
}
async function onSignOut() {
await supabase.auth.signOut();
}
return (
<div style={{ maxWidth: 520, margin: '2rem auto', fontFamily: 'sans-serif' }}>
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<h1>Todos</h1>
<span>
{userEmail}{' '}
<button type="button" onClick={onSignOut}>Sign out</button>
</span>
</header>
<form onSubmit={onAdd} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="What needs doing?"
maxLength={500}
style={{ flex: 1, padding: 6 }}
/>
<button type="submit">Add</button>
</form>
{error && <p style={{ color: 'crimson' }}>{error}</p>}
{loading ? (
<p>Loading…</p>
) : todos.length === 0 ? (
<p>No todos yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map((t) => (
<li
key={t.id}
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0' }}
>
<input type="checkbox" checked={t.completed} onChange={() => onToggle(t)} />
<span style={{ flex: 1, textDecoration: t.completed ? 'line-through' : 'none' }}>
{t.title}
</span>
<button type="button" onClick={() => onDelete(t)}>Delete</button>
</li>
))}
</ul>
)}
</div>
);
}
- Step 2: Verify build
cd frontend
npm run build
Expected: success.
- Step 3: Commit
git add frontend/src/todos/TodoList.tsx
git commit -m "Add TodoList component"
Task 15: Wire it all together in App.tsx and main.tsx
Files:
-
Modify:
frontend/src/main.tsx -
Modify:
frontend/src/App.tsx -
Step 1: Wrap the app in
AuthProvider
Replace frontend/src/main.tsx with:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { AuthProvider } from './auth/AuthProvider';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>
);
- Step 2: Route between
SignInandTodoListbased on session
Replace frontend/src/App.tsx with:
import { useAuth } from './auth/AuthProvider';
import { SignIn } from './auth/SignIn';
import { TodoList } from './todos/TodoList';
export function App() {
const { session, loading } = useAuth();
if (loading) return <p style={{ textAlign: 'center', marginTop: '4rem' }}>Loading…</p>;
if (!session) return <SignIn />;
return <TodoList userEmail={session.user.email ?? '(no email)'} />;
}
- Step 3: Verify build
cd frontend
npm run build
Expected: success, no TS errors.
- Step 4: Commit
git add frontend/src/main.tsx frontend/src/App.tsx
git commit -m "Route between sign-in and todo list based on session"
Task 16: End-to-end verification in the browser
Files: none
This is a manual smoke test. Do not commit anything here.
- Step 1: Start both processes
Two terminals from the repo root:
# Terminal 1
cd backend && dotnet run
# Terminal 2
cd frontend && npm run dev
Expected: backend on http://localhost:5000, Vite on http://localhost:5173.
- Step 2: Sign up
Open http://localhost:5173. Click "Don't have an account? Sign up". Enter an email like dev+$(date +%s)@example.com and a password ≥ 6 chars. Submit.
Expected: the view switches to the todo list (empty).
If instead you see an error like "email not confirmed" or the view doesn't switch, email confirmation is on for this Supabase project. Turn it off (Dashboard → Authentication → Providers → Email → "Confirm email"), sign up with a new email, and continue.
- Step 3: Add a todo
Type "buy milk", click Add.
Expected: the item appears at the top of the list, [ ] buy milk [Delete]. The Network tab shows POST /api/todos → 201.
- Step 4: Toggle complete
Click the checkbox next to "buy milk".
Expected: the text gets strikethrough, checkbox stays ticked. Network shows PATCH /api/todos/{id} → 200.
- Step 5: Delete
Click Delete.
Expected: the item disappears. Network shows DELETE /api/todos/{id} → 204.
- Step 6: Sign out + sign back in
Click Sign out. Sign back in with the same email + password. Add another todo. Reload the page.
Expected: the todo persists across reloads; the session is restored automatically from localStorage.
- Step 7: Cross-user isolation (optional)
Sign out. Sign up as a second user with a different email. Confirm the todo list is empty (the first user's todos are not visible).
- Step 8: Stop both processes
Ctrl+C in each terminal.
If any step fails, debug in the browser DevTools (Console + Network) and in the backend log before continuing.
Task 17: Finalize the README
Files:
-
Modify:
README.md -
Step 1: Rewrite
README.mdwith real setup instructions
Replace README.md:
# supabase_test
Minimal per-user to-do list.
- **Frontend:** React 18 + TypeScript, built with Vite. Uses `@supabase/supabase-js` only for authentication.
- **Backend:** ASP.NET Core 9 Web API. Validates Supabase-issued JWTs against the project's JWKS (via OpenID discovery), talks to Postgres directly with EF Core + Npgsql.
- **Auth + DB:** Supabase.
See [the design doc](docs/superpowers/specs/2026-08-15-supabase-todo-app-design.md) for the architecture and rationale.
## Prerequisites
- .NET SDK 9.0
- Node.js 20+
- A Supabase project (URL, publishable key, and Postgres password to hand)
## One-time setup
1. Apply the database migration to your Supabase project. Either:
- Open the Supabase dashboard → SQL Editor → paste `migrations/001_create_todos.sql` → Run, or
- `psql "host=db.<PROJECT>.supabase.co port=5432 dbname=postgres user=postgres sslmode=require" -f migrations/001_create_todos.sql`
2. In the Supabase dashboard, **Authentication → Providers → Email → toggle "Confirm email" off** for local dev, so sign-up returns a session immediately.
3. Backend config:
```bash
cp backend/appsettings.Development.example.json backend/appsettings.Development.json
# Fill in the DB password and (if different) the project URL.
- Frontend config:
cp frontend/.env.example frontend/.env.local # Fill in VITE_SUPABASE_URL, VITE_SUPABASE_PUBLISHABLE_KEY, VITE_API_URL. - Install frontend deps:
cd frontend && npm install
Run it
Two terminals:
# Terminal 1
cd backend && dotnet run # http://localhost:5000
# Terminal 2
cd frontend && npm run dev # http://localhost:5173
Open http://localhost:5173, sign up, add todos.
Layout
backend/ ASP.NET Core Web API
frontend/ Vite + React + TypeScript SPA
migrations/ Plain SQL files applied to Supabase Postgres
docs/ Design and implementation-plan docs
- [ ] **Step 2: Commit and push**
```bash
git add README.md
git commit -m "Update README with real setup instructions"
git push
Done criteria
- All 17 tasks completed with commits.
- The end-to-end verification (Task 16) passes every step.
backend/appsettings.Development.jsonandfrontend/.env.localare not tracked by git (git statusclean,git ls-filesdoes not list them).- A fresh clone + the "One-time setup" steps in the README +
dotnet run+npm run devyields a working app.