NuestroNido is a web app that lets a household stop coordinating its life inside a WhatsApp group: chores, shopping lists, shared notes and progress, all in one place.

The problem

Every group sharing a house ends up on the same system: a chat. It works until three questions need an answer at once. Who bought the detergent, who takes the trash out on Thursday, and where the plumber's number was written down. The chat answers all three, but only if someone scrolls far enough.

The problem isn't a lack of tools. It's that household information lives in channels that don't talk to each other: a list on one person's phone, a voice note, a message nobody can find anymore. That scatter has a concrete cost. Things get bought twice, tasks sit unassigned without anyone noticing, and the division of labor feels unfair even when nobody can prove it.

NuestroNido starts from a simple idea: if the household is the unit of shared life, the household has to be the entity in the system. Tasks, lists, notes and invitations hang off it and get read from one place.

One honest caveat before going further: this is a personal project. The problem is framed as a design hypothesis built on real friction, not as the output of interviews or adoption metrics.

What it does

Someone creates a nest and shares it with an eight-character code or a link. From there the household has four surfaces.

Tasks with daily, weekly (specific days) or monthly recurrence, assigned to a member and given a due date. A scheduled job clears and resets them periodically.

Shopping list with a closed set of categories (food, pharmacy, pets, cleaning, misc, other), quantities and purchase status.

Notes, shared, with an author.

Progress: completing a task awards between 10 and 20 experience points to the assigned member, and the total defines their level. The point is to make effort visible and on the record, which is exactly what's missing when the division of labor gets argued from memory.

On top of that: native browser push notifications and a fully bilingual interface.

Architecture

The backend is split into four layers with dependencies pointing one way. The HTTP layer validates and serializes, services decide, repositories query, and models don't know the other three exist. The rule holding it together is boring but effective: a service can't import anything from fastapi, and a repository can't hold business rules.

graph TD subgraph Client["CLIENT"] Web["Next.js 16 · React 19 · TypeScript"] end subgraph Transport["TRANSPORT · src/api"] M["CSRF · CORS · rate limit<br/>security headers"] R["FastAPI routers"] DI["Dependency injection<br/>get_db · services"] end subgraph Domain["DOMAIN · src/services"] S1["Auth"] S2["Family"] S3["Family member"] S4["Task"] S5["List"] S6["Note"] S7["User"] S8["Push"] end subgraph Data["DATA · src/repositories"] BR["BaseRepository<br/>CRUD + flush"] RR["Specialized repositories"] end subgraph Infra["INFRASTRUCTURE"] PG[("PostgreSQL")] RD[("Redis")] SB["Supabase Auth"] end Web -->|"HTTPS · HttpOnly cookies"| M M --> R R --> DI DI --> Domain Domain --> Data Data -->|"SQLAlchemy 2.0 async"| PG Domain -.->|"read cache"| RD S1 -.->|"OAuth · token exchange"| SB

The most important decision sits somewhere unglamorous: where COMMIT happens. Repositories never close a transaction. They call flush(), which sends changes to PostgreSQL inside the open transaction so constraints get validated and generated IDs come back, but leaves it alive. The thing that closes the loop is the get_db dependency, which wraps the whole request: commit on success, rollback on any exception.

This matters because creating a nest is two dependent inserts. First the row in families, then the creator's membership, which needs the freshly generated family_id. If each repository committed its own work, a failure on the second insert would leave a household with no members. With the transaction boundary at the request level, the operation is atomic without any service having to remember to roll back.

sequenceDiagram autonumber actor C as Client participant F as FastAPI route participant DB as get_db (session) participant S as Service participant R as Repository participant PG as PostgreSQL C->>F: POST /api/v1/families F->>DB: open AsyncSession F->>S: execute use case S->>R: create family R->>PG: INSERT + flush() → family_id S->>R: create creator membership R->>PG: INSERT + flush() → references family_id alt success F->>DB: request complete DB->>PG: COMMIT F-->>C: 201 Created else any exception F->>DB: exception captured DB->>PG: ROLLBACK F-->>C: 4xx / 5xx end

The price is that no service can commit on its own, and scheduled jobs have to open their own session scope because they run outside a request lifecycle. I'll take that trade any day over thinking about orphaned data again.

The data model

Ten tables, all with UUIDs generated in the application. families is the root and everything else hangs off it.

