Next.js Confused Me on Purpose: A Field Guide to Framework Gotchas

Retro developer workspace illustrating Next.js framework gotchas with middleware, prerendering, Postgres, and TypeScript
Next.js can feel confusing when framework conventions, prerendering behavior, database quirks, and TypeScript rules aren’t obvious at first.

If you’ve ever built something with Next.js and Postgres, you’ve probably hit a moment where the app does something that looks broken but technically isn’t. Your database ID jumps from 2 to 35 out of nowhere. Next.js throws a scary-looking error about “blocking” your route. Your editor yells at you for naming a file wrong. None of these mean you broke something — they mean the framework is trying to tell you how it actually works under the hood.

Here are three of the most common “wait, is this a bug?” moments, explained without the panic.

Why Your Middleware Isn’t Running (Even Though the Code Is “Right”)

Here’s a fun one: you write a perfectly valid function that redirects requests, the logic is airtight, TypeScript isn’t complaining — and Next.js just… never runs it. No error. No warning. It just quietly does nothing.

export function proxy(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/admin")) {
    return NextResponse.redirect(new URL("/", request.url));
  }
}

This looks like middleware. It smells like middleware. But Next.js won’t touch it, for two very specific, very unforgiving reasons: the file isn’t named middleware.ts, and the function isn’t named middleware.

Next.js doesn’t scan your project for “a function that looks like it redirects things.” It looks for one exact convention: a file called middleware.ts (or .js) at your project root, or inside src/, exporting a function named middleware. Call it proxy, put it in the wrong folder, or misspell it, and you don’t get an error — you just get silence, which is arguably worse.

The fix is boring on purpose:

// middleware.ts (project root or src/)
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith("/admin")) {
    return NextResponse.redirect(new URL("/", request.url));
  }
}

export const config = {
  matcher: "/((?!api|_next/static|_next/image|.*\\.png$).*)",
};

That config.matcher is worth a second look too — it’s a regex telling Next.js which routes should even bother running your middleware. The pattern above says “run on everything except API routes, static assets, optimized images, and PNGs,” which keeps you from redirecting your own favicon by accident.

The lesson: some frameworks infer intent from your code. Next.js middleware isn’t one of them — it wants the exact filename and the exact export name, full stop.

“Blocking RouteServer” — Why Next.js Is Yelling About Prerendering

Next.js middleware, prerendering, Postgres ID gaps, and TypeScript .ts versus .tsx gotchas
Next.js framework gotchas make more sense once you understand the conventions behind middleware, prerendering, database IDs, and TypeScript file extensions.

If you’ve fetched data straight from a database inside a page component, you may have run into this warning:

Next.js encountered uncached data during prerendering. This prevents the route from being prerendered, blocking navigation and leading to a slower user experience.

Translated: Next.js tried to build this page ahead of time (prerendering), but your database call can only happen when a real request comes in. That makes the whole page “block” — nobody sees anything until the query finishes.

Next.js gives you three honest options here, not just one “correct” answer:

1. Stream it with Suspense. Pull the dynamic part into its own component, wrap it in Suspense, and let the rest of the page load instantly while that piece streams in behind a fallback.

async function PostsList() {
  const posts = await prisma.post.findMany();
  return <ul>{/* render posts */}</ul>;
}

export default function PostsPage() {
  return (
    <div>
      <h1>Posts</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <PostsList />
      </Suspense>
    </div>
  );
}

2. Cache it. If the data doesn’t need to be fetched fresh on every request, mark the function with “use cache” so Next.js can treat it like static content.

3. Just allow the block. Sometimes a page genuinely needs fresh data every time and there’s no useful “shell” to show early. In that case you can tell Next.js you’re doing this on purpose:

export const instant = false;

Worth knowing: instant = false doesn’t change how your page behaves at all. It doesn’t optimize anything or add caching. It just turns off the warning for that one route, essentially saying “yes, I know, I meant to do that.” It’s an escape hatch, not a fix — treat it as a temporary pass, not a permanent answer, unless you’re sure that route genuinely needs fresh data on every load.

.ts vs .tsx: The Extension That Actually Means Something

This one trips up a lot of people moving into TypeScript + React, and the rule is simpler than it looks:

Does the file contain JSX (like <div> or <Button />)? Use .tsx. Does it not? Use .ts.

That’s genuinely it. The extension isn’t about what a file “does” conceptually — it’s a signal to the compiler about how to parse < characters. Without .tsx, TypeScript can’t tell whether <Post> is a JSX tag or a generic type comparison, and it’ll throw a fit.

A quick cheat sheet:

  • Page or component that returns markup → .tsx
  • Server action that just runs logic and talks to a database → .ts
  • Utility functions, type definitions, middleware → .ts
  • Anything with a literal return (<div>...) in it → .tsx

.ts file can absolutely still be used by React components, call hooks indirectly, or be deeply “React-related” — the extension only cares about whether JSX syntax physically appears in that specific file.

The Takeaway

None of these are bugs. They’re the framework and the database being honest about tradeoffs you didn’t know you were making:

  • Postgres trades gap-free IDs for speed — deal with it, don’t fight it.
  • Next.js wants you to be explicit about what’s fast and what’s allowed to be slow.
  • TypeScript just wants to know, up front, whether it should expect angle brackets to mean HTML or math.

Once you know the “why,” none of these errors are scary anymore — they’re just the tools doing their job a little too honestly.