DEV Community

Sathish
Sathish

Posted on

Cursor + Claude: stop shipping flaky API clients

  • I generate a typed API client from an OpenAPI file.
  • I run contract tests locally with zero servers.
  • I make Cursor fix only what the tests prove.
  • I ship fewer “it works on my laptop” bugs.

Context

I build small SaaS projects. Usually solo. Usually fast.

My most common failure mode isn’t UI. It’s the boring glue code between frontend and backend.

I’d tweak an endpoint. Rename a field. Change a status code. Then ship.

Next day: runtime errors. Cannot read properties of undefined. Or worse. Silent wrong data.

Manual testing didn’t catch it. Postman collections drifted. And “just write types” didn’t help when my types were lying.

So I pushed my API boundary into something I can diff and test: OpenAPI + generated client + contract tests. Cursor + Claude help a lot here, but only when I force them to work against failing tests.

1) I start with a real contract. Not vibes.

I keep a single openapi.json in my repo.

Not perfect. Still worth it.

The point: my client types come from the contract, not from whatever I remembered last night.

Here’s a minimal OpenAPI snippet that already saves me from dumb mistakes (wrong path params, wrong response shapes).

{
  "openapi": "3.0.3",
  "info": { "title": "Example API", "version": "1.0.0" },
  "paths": {
    "/v1/jobs/{id}": {
      "get": {
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": { "type": "string" }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": ["id", "title"],
                  "properties": {
                    "id": { "type": "string" },
                    "title": { "type": "string" },
                    "salary": { "type": "integer", "nullable": true }
                  }
                }
              }
            }
          },
          "404": { "description": "Not found" }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

I learned the hard way that “nullable” matters.

Without it, I’d treat salary: null as a bug and add weird defaulting logic. Then I’d hide real missing data.

2) I generate the client. Then I stop hand-writing fetch.

I used to write wrappers like getJob(id) with fetch() everywhere.

Brutal to keep consistent.

Now I generate.

I use openapi-typescript for types. And openapi-fetch for a tiny client. Both are boring. That’s why I like them.

# install
npm i -D openapi-typescript
npm i openapi-fetch

# generate types
npx openapi-typescript ./openapi.json -o ./src/api/schema.d.ts
Enter fullscreen mode Exit fullscreen mode

Then I create a single client file.

// src/api/client.ts
import createClient from "openapi-fetch";
import type { paths } from "./schema";

// One client. One baseUrl.
// In Next.js I set NEXT_PUBLIC_API_BASE_URL.
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:3000";

export const api = createClient({
  baseUrl,
  headers: {
    "content-type": "application/json"
  }
});
Enter fullscreen mode Exit fullscreen mode

And I call it like this:

// src/api/jobs.ts
import { api } from "./client";

export async function getJob(id: string) {
  const { data, error, response } = await api.GET("/v1/jobs/{id}", {
    params: { path: { id } }
  });

  if (response.status === 404) return null;
  if (error) throw new Error(JSON.stringify(error));

  // data is typed from OpenAPI
  return data;
}
Enter fullscreen mode Exit fullscreen mode

This kills a whole category of bugs.

No more fetch('/v1/jobs/' + id) missing a slash. No more “was it jobId or id?” arguments.

Cursor helps here. A lot.

But I don’t let it “design” my API calls. I paste the OpenAPI path and tell it: “Use the generated client. No raw fetch.” It’ll still try to sneak in raw fetch sometimes. I delete it. Every time.

3) I write contract tests with a fake server. Zero Docker.

I want a test that fails if:

  • the client assumes the wrong status code
  • the response body shape changes
  • the client forgets to handle 404

I don’t want to spin up my whole backend.

So I mock at the HTTP layer using msw (Mock Service Worker) in Node mode.

This is where Claude is useful. It writes the handlers fast. And I can focus on the assertions.

// test/msw.ts
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

export const server = setupServer(
  http.get("http://localhost:3000/v1/jobs/:id", ({ params }) => {
    const id = String(params.id);

    if (id === "missing") {
      return new HttpResponse(null, { status: 404 });
    }

    return HttpResponse.json({
      id,
      title: "Data Engineer",
      salary: null
    });
  })
);
Enter fullscreen mode Exit fullscreen mode

And the test itself.

// test/jobs.test.ts
import { beforeAll, afterAll, afterEach, test, expect } from "vitest";
import { server } from "./msw";
import { getJob } from "../src/api/jobs";

beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test("getJob returns typed data", async () => {
  process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:3000";

  const job = await getJob("123");

  expect(job?.id).toBe("123");
  expect(job?.title).toBe("Data Engineer");
  expect(job?.salary).toBe(null);
});

test("getJob returns null on 404", async () => {
  process.env.NEXT_PUBLIC_API_BASE_URL = "http://localhost:3000";

  const job = await getJob("missing");
  expect(job).toBe(null);
});
Enter fullscreen mode Exit fullscreen mode

Yes, I set env vars inside tests. It’s not pretty.

But it’s deterministic. And it keeps my client code clean.

Also: onUnhandledRequest: "error" is everything.

Without it, MSW will quietly let real network calls through. Then your test “passes” while hitting your actual dev server. Ask me how I know. Spent 4 hours on that. Most of it was wrong.

4) I only let Cursor touch code when a test fails

This is the workflow change.

Not a tool change.

I do this loop:

  1. Change OpenAPI spec.
  2. Regenerate types.
  3. Run tests.
  4. If tests fail, I paste the failing output into Cursor chat.
  5. I ask for the smallest diff that makes tests pass.

Example failure I hit a lot:

  • I change a field from salary to salaryUsd in the spec.
  • Generated types update.
  • UI code still reads job.salary.

TypeScript catches some of it. But not all.

So I add one more test. A tiny runtime assertion that matches what the UI expects.

// src/api/assert.ts
export function assertHasTitle(x: unknown): asserts x is { title: string } {
  if (!x || typeof x !== "object") throw new Error("Expected object");

  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const anyX = x as any;

  if (typeof anyX.title !== "string") {
    throw new Error("Expected title:string");
  }
}
Enter fullscreen mode Exit fullscreen mode

Then in a component or loader:

// src/app/job/[id]/loader.ts
import { getJob } from "@/api/jobs";
import { assertHasTitle } from "@/api/assert";

export async function loadJob(id: string) {
  const job = await getJob(id);
  if (!job) return null;

  // Runtime guard for UI assumptions
  assertHasTitle(job);
  return job;
}
Enter fullscreen mode Exit fullscreen mode

Now when the contract changes, something screams early.

And Cursor becomes a mechanic, not a novelist.

I’ll literally write: “Fix loadJob and getJob to satisfy assertHasTitle and tests. Don’t change the tests.”

It works.

Results

I used to ship API mismatches weekly. Like clockwork.

In the last 14 days, I hit 11 contract breaks while refactoring endpoints (wrong field name, wrong nullable handling, missing 404 branch). All 11 got caught by local tests before I pushed.

Before this setup, those 11 would’ve made it to runtime. At least 7 would’ve hit a real user flow. And 3 would’ve been silent wrong-data bugs.

The best part: I can change APIs faster now, because I’m not scared of the blast radius.

Key takeaways

  • Put your API contract in a file and diff it like code.
  • Generate the client. Don’t hand-roll fetch wrappers.
  • Use msw with onUnhandledRequest: "error" or you’ll lie to yourself.
  • Add one or two runtime assertions for UI assumptions. Types don’t cover everything.
  • With Cursor + Claude, always drive from failing tests. No exceptions.

Closing

If you already generate clients, you’re ahead.

But I’m curious about the testing part.

Do you run contract tests against a mocked HTTP layer (MSW), or do you spin up the real API in CI and hit it for real?

Top comments (0)