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.getFeed reads 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

  • Node.js 20+
  • A Supabase project (PostgreSQL + Storage)
  • A Clerk application
  • An Upstash Redis database

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_KEY is a server-only secret. It is imported exclusively in server-only modules 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: 110 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.ts currently 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.
S
Description
No description provided
Readme
5.6 MiB
Languages
TypeScript 98.4%
Shell 0.6%
CSS 0.5%
JavaScript 0.3%
Dockerfile 0.2%