erDiagram LEVELS |o--o{ USERS : "" COLORS |o--o{ USERS : "" USERS ||--o| FAMILY_MEMBERS : "" FAMILIES ||--o{ FAMILY_MEMBERS : "" FAMILIES ||--o{ TASKS : "" FAMILIES ||--o{ LIST_ITEMS : "" FAMILIES ||--o{ NOTES : "" FAMILIES ||--o{ FAMILY_INVITATIONS : "" USERS |o--o{ TASKS : "" USERS ||--o{ NOTES : "" USERS ||--o{ PUSH_SUBSCRIPTIONS : "" USERS ||--o{ FAMILY_INVITATIONS : "" USERS { uuid id PK string name int experience_points uuid level_id FK uuid color_id FK } LEVELS { uuid id PK string name int level_number int required_progress } COLORS { uuid id PK string name string bg } FAMILIES { uuid id PK string name int tasks_count int notes_count datetime created_at } FAMILY_MEMBERS { uuid id PK uuid family_id FK uuid user_id FK enum role datetime joined_at } TASKS { uuid id PK uuid family_id FK string title enum recurrence_type string week_days uuid assigned_to_user_id FK enum status int xp_reward datetime due_date } LIST_ITEMS { uuid id PK uuid family_id FK string title string category int quantity bool purchased } NOTES { uuid id PK uuid family_id FK uuid user_id FK string title text content } FAMILY_INVITATIONS { uuid id PK uuid family_id FK string code uuid token datetime expires_at int max_uses int uses bool revoked uuid created_by FK } PUSH_SUBSCRIPTIONS { uuid id PK uuid user_id FK string endpoint string p256dh string auth }

Three decisions worth explaining.

A user belongs to exactly one household. family_members.user_id is unique. That was a product constraint before a technical one: it simplifies the entire permissions model, because every request resolves the household from the user and there's no ambiguity. If the product ever needed multi-household support, it's a matter of dropping the UNIQUE and adding an active household to the session.

Cascade deletes live in the engine. Deleting a household takes members, tasks, lists, notes and invitations with it through ON DELETE CASCADE. There's no Python-level cascade and no N round-trips: one statement resolves the whole graph, atomically.

tasks.xp_reward is an audit field. The awarded XP is random, so it has to be stored to be taken back. When a task gets unmarked, the system subtracts exactly the value it added. Without that column, the gamification would be irreversible.

What runs on its own

Four APScheduler jobs keep the system tidy without intervention:

Job When What it does
Task cleanup every 2 days, 00:01 deletes expired one-off tasks, resets recurring ones
List cleanup Sundays, 03:00 removes items already purchased
Task reminder daily, 09:00 push to anyone with pending tasks
Shopping reminder Thursdays, 18:00 push to anyone with unpurchased items

On the read side, Redis absorbs fast UI refreshes: ten seconds of cache on tasks and lists, one hour on static catalogs. The biggest win is the levels table, queried on every completed task to calculate a level-up; with caching, one query per hour is enough.

Operating it produces three signals. Latency and throughput metrics in Prometheus, errors with request context in Sentry, and structured logs. The /health endpoint checks the database connection, so an orchestrator can tell a live process from a working application.

Three things that cost me

XP was doubling. The first design awarded points every time a task reached completed. A double click or a client retry added them twice. The fix was to look at the previous state and store the awarded XP on the row. Completing twice now adds nothing, and unmarking subtracts exactly what was added.

Two simultaneous first logins broke user provisioning. The prior SELECT protected nothing: there's always a window between the check and the insert. I moved idempotency into the engine and the problem disappeared.

The Python cascade was a time bomb. It worked until a query failed halfway through. Moving it to ON DELETE CASCADE didn't just make it faster; it made it correct by definition.

If I had to do it again, I'd change two things. tasks.week_days stores the days as "1,3,5" in a string, which forces day filtering in memory; a normalized recurrence table would solve it in SQL. And APScheduler jobs run once per instance, so with more than one backend deployed every reminder would fire twice. Today there's a single instance, but it's logged technical debt.

Closing

NuestroNido wasn't a CRUD exercise. It had a real domain model (households, roles, expiring invitations), rules with side effects that had to be reversible, and a transaction boundary that had to be chosen on purpose.

What I take from it isn't FastAPI or Next.js. It's having had to decide, for every rule, where it lives: which constraint belongs to the database, which validation to the schema, which logic to the service, and which guarantee to the request. That distribution is what lets you reason about an operation without rebuilding the whole system in your head.