All playbooksNext.js + Flutter Product Monorepo

Add an endpoint to the shared contract

Add an API operation to the OpenAPI contract, serve it from a Next.js Route Handler behind RLS and call it from Flutter through the generated client.

When to use it

Web and mobile both need new data or a new action, for example the notes of the signed-in user. The steps use GET /v1/notes as the example.

Rules to keep

  • contracts/openapi.json comes first: change the contract, then the code on both sides. Do not define the type separately in TypeScript and Dart.
  • Adding an operation or an optional field is compatible and stays in /v1. Removing or renaming a field, or changing its type, needs a new /v2 path next to the old one.
  • apps/mobile/lib/api/generated/product_api.dart is never edited by hand: change the generator and run npm run contract:generate.
  • The server decides who the user is and what they may read. The handler accepts the session cookie (web) or Authorization: Bearer <token> (mobile), and RLS limits the rows. Never trust a user id or role sent by the client.

Steps

  1. If the data is new, add a table in a new file under supabase/migrations/ (append-only), with RLS enabled and one policy per allowed operation, as in supabase/migrations/202609280001_shared_product_contract.sql. Describe it in DATABASE.md.

  2. In contracts/openapi.json add the path with a stable operationId and the schema under components.schemas. Wrap lists in an object, so a field can be added later without breaking clients:

    "/v1/notes": {
      "get": {
        "operationId": "listNotes",
        "responses": { "200": { "description": "Notes of the signed-in user", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/NoteList" } } } } }
      }
    }
    

    NoteList has a required items array of Note; Note has the required fields id (uuid), body and createdAt (date-time). Raise info.version (minor for a compatible change).

  3. tool/generate-dart-client.mjs is a small generator written for this contract: it writes the Dart classes from a fixed text and reads only the enum values from the contract. Add to that text the Note class with a fromJson factory (DateTime.parse for createdAt) and the method Future<List<Note>> listNotes(); in ProductApi. If the schema has an enum, build the values from the contract as for BillingStatus.

  4. Run npm run contract:generate. npm run contract:check only compares the Dart file with the generator output, so also extend tool/test-openapi-contract.mjs: add the path to the checked list and assert the required fields that the Dart class reads.

  5. Create apps/web/app/api/v1/notes/route.ts following apps/web/app/api/v1/me/route.ts: read the bearer token, pass it to createSupabaseServerClient(token) so the queries run as that user, call auth.getUser(token) and answer 401 without a user. Query the table without filtering on a user id from the request, and map the columns to the contract names (created_at becomes createdAt). GET handlers are dynamic by default: do not add caching to responses that depend on the user.

  6. In apps/mobile/lib/api/supabase_product_api.dart implement listNotes with _get('/api/v1/notes') (Next.js serves the contract under /api) and read items with Note.fromJson.

  7. Add tests in apps/mobile/test/supabase_product_api_test.dart with MockClient: the request carries the bearer token, the response is parsed, and a non-200 status throws ProductApiException.

  8. Document the operation in API.md.

Done when

  • the verification commands pass, and npm run contract:check reports no difference after npm run contract:generate;
  • the handler answers 401 without a session or token and returns only the rows of the signed-in user, from the browser and with a bearer token;
  • API.md describes the new operation.