SsuperslateDocs

Worked feature recipe

Build a personal saved-links domain from a concrete product decision through browser proof.

Worked feature recipe: personal saved links

This example turns one bounded product request into a complete vertical slice. It is deliberately personal: an authenticated user saves, lists, and deletes their own links. It does not add sharing, organizations, roles, billing, uploads, background work, or a public API.

Use this as a concrete companion to Build your first feature. The exact product name and UI can change; the owner, boundaries, and proof cannot be silently dropped.

Product contract

SituationRequired result
Signed-in owner saves a valid URL and labelA personal link appears in the owner's list.
Same owner saves the same URL twiceThe application returns a stable conflict without a duplicate row.
Anonymous callerThe route returns 401; the browser sends the user to sign in.
Another user reads or deletes a link IDThe application returns the chosen non-disclosing missing result and changes no row.
New user opens the pageThe page has a clear empty state and an accessible way to add the first link.
Save request is pending or failsThe form communicates progress and a useful recovery action without losing its safe input.

The public fields are bookmark_id, url, label, and created_at. user_id is persistence and authorization data, not client-controlled input.

1. Design the schema around ownership

Create a new post-release dbmate migration. A suitable table has a generated UUID primary key, a non-null user_id UUID REFERENCES users(id) ON DELETE CASCADE, a bounded URL and label, a creation timestamp, a unique (user_id, url) constraint, and an index supporting the owner's ordered list.

The important behavior is expressed by the database:

CREATE TABLE bookmarks (
    bookmark_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    url TEXT NOT NULL CHECK (length(url) <= 2048),
    label TEXT NOT NULL CHECK (length(trim(label)) BETWEEN 1 AND 120),
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE (user_id, url)
);

CREATE INDEX bookmarks_user_created_at_idx
    ON bookmarks (user_id, created_at DESC);

The migration's down section drops only bookmarks and its dependent index. Before release, prove fresh up → down → up on a named disposable database. Follow Add a database migration for reversible migration and deployment rules.

2. Define one wire contract

Create packages/contracts/src/bookmarks.ts and export it from the contracts package index. The create request accepts only a trimmed URL and label; it does not accept user_id, bookmark_id, or an entitlement claim. The list response is a public, mapped bookmark record rather than a raw database row.

Validate the request once with Zod, then infer its TypeScript type from that schema. Add contract tests for a valid HTTPS URL, an invalid URL, blank/overlong labels, and unknown input keys. This gives both the route and browser client the same public shape.

3. Keep the server layers narrow

Create apps/server/src/domains/bookmarks/ with repository.ts, service.ts, routes.ts, and an internal types.ts only when an internal row type is needed.

The repository receives both bookmarkId and current userId for detail or delete operations. Its SQL owns the authorization predicate:

DELETE FROM bookmarks
WHERE bookmark_id = $1
  AND user_id = $2
RETURNING bookmark_id;

The service translates no returned row into the deliberate private-resource not-found result. It also translates the unique-constraint conflict into a stable application error. The route mounts sessionAuth, obtains identity with requireUserId(c), validates with the shared Zod schema, and maps the result to the standard response helper. It contains no SQL and never reads user_id from the request.

Mount the route once in apps/server/src/app.ts, then run the server and contracts typechecks. The typed Hono client should expose the route without a manually duplicated browser interface.

4. Add the browser feature

Create apps/web/src/services/bookmarks.service.ts with one query-key factory, a paginated list query, a create mutation, and a delete mutation. Each mutation invalidates only the affected bookmark list or detail key.

Create apps/web/src/modules/bookmarks/ with an accessible form and list. Add a lazy /saved-links route and navigation only after the page exists. The page must cover:

  • a labeled URL and label form with invalid-input feedback;
  • pending save/delete affordances that prevent accidental double-submit;
  • empty, loading, error, and populated list states;
  • keyboard operation, visible focus, reduced motion, and a narrow viewport;
  • a clear outcome after duplicate-save conflict and after delete.

The page calls its service hook, not fetch, and it does not treat the browser as the authority for the current user or access policy.

5. Prove the behavior at the right boundaries

LayerProof
ContractRequest parser accepts a valid link and rejects invalid, blank, oversized, or unknown fields.
Migration/repositoryThe owner can list/delete; another user cannot read or delete; the uniqueness constraint holds under a second insert.
HTTP/authNo session returns 401; malformed input returns 400; another user gets the non-disclosing result; no cross-owner mutation occurs.
BrowserA signed-in user creates and sees a link, receives the empty state before creation, and can delete it.
ReleaseMigration reversal, database-enabled tests, E2E, root checks, builds, and a custom generated buyer pass.

Add the browser behavior to e2e/bookmarks.spec.ts using accessible labels and roles. See End-to-end testing for the disposable stack and failure artifacts.

6. Decide what comes next

Sharing a bookmark, adding a team workspace, importing bookmarks asynchronously, charging for a limit, attaching preview images, or exposing a public integration are separate product decisions. They change ownership, failure recovery, cost, and security boundaries. Do not add them as an unreviewed extension of this small personal domain.

Use the domain golden path as the full change checklist and Go live before accepting real users.

On this page