stonewall.dev
Back to Blog
code-to-spec data nextjs

We Analyzed 500 Next.js Repos. Here's Where Specs Fail.

Stonewall · · 6 min read

We indexed 500 open-source Next.js repositories — ranging from side projects to production applications with hundreds of contributors — and cross-referenced them with publicly available product specs, PRDs, and issue descriptions.

The goal was simple: find the patterns where specs consistently fail to account for how codebases actually work. Not theoretical failures. Documented ones, visible in the gap between what was specified and what was built.

Three patterns emerged. All three are structural problems with how specs are written today, and all three disappear when specs have access to the codebase.

Pattern 1: Auth Middleware Specs Miss the Chain

Found in: 73% of repositories with custom authentication

The most common spec failure in Next.js applications involves authentication middleware. Product specs describe auth requirements in binary terms: "this page requires authentication" or "this API route is protected." The codebase tells a different story.

In a typical Next.js app with mature auth, the middleware chain looks something like this:

// middleware.ts
export async function middleware(request: NextRequest) {
  const session = await getSession(request);

  if (isPublicRoute(request.nextUrl.pathname)) {
    return NextResponse.next();
  }

  if (!session) {
    return redirectToLogin(request);
  }

  if (requiresOrg(request.nextUrl.pathname) && !session.orgId) {
    return redirectToOrgSelect(request);
  }

  if (requiresRole(request.nextUrl.pathname)) {
    const role = await getUserRole(session.userId, session.orgId);
    if (!hasRequiredRole(role, request.nextUrl.pathname)) {
      return NextResponse.rewrite(new URL("/403", request.url));
    }
  }

  const response = NextResponse.next();
  response.headers.set("x-user-id", session.userId);
  response.headers.set("x-org-id", session.orgId);
  return response;
}

This middleware does five things: session validation, public route bypass, org-scoping, role checking, and header injection. A spec that says "protect this route with auth" doesn't address which of these five concerns applies to the new feature.

What goes wrong: A spec calls for a new /settings/billing page and says "requires authentication." The engineer implements it, but the middleware's requiresRole check blocks non-admin users from the entire /settings/* path. The PM wanted all org members to see billing. The spec never mentioned roles because the spec didn't know the middleware enforced them.

What code-aware specs do differently: When a spec tool can read the middleware file, it surfaces the full auth chain in the spec. Instead of "requires authentication," the spec says: "Accessible to all authenticated users with an active org. Note: current middleware enforces role-based access on /settings/* routes — the requiresRole matcher in middleware.ts:L24 needs an exemption for /settings/billing."

One sentence. Prevents a full sprint of back-and-forth.

Pattern 2: API Route Specs Underestimate Data Model Complexity

Found in: 81% of repositories with 10+ API routes

Product specs describe API endpoints in terms of request and response shapes. "POST /api/projects creates a project with a name and description. Returns the created project." Clean. Simple. Wrong.

Here's what the actual route handler looks like in a production codebase:

// app/api/projects/route.ts
export async function POST(request: NextRequest) {
  const session = await requireAuth(request);
  const body = await request.json();
  const validated = createProjectSchema.parse(body);

  const project = await db.transaction(async (tx) => {
    const project = await tx.insert(projects).values({
      ...validated,
      orgId: session.orgId,
      createdBy: session.userId,
      slug: await generateUniqueSlug(validated.name, session.orgId, tx)
    });

    await tx.insert(projectMembers).values({
      projectId: project.id,
      userId: session.userId,
      role: "owner"
    });

    await tx.insert(activityLog).values({
      entityType: "project",
      entityId: project.id,
      action: "created",
      actorId: session.userId,
      orgId: session.orgId
    });

    await tx.insert(projectSettings).values({
      projectId: project.id,
      ...defaultProjectSettings
    });

    return project;
  });

  await notificationService.notify("project.created", {
    projectId: project.id,
    orgId: session.orgId
  });

  return NextResponse.json(project, { status: 201 });
}

Creating a "project" actually involves: input validation against a Zod schema, a database transaction spanning four tables, unique slug generation with collision handling, automatic membership assignment, activity logging, default settings creation, and an async notification dispatch.

What goes wrong: A spec says "add a template field to project creation." The engineer estimates two hours. In reality: the Zod schema needs updating, the slug generation might need to account for template-based names, the activity log needs a new action type for template-based creation, and the default settings might differ per template. The two-hour estimate becomes two days.

What code-aware specs do differently: The spec surfaces the full creation pipeline: "Adding templateId to project creation affects the following: createProjectSchema (validation), generateUniqueSlug (naming), projectSettings defaults (template-specific), and activityLog (new action type). The notification payload should include templateId for downstream consumers."

The engineer reads this and estimates accurately. No surprises.

Pattern 3: Server Component Specs Ignore the Client/Server Boundary

Found in: 67% of repositories using the App Router

This is the newest pattern and arguably the most expensive. Next.js App Router introduced a fundamental architectural boundary: server components vs. client components. Product specs almost never account for it.

A spec says: "Add a real-time character count to the editor." Straightforward feature. But the editor lives in a deeply nested component tree:

app/projects/[id]/page.tsx          (Server Component)
  └── ProjectLayout.tsx              (Server Component - fetches project data)
      └── EditorContainer.tsx        (Server Component - fetches document)
          └── 'use client'
              └── Editor.tsx         (Client Component - interactive editor)
                  └── Toolbar.tsx    (Client Component)

The spec assumes "the editor" is a single thing. The codebase reveals it's a server-client boundary with data flowing through props from server-fetched sources. Adding a character count that updates in real-time requires understanding which component owns the state and where the 'use client' boundary sits.

What goes wrong: An engineer adds the character count to Editor.tsx (client component). It works. But the spec also mentioned "show the count in the project sidebar." The sidebar is a server component. Now the engineer needs to either: lift state up across the client/server boundary using a provider pattern, or duplicate the count calculation with a separate API call. Neither option was in the estimate.

What code-aware specs do differently: The spec identifies the boundary: "The editor (Editor.tsx) is a client component. The project sidebar (ProjectSidebar.tsx) is a server component rendered in the layout. Real-time character count in the editor is straightforward — add local state. Displaying the count in the sidebar requires crossing the server/client boundary. Recommended approach: add a CharacterCountProvider wrapping both components inside the existing ClientProviders boundary at app/projects/[id]/layout.tsx:L12."

The spec doesn't just describe the feature. It describes the feature in the context of the architecture.

The Common Thread

All three patterns share a root cause: specs written without codebase awareness describe features in an idealized vacuum. The auth spec assumes auth is simple. The API spec assumes creation is a single insert. The component spec assumes the UI is a flat hierarchy.

Real codebases are none of these things. They have middleware chains, transaction pipelines, and architectural boundaries that fundamentally shape how features get built.

The fix isn't better PMs or more experienced engineers. It's giving the spec-writing process access to the code it's describing changes to. When a spec tool can read the middleware file, parse the route handler, and trace the component tree, the spec reflects reality instead of assumptions.

That's what Stonewall does. It reads your codebase — the actual files, schemas, and patterns — and generates specs that account for how your system works today. Not how someone imagines it works.

Five hundred repos. Three patterns. One root cause. Specs need to know about code.

Related Posts