PawFeed
A social platform where pets are the stars. Dogs, cats, and birds get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals.
Core value: Pets are first-class social identities — not just content on a human's feed — so every feature is built around the pet's life, not the owner's.
Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js | 16.x |
| UI | React + Tailwind CSS v4 + shadcn/ui (base-nova preset) | React 19 |
| Language | TypeScript | 6.x |
| API | tRPC | 11.x |
| ORM | Prisma | 7.x |
| Database | PostgreSQL via Supabase (managed) | 16 |
| Auth | Clerk | 7.x |
| Media Storage | Supabase Storage (S3-compatible) | — |
| Cache / Feed | Upstash Redis (serverless) | — |
| Client Fetching | TanStack Query | 5.x |
| Tests | Vitest | 4.x |
| Hosting | Vercel (target) | — |
Design decisions:
- All data access goes through Prisma → direct Postgres connection, never through Supabase PostgREST. RLS is enabled on all tables with no policies, which closes the PostgREST surface entirely.
- Media (photos, avatars, stories) is stored in Supabase Storage via presigned PUT URLs — file bytes never pass through the Next.js API server.
- The social feed uses a Redis fan-out-on-write pattern: on each new post, a sorted set entry is written into every follower's feed inbox.
feed.getFeedreads from Redis and hydrates with Prisma. - Authentication is handled by Clerk (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users.
Project Structure
src/
app/
(app)/ # Protected app shell (Clerk middleware)
feed/ # Home feed page + StoryTray
pets/[petId]/ # Pet profile page + ProfileTabs
pets/[petId]/edit/
pets/[petId]/followers/
pets/[petId]/following/
onboarding/ # Pet creation onboarding
(auth)/ # Clerk-rendered sign-in / sign-up
components/
feed/ # PostCard, MilestoneCard, FeedList, SkeletonCard
layout/ # Sidebar, MobileNav
pet/ # AvatarUpload, PetForm, PetListItem
post-creation/ # PostTypeSheet, PhotoPostForm, StoryForm, MilestoneForm, MultiImageUpload
profile/ # ProfileTabs (Posts grid + Milestones list)
safety/ # BlockDialog, ReportSheet
stories/ # PawRing, StoryViewer, StoryTray, StoryForm
ui/ # shadcn component copies (button, dialog, sheet, etc.)
context/
ActivePetContext.tsx # Global active pet state (pet switcher)
hooks/
useActivePet.ts
lib/
auth.ts # Clerk server helper
avatar-url.ts # CDN URL construction for avatars
feed-helpers.ts # fanOutPost, backfillOnFollow (Redis)
media-url.ts # CDN URL construction for post/story media
milestone-meta.ts # MilestoneType → Lucide icon + label map
milestone-thresholds.ts # Follower count thresholds for auto-milestones
prisma.ts # Prisma singleton with PgBouncer adapter
r2.ts # AWS SDK S3 client for Cloudflare R2 (Phase 5+)
redis.ts # Upstash Redis client
supabase-storage.ts # Supabase Storage presigned URL helpers (server-only)
utils.ts # cn() helper
trpc/
init.ts # tRPC context (userId + prisma)
server.ts # Server-side caller for RSC
query-client.ts # TanStack Query client factory
routers/
_app.ts # Root router
pets.ts # CRUD for pet profiles
posts.ts # Photo posts (create, updateCaption, delete, byPetId)
stories.ts # 24h stories (create, listActive, hasActiveStory, recordView, getViewers)
milestones.ts # Milestone posts (create, listByPet)
feed.ts # Home feed (getFeed via Redis + Prisma hydration)
follows.ts # Pet-to-pet follow graph
blocks.ts # Block a pet
reports.ts # Report a post or pet
media.ts # Avatar presigned upload (getPresignedUrl, confirmUpload)
__tests__/
helpers/
prisma-mock.ts # Typed mock PrismaClient factory
auth.test.ts
feed.test.ts
follows.test.ts
blocks.test.ts
reports.test.ts
posts.test.ts
stories.test.ts
milestones.test.ts
proxy.ts # Next.js middleware (Clerk auth guard)
prisma/
schema.prisma # Full DB schema
seed.ts # Species + breed seed (Dog/Cat/Bird)
supabase/
migrations/
20260613000000_enable_rls_all_tables.sql
.planning/ # GSD planning artefacts (phases, plans, summaries)
Setup
Prerequisites
1. Clone and install
git clone <repo-url>
cd pawfeed
npm install
2. Environment variables
Copy .env.example to .env.local and fill in all values:
cp .env.example .env.local
| Variable | Where to find it |
|---|---|
DATABASE_URL |
Supabase → Project Settings → Database → Connection string (pooled, port 6543) |
DIRECT_URL |
Supabase → Project Settings → Database → Connection string (direct, port 5432) |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
Clerk Dashboard → API Keys |
CLERK_SECRET_KEY |
Clerk Dashboard → API Keys |
NEXT_PUBLIC_SUPABASE_URL |
Supabase → Project Settings → API → Project URL |
SUPABASE_SERVICE_ROLE_KEY |
Supabase → Project Settings → API → service_role key |
UPSTASH_REDIS_REST_URL |
Upstash Console → Redis database → REST URL |
UPSTASH_REDIS_REST_TOKEN |
Upstash Console → Redis database → REST Token |
SUPABASE_SERVICE_ROLE_KEYis a server-only secret. It is imported exclusively inserver-onlymodules and never exposed to the client.
3. Database setup
Push the Prisma schema to Supabase:
npx prisma db push
Seed species and breeds:
npx prisma db seed
Enable Row Level Security on all tables (run once in Supabase SQL Editor):
-- File: supabase/migrations/20260613000000_enable_rls_all_tables.sql
-- Paste the contents of this file into the Supabase SQL Editor and run.
This closes the PostgREST surface. Prisma (direct connection with service_role) is unaffected.
4. Supabase Storage
Create a public bucket named pet-avatars in Supabase Storage (Dashboard → Storage → New bucket).
5. Run the dev server
npm run dev
Open http://localhost:3000. The app redirects unauthenticated users to Clerk sign-in.
Key Commands
| Command | What it does |
|---|---|
npm run dev |
Start Next.js dev server (Turbopack) |
npm run build |
Production build |
npm run lint |
ESLint |
npx vitest run |
Run full test suite |
npx vitest run src/__tests__/posts.test.ts |
Run a single test file |
npx tsc --noEmit |
Type check without emitting |
npx prisma db push |
Sync schema to DB (no migration files) |
npx prisma studio |
Open Prisma Studio (DB browser) |
npx prisma db seed |
Re-seed species + breeds |
npx prisma generate |
Regenerate Prisma client after schema change |
Architecture Notes
Identity model
Clerk User (owner)
└── owns 1..n Pet profiles (Postgres)
└── Pet is the social identity (posts, follows, stories, milestones)
An owner can switch between their pets via ActivePetContext. All tRPC mutations assert pet.ownerId === ctx.userId before writing.
Media pipeline
Client → tRPC getPresignedUrl → Supabase Storage signed URL
Client → XHR PUT directly to signed URL (file bytes never hit Next.js server)
Client → tRPC create/confirmUpload (writes storage key to DB)
Feed → getMediaUrl(storageKey) → Supabase public CDN URL
Feed architecture
Post created
└── fanOutPost(redis, postId, petId, createdAt, prisma)
└── for each follower: ZADD feed:{followerPetId} score=timestamp member=postId
feed.getFeed({ petId, cursor })
└── ZRANGE feed:{petId} → postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) → hydrated posts
Story expiry (lazy-delete)
Stories are never deleted in real time. All queries filter expiresAt > NOW(). A weekly cleanup job purges expired rows. Story views are retained 30 days for analytics.
PawRing (story indicator)
Instead of a standard circular ring (Instagram-style), active stories are indicated by a paw-print SVG outline around the avatar: a circular main pad ring with four toe-bean circles above it, all in orange-500.
Development Progress
Phase 1 — Foundation (complete)
- Next.js 16 + Tailwind v4 + shadcn/ui (base-nova) scaffold
- Clerk auth with middleware, protected app shell, onboarding flow
- Prisma 7 + Supabase PostgreSQL (pooled via PgBouncer)
- tRPC 11 + TanStack Query 5 wired end-to-end
- Upstash Redis client
- Supabase Storage presigned upload pipeline
- Pet CRUD (create, edit, avatar upload)
- ActivePetContext (pet switcher)
- Responsive layout (Sidebar + MobileNav)
Phase 2 — Content Core (complete)
- Social graph: pet-to-pet follow/unfollow, followers/following pages
- Safety: block a pet, report a post or profile
- Feed: Redis fan-out-on-write, infinite scroll FeedList, PostCard
- Photo posts: 1–10 image carousel (Embla), caption edit/delete, presigned upload
- Stories: 24h photo stories, paw-print ring indicator, StoryTray, StoryViewer with progress bar + viewer list
- Milestones: 5 structured milestone types (Birthday, Adoption Day, First Outing, Vet Visit, New Litter), distinct orange card, PostTypeSheet fully wired for all 3 post types
- Profile: Posts 3-col grid + Milestones tab
Phase 3 — Engagement (planned)
Likes, comments, reactions, notifications, explore/search.
Phase 4 — Growth (planned)
Follower-milestone auto-trigger, push notifications, pet discovery.
Phase 5 — Video (planned)
Short-form video stories and posts via Mux (HLS adaptive streaming).
Phase 6 — Messaging (planned)
Direct messages between pet owners via Supabase Realtime.
Phase 7 — Health (planned)
Vet records, vaccination logs, weight tracking.
Testing
Tests are unit tests only (no DB, no network). Prisma and Redis are mocked per file using createMockPrisma() from src/__tests__/helpers/prisma-mock.ts.
npx vitest run # full suite (~3-4s)
npx vitest # watch mode
src/__tests__/media-upload.test.tscurrently fails due to a missing Supabase URL in the test environment. This is a pre-existing issue from Phase 1 and does not affect any Phase 2 tests.
Known Issues / Debugging Notes
| Issue | Notes |
|---|---|
DATABASE_URL must use the pooled connection (port 6543, ?pgbouncer=true) |
Prisma uses this at runtime. DIRECT_URL (port 5432) is only for prisma db push / migrations. |
Prisma on Windows requires DIRECT_URL set in .env.local |
The Prisma CLI reads .env.local via a custom prisma.config.ts. If npx prisma db push hangs, check DIRECT_URL is set. |
| Supabase service_role key is never client-facing | All files that import supabase-storage.ts must be server-only. The client only ever receives signed URLs. |
asChild prop is not available in the base-nova shadcn preset |
Do not use asChild on any shadcn component. This affects PopoverTrigger, DropdownMenuItem, etc. Style the trigger element directly. |
| StoryView records are NOT cascade-deleted when a story expires | This is intentional (D-08): view analytics are retained 30 days. A separate cleanup job will purge old views. |
| Feed cleanup on post delete is best-effort in Phase 2 | When a post is deleted, its ID is not removed from Redis sorted sets. feed.getFeed silently skips IDs that Prisma doesn't return. Full cleanup (ZREM) is scheduled for Phase 4. |