Deploying Next.js to Production

What Actually Matters

15 min readDevOps

Production is not a larger localhost. It is a different beast. Your code runs on unknown hardware, at scale, with real people depending on it. Treat it differently.

Pre-deployment Checklist

Before moving anything to production, ensure these are done:

  • Environment validation: Your app should crash on startup if env vars are missing or invalid.
  • Database migrations: Automate them. Never run migrations manually.
  • Error tracking: Sentry, Rollbar, or similar. Silent errors kill businesses.
  • Health checks: Implement /health endpoints that verify database connectivity.

Environment Configuration

Validate all environment variables at startup. Fail loudly if anything is missing:

// next.config.ts
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  REDIS_URL: z.string().url(),
  NODE_ENV: z.enum(["development", "production", "test"]),
  NEXT_PUBLIC_API_URL: z.string().url(),
});

const env = envSchema.parse(process.env);

export default {
  // ... rest of config
};

Connection Pooling

Never create a new database connection per request. Connection pools manage reuse efficiently:

// src/lib/db.ts
import { Pool } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // Max connections
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

pool.on("error", (error) => {
  console.error("Unexpected error on idle client", error);
  process.exit(-1);
});

export async function query(text: string, params?: any[]) {
  const start = Date.now();
  try {
    const result = await pool.query(text, params);
    const duration = Date.now() - start;
    
    if (duration > 1000) {
      console.warn(
        `Slow query (${duration}ms): ${text}`
      );
    }
    
    return result.rows;
  } catch (error) {
    console.error("Database error:", error);
    throw error;
  }
}

Graceful Shutdown

When SIGTERM arrives, finish in-flight requests before exiting:

// main.ts
process.on("SIGTERM", async () => {
  console.log("SIGTERM received. Shutting down gracefully...");
  
  // Stop accepting new requests
  server.close(() => {
    console.log("HTTP server closed");
  });

  // Close database connections
  await pool.end();
  console.log("Database pool closed");

  // Close any other resources
  await redis.quit();
  console.log("Redis connection closed");

  // Exit
  process.exit(0);
});

Monitoring in Production

You cannot fix what you cannot see. Implement monitoring from day one:

// lib/monitor.ts
import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.1, // 10% of transactions
  integrations: [
    new Sentry.Integrations.Http({ tracing: true }),
  ],
});

export function captureException(error: unknown) {
  Sentry.captureException(error);
}

// In your API routes
export async function GET(request: NextRequest) {
  try {
    const data = await fetchData();
    return NextResponse.json(data);
  } catch (error) {
    captureException(error);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

Key Takeaways

  • Production requires explicit monitoring and alerting
  • Connection pooling is non-negotiable at scale
  • Graceful shutdown prevents data corruption
  • Slow query logging catches performance regressions early
  • Environment validation catches configuration errors before users see them

Future Improvements

  • Implement load testing to find bottlenecks
  • Create deployment checklist automation
  • Set up performance monitoring dashboards
  • Document runbooks for common incidents
← Back to all articles