Right, so you’ve got your tRPC routes humming and your Next.js pages rendering. Feels good, doesn’t it? That end-to-end type safety is like a warm blanket on a cold night. But now you want to get fancy. You want to intercept requests, add some auth, set some headers, or maybe run some logic at the edge. You’re diving into middleware and edge functions, and you’re wondering if TypeScript’s warm blanket turns into a straitjacket here. The answer is: it depends on how much you fight the framework. Let’s get into it.

The Middleware Minefield (and How to Navigate It)

Next.js middleware is fantastic. It runs before your pages or API routes, perfect for authentication, redirects, rewrites, or even bot detection. But its execution context is weird. It’s not Node.js, it’s not a browser—it’s this special V8 runtime that Next.js sets up. This means a lot of Node-specific APIs you might be used to are simply not available. No fs, no path, nada.

The first, and most important, rule of TypeScript in middleware: you must use the NextResponse and NextRequest objects. Don’t even think about using the standard Response and Request from the Web APIs. Why? Because Next.js extends them with crucial methods like .next(), .rewrite(), and .cookie(). If you use the standard types, you’ll lose type safety on those very methods you need.

Here’s the baseline. Create a middleware.ts file at the root of your project (or in your src folder).

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Let's check for a secret cookie before allowing access to /dashboard
  const isAdmin = request.cookies.get('super-secret-admin-cookie')?.value;

  if (!isAdmin && request.nextUrl.pathname.startsWith('/dashboard')) {
    // Absolutely redirect them using the NextURL object
    const loginUrl = new URL('/login', request.url);
    return NextResponse.redirect(loginUrl);
  }

  // Otherwise, just let the request through
  return NextResponse.next();
}

// Configure your matcher! Don't run this on every single request.
export const config = {
  matcher: '/dashboard/:path*',
};

See how we’re using NextRequest? It has the nextUrl and cookies properties typed correctly. Using the standard Request would cause TypeScript to yell at you for trying to access request.nextUrl. This is the framework guiding you—listen to it.

Taming the Matcher Config with Types

That config.matcher is powerful but string-based, which is a recipe for typos. While you can’t get full type safety on the path strings themselves, you can use TypeScript to ensure your matcher logic is sound. For example, if you have a list of protected routes, define them as a constant array and then derive your matcher.

// middleware.ts
const protectedRoutes = ['/dashboard', '/settings', '/api/trpc/secret.'] as const;

export const config = {
  // This creates a matcher for: /dashboard, /dashboard/*, /settings, /settings/*, etc.
  matcher: protectedRoutes.map(route => `${route}/:path*`),
};

This way, if you change a route in your application, you can update the protectedRoutes array and your middleware will automatically stay in sync. It’s a simple pattern, but it prevents the “why is my middleware not running?!” debugging session.

Edge Functions: TypeScript on the Bleeding Edge

Edge Functions are like middleware’s more capable sibling. They run on the same V8 isolate but are actual API endpoints. The same rules apply: limited Node.js APIs. The big win here is that you can use your tRPC types directly, but it requires a bit of setup.

Let’s say you want to create an edge API route that uses your tRPC router’s types for validation. You can’t import the server directly because it likely uses Node.js APIs (like fs for reading files). But you can import your router definition. This is where separating your router’s instantiation from its definition pays off.

// /src/server/router/_app.ts
import { router } from '../trpc';
import { postRouter } from './post';

// This is just the type definition, no Node APIs here!
export const appRouter = router({
  post: postRouter,
});

// Export type *only* for use on the client and in edge functions
export type AppRouter = typeof appRouter;

Now, in your Edge API route, you can import the type and use it with a lightweight client.

// /src/app/api/edge-preview/route.ts
import { type NextRequest } from 'next/server';
import { type AppRouter } from '@/server/router/_app';
import { getAuth } from '@clerk/nextjs/server'; // Example auth provider

// This is an Edge Function!
export const runtime = 'edge';

export async function POST(req: NextRequest) {
  try {
    // Get auth state from the request (this is just an example)
    const { userId } = getAuth(req);

    // We can't use the full tRPC server, but we can get the input parser
    // Assume we have a `postById` procedure that takes a `{ id: number }` input
    const input: AppRouter['postById']['_def']['_input_in'] = await req.json();

    // Now you can validate your input manually here, or use a library like Zod directly.
    // You have the type, so you know what it's *supposed* to be.

    // ... do some edge-cache logic or geolocation-based stuff ...

    return new Response(JSON.stringify({ success: true, userId, input }), {
      status: 200,
    });
  } catch (error) {
    return new Response(JSON.stringify({ error: 'Invalid input' }), {
      status: 400,
    });
  }
}

This is advanced, but incredibly powerful. You’re leaching the types from your central tRPC definition and using them in a completely different runtime. That’s the real superpower of TypeScript in this full-stack context.

The Pitfalls: What Will Absolutely Bite You

  1. Assuming Node.js APIs Exist: This is the big one. process.env has to be accessed differently. You need to use NextRequest’s cookies instead of cookie parsers. Always check the Next.js Edge Runtime docs before assuming a module will work.
  2. Bundle Bloat: The edge runtime has strict limits. If you import a massive library that uses Node APIs, it will fail. Use your bundler’s edge condition to avoid even trying to bundle it.
  3. Type Casting Blindly: You’ll be tempted to do req as NextRequest everywhere. Resist. Structure your code so the correct type is inferred from the start. If you’re casting, it’s often a sign you’re importing the wrong thing.

The designers made a choice here: power and performance over familiarity. It’s a trade-off, but once you learn the rules of the new game, you realize it’s a fair one. The type system is your map through this unfamiliar territory. Use it aggressively.