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
/aboutto/en/aboutbased onAccept-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:
- Middleware — coarse gate (has a session cookie?).
- Server Components / Server Actions — verify session server-side before returning data or performing mutations.
- 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; configureoutput: "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.