Next.jsAdvanced

Next.js Production: Middleware, Streaming & Deployment

Ship Next.js apps with confidence. Run edge middleware for redirects and auth gates, stream UI with Suspense, optimize images and fonts, handle errors gracefully, and understand deployment trade-offs.

4 sections · ~35 min · 5-question quiz (pass ≥ 70%)

1Middleware: Edge Logic Before the Request

middleware.ts at the project root (or src/) runs before a request completes — on the Edge runtime by default:

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

export function middleware(request: NextRequest) {
  const token = request.cookies.get("session")?.value;
  const isAuthPage = request.nextUrl.pathname.startsWith("/login");

  if (!token && request.nextUrl.pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  if (token && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/login"],
};

Common middleware use cases:

  • Authentication gates — redirect unauthenticated users before the page renders.
  • Locale detection — rewrite /about to /en/about based on Accept-Language.
  • A/B testing — set a cookie and rewrite to variant routes.
  • Security headers — inject CSP, HSTS, or frame options on every response.

Middleware is fast (runs at the edge) but has limits: no Node.js APIs, no direct database access. Validate sessions via JWT/cookie parsing or call an edge-compatible auth provider. Heavy authorization logic can run in Server Components after middleware performs a lightweight gate.

2Auth Patterns, Streaming & Suspense

Auth in App Router apps typically combines three layers:

  1. Middleware — coarse gate (has a session cookie?).
  2. Server Components / Server Actions — verify session server-side before returning data or performing mutations.
  3. Client state — optional UI polish (show avatar, handle client-only OAuth redirects).

Never trust client-only checks for protected data. Always re-verify on the server:

// app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/lib/auth";

export default async function DashboardPage() {
  const session = await getSession();
  if (!session) redirect("/login");

  const stats = await db.stats.findMany({ where: { userId: session.userId } });
  return <StatsPanel stats={stats} />;
}

Streaming with Suspense sends HTML progressively instead of waiting for everything:

import { Suspense } from "react";

function SlowChart() { /* async server component */ }

export default function Page() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={<ChartSkeleton />}>
        <SlowChart />
      </Suspense>
    </main>
  );
}

The shell renders immediately; SlowChart streams in when ready. Pair with loading.tsx for route-level fallbacks. Streaming improves Time to First Byte and perceived performance on data-heavy pages.

3Performance: Images, Fonts & Core Web Vitals

next/image automatically optimizes images — responsive sizes, lazy loading, WebP/AVIF conversion:

import Image from "next/image";

export function Hero() {
  return (
    <Image
      src="/images/hero.png"
      alt="Team collaboration"
      width={1200}
      height={630}
      priority
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,..."
    />
  );
}

Use priority for above-the-fold images (LCP candidates). Always provide width and height (or fill with a sized parent) to prevent layout shift (CLS).

Font optimization with next/font self-hosts fonts and eliminates layout shift:

import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"], display: "swap" });

export default function RootLayout({ children }) {
  return (
    <html lang="en" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}

Monitor Core Web Vitals (LCP, INP, CLS) in production. Common fixes: reduce Client Component bundle size, stream slow data, optimize images, and avoid layout-shifting dynamic content without reserved space.

4Error Boundaries, Loading States & Deployment

error.tsx must be a Client Component — it catches runtime errors in its segment and siblings below:

"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

loading.tsx shows instant UI while the server prepares the segment — no configuration needed beyond creating the file.

global-error.tsx wraps the root layout for catastrophic failures.

Deployment considerations:

  • Vercel — zero-config, native support for ISR, edge middleware, and image optimization.
  • Node.js hosting (Docker, Railway, etc.) — run next build && next start; configure output: "standalone" for smaller containers.
  • Static export (output: "export") — only if you have zero server features (no SSR, API routes, or middleware).

Set NODE_ENV=production, use environment-specific .env files, enable analytics, and run next build locally before deploying to catch build-time errors early. Use preview deployments to validate dynamic routes and auth flows before promoting to production.

Ready to test yourself?

Sign in to take the quiz, track progress, and earn a certificate.

Sign in