DEV Community

Sathish
Sathish

Posted on

Cursor + Claude: stop shipping flaky Next.js APIs

  • I use Cursor + Claude to turn “random 500s” into a reproducible test.
  • I add one wrapper: request ID + timing + safe error output.
  • I write a tiny Node script to hammer endpoints and catch regressions.
  • No new infra. Just code you can paste today.

Context

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

And the fastest way to lose a weekend is an API route that fails “sometimes”. You see a 500 in the browser. Then it works on refresh. Then it dies again. Brutal.

I used to chase logs by vibe. Console spam. Random try/catch. Half fixes. Spent 4 hours on this last month. Most of it was wrong.

The only thing that consistently helps: make failures reproducible, make errors visible (but not leaky), and lock it in with a script I can run before I push.

This is that workflow. Cursor to refactor fast. Claude to keep me honest on edge cases. Me to write the final code and own the mess.

1) I start with a failing script. Not vibes.

If I can’t reproduce it locally, I’m just guessing.

So I write a tiny load script. No k6. No artillery. Just Node 20.

It does three things.

1) Runs N requests with concurrency.
2) Prints status counts.
3) Shows a few error bodies so I can pattern-match.

// scripts/hit-route.mjs
// Node 20+. Run: node scripts/hit-route.mjs http://localhost:3000/api/health 200 20

const [url, totalStr = "200", concStr = "20"] = process.argv.slice(2);
if (!url) {
  console.error("Usage: node scripts/hit-route.mjs  [total] [concurrency]");
  process.exit(1);
}

const total = Number(totalStr);
const concurrency = Number(concStr);

const counts = new Map();
const samples = [];

let i = 0;
async function worker(id) {
  while (true) {
    const n = i++;
    if (n >= total) return;

    const res = await fetch(url, { headers: { "x-load-worker": String(id) } });
    const text = await res.text();

    counts.set(res.status, (counts.get(res.status) ?? 0) + 1);
    if (res.status >= 400 && samples.length < 5) {
      samples.push({ status: res.status, body: text.slice(0, 300) });
    }
  }
}

const start = Date.now();
await Promise.all(Array.from({ length: concurrency }, (_, idx) => worker(idx)));
const ms = Date.now() - start;

console.log("Done", { total, concurrency, ms });
console.log("Status counts:");
for (const [k, v] of [...counts.entries()].sort((a, b) => a[0] - b[0])) {
  console.log(k, v);
}
if (samples.length) {
  console.log("\nError samples:");
  for (const s of samples) console.log("-", s);
}
Enter fullscreen mode Exit fullscreen mode

This script is dumb on purpose.

But it catches the stuff browsers hide.

Like: a route that fails only when two requests overlap. Or an occasional JSON parse crash. Or a “works on my machine” dependency on a missing env var.

Cursor helps here because I can keep the script open in a split pane and iterate fast. Run, edit, run again. No ceremony.

2) I wrap every route with request IDs and timing

Next.js route handlers are tiny. That’s the problem.

Everyone writes:

  • parse params
  • call DB
  • return JSON

Then toss a try/catch on top. Ship.

I do one wrapper instead. It enforces the same behavior for every route:

  • request id
  • duration
  • safe error JSON
  • logs that are searchable
// lib/api/route.ts
import { NextResponse } from "next/server";
import { randomUUID } from "crypto";

export function withApi(
  handler: (ctx: { requestId: string; startedAt: number }) => Promise
) {
  return async function route() {
    const requestId = randomUUID();
    const startedAt = Date.now();

    try {
      const data = await handler({ requestId, startedAt });
      const ms = Date.now() - startedAt;

      // Keep logs terse. Always include requestId.
      console.log(JSON.stringify({ level: "info", requestId, ms }));

      return NextResponse.json({ ok: true, requestId, data }, { status: 200 });
    } catch (err) {
      const ms = Date.now() - startedAt;
      const message = err instanceof Error ? err.message : "Unknown error";

      // Don’t leak stack traces to clients.
      console.error(JSON.stringify({ level: "error", requestId, ms, message }));

      return NextResponse.json(
        { ok: false, requestId, error: "Internal error" },
        { status: 500 }
      );
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

A few details that matter.

I return requestId to the client. Always. When I see a 500 in the UI, I can grep logs by requestId.

I log JSON. Not pretty strings. Because later I’ll paste them into a log tool or just search in a terminal.

And I never send stack traces to the browser. I did that once. Then I forgot. Then I shipped it. Don’t do what I did.

3) I validate inputs with code, not comments

Most flaky APIs aren’t “flaky”. They’re under-validated.

?limit=abc.
Missing headers.
Empty body.
Then something deep throws, you get a 500, and you blame the database.

I use Zod for validation because it’s readable and strict.

Cursor + Claude helps here because it’s easy to drift into “accept anything” schemas. Claude will happily suggest z.any(). That’s how you get 500s later.

Here’s a route that validates query params and returns a real 400.

// app/api/items/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { withApi } from "@/lib/api/route";

const QuerySchema = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
  cursor: z.string().min(1).optional(),
});

export const GET = withApi(async ({ requestId }) => {
  // Next.js gives us no request object in this wrapper.
  // So read from headers/env/etc inside handler? Nope.
  // Better: keep wrapper for shared behavior and pass request explicitly when needed.
  // For this example, we’ll parse from a global via NextRequest in a tiny adapter.

  return { note: "Use the GET2 export below" };
});

export async function GET2(req: NextRequest) {
  const requestId = crypto.randomUUID();
  const startedAt = Date.now();

  try {
    const url = new URL(req.url);
    const parsed = QuerySchema.safeParse(Object.fromEntries(url.searchParams));

    if (!parsed.success) {
      return NextResponse.json(
        { ok: false, requestId, error: parsed.error.flatten() },
        { status: 400 }
      );
    }

    const { limit, cursor } = parsed.data;

    // Fake data. Replace with DB call.
    const items = Array.from({ length: limit }, (_, i) => ({
      id: `${cursor ?? "0"}-${i}`,
      name: `Item ${i}`,
    }));

    console.log(JSON.stringify({ level: "info", requestId, ms: Date.now() - startedAt }));
    return NextResponse.json({ ok: true, requestId, data: { items } });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    console.error(JSON.stringify({ level: "error", requestId, ms: Date.now() - startedAt, message }));
    return NextResponse.json({ ok: false, requestId, error: "Internal error" }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Yeah, that has an awkward moment.

I originally wanted the wrapper to hide everything. Then I hit the reality: handlers need NextRequest sometimes.

So I do this instead in real code:

  • withApi(req, handler) signature
  • wrapper reads req.url, headers, etc.

I left this “wrong turn” in because it’s real. I’ve rewritten this wrapper three times.

The lesson: abstractions are allowed. But they can’t fight the framework.

4) I add a timeout so hung requests don’t pile up

The worst “flaky” bug isn’t a 500.

It’s a request that never ends.

One slow upstream call. One stuck DB connection. Suddenly your server has 37 open requests and everything looks dead.

I add a timeout wrapper for any external call. Fetch. DB. Anything.

// lib/timeout.ts
export async function withTimeout(
  promise: Promise,
  ms: number,
  label = "operation"
): Promise {
  const controller = new AbortController();

  const timer = setTimeout(() => controller.abort(), ms);

  try {
    // If the promise supports AbortSignal, pass controller.signal there.
    // For generic promises, we race against an abort.
    return await Promise.race([
      promise,
      new Promise((_, reject) => {
        controller.signal.addEventListener("abort", () => {
          reject(new Error(`${label} timed out after ${ms}ms`));
        });
      }),
    ]);
  } finally {
    clearTimeout(timer);
  }
}
Enter fullscreen mode Exit fullscreen mode

And here’s how I use it for fetch.

// app/api/ping-upstream/route.ts
import { NextResponse } from "next/server";
import { withTimeout } from "@/lib/timeout";

export async function GET() {
  const startedAt = Date.now();
  const controller = new AbortController();

  try {
    const res = await withTimeout(
      fetch("https://example.com", { signal: controller.signal }),
      1500,
      "upstream fetch"
    );

    return NextResponse.json({ ok: true, status: res.status, ms: Date.now() - startedAt });
  } catch (err) {
    const message = err instanceof Error ? err.message : "Unknown error";
    return NextResponse.json({ ok: false, error: message, ms: Date.now() - startedAt }, { status: 502 });
  }
}
Enter fullscreen mode Exit fullscreen mode

Two things.

1) Timeouts turn “random hangs” into a specific error message.
2) Once it’s specific, Claude becomes useful. I can paste the exact error and get targeted fixes instead of generic advice.

Results

I used this setup on 6 API routes in one Next.js app last week.

Before: my load script (200 requests, concurrency 20) would usually produce 9–23 HTTP 500s depending on timing. The error bodies were inconsistent because I was returning different shapes per route.

After: same script produced 0 500s across 600 total requests (3 runs). I did get 17 HTTP 400s on purpose by sending bad params, which is exactly what I wanted. Errors became boring. Shipping got easier.

Key takeaways

  • Write a repro script first. If you can’t trigger it, you can’t fix it.
  • Add request IDs. Return them to the client. Debugging becomes searchable.
  • Validate inputs and return 400s. Most “flaky” 500s are bad params.
  • Put timeouts on external calls. Hanging requests kill apps quietly.
  • Use Cursor + Claude for speed, but keep the final shape simple enough that you can maintain it at 2am.

Closing

If you already do one thing here, do the load script. It changes everything.

What’s your go-to number for local load testing: how many requests and what concurrency do you run before you trust a Next.js API route?

Top comments (0)