DEV Community

Cover image for React in Production — Ship It, Monitor It, Sleep at Night
Kushang Tailor
Kushang Tailor

Posted on

React in Production — Ship It, Monitor It, Sleep at Night

Read Time: ~15 minutes | The gap between "it works on my machine" and "it works for everyone, always"

Prerequisites: The full React Mastery Series — Parts 1–5


Part 6: The Finale 🎉


📌 What You'll Learn

By the end of this guide, you'll be able to:

  • ✅ Implement authentication with JWT and session cookies
  • ✅ Add OAuth (Google / GitHub login) in minutes
  • ✅ Set up CI/CD with GitHub Actions to automate your deployments
  • ✅ Catch and monitor errors in production with Sentry
  • ✅ Protect routes and handle auth state globally
  • ✅ Avoid the most common production mistakes React developers make
  • ✅ Understand what "production-ready" actually means in practice

🏁 You Made It Here — Now What?

Five articles in, you can build fast, well-structured, testable React apps. You know your hooks, your state patterns, your performance levers, and your Next.js data flows.

What you haven't done yet is ship something real, break it, watch it burn, and fix it gracefully — that's what production is.

This final part covers the things that separate a side project from a product people actually rely on: authentication that doesn't embarrass you, a CI/CD pipeline that ships your code automatically, and error monitoring that tells you something broke before your users do.


🔐 Authentication: JWT vs Session Cookies

Authentication is one of those topics where developers either overcomplicate it or cut too many corners. Let's get the fundamentals right first.

The Two Main Approaches

JWT (JSON Web Token)
└─ Token stored on client (localStorage or cookie)
└─ Server is stateless — doesn't store sessions
└─ Fast to verify (no DB lookup per request)
└─ Hard to invalidate before expiry (logout is tricky)

Session Cookies
└─ Session ID stored in an httpOnly cookie
└─ Session data lives on the server (DB or cache)
└─ Easy to invalidate (delete the session record)
└─ Requires DB lookup on every authenticated request
Enter fullscreen mode Exit fullscreen mode

Recommendation for most apps: session cookies with httpOnly — more secure by default, easier logout, slightly more setup.

JWT makes sense when: you have multiple services that need to verify auth without sharing a database (microservices, third-party APIs).

Building JWT Auth in Next.js

Install dependencies:

npm install jsonwebtoken bcryptjs
npm install --save-dev @types/jsonwebtoken @types/bcryptjs
Enter fullscreen mode Exit fullscreen mode

Step 1 — User login API route:

// app/api/auth/login/route.ts
import { NextRequest, NextResponse } from 'next/server';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';

const JWT_SECRET = process.env.JWT_SECRET!;

export async function POST(request: NextRequest) {
  const { email, password } = await request.json();

  // 1. Find user in database
  const user = await db.users.findUnique({ where: { email } });

  if (!user) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  // 2. Compare hashed password
  const isValid = await bcrypt.compare(password, user.passwordHash);

  if (!isValid) {
    return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
  }

  // 3. Create token (expires in 7 days)
  const token = jwt.sign(
    { userId: user.id, email: user.email },
    JWT_SECRET,
    { expiresIn: '7d' }
  );

  // 4. Set token in httpOnly cookie (safer than localStorage)
  const response = NextResponse.json({ success: true });
  response.cookies.set('auth-token', token, {
    httpOnly: true,   // JS can't read this cookie
    secure: true,     // HTTPS only
    sameSite: 'lax',  // CSRF protection
    maxAge: 60 * 60 * 24 * 7, // 7 days in seconds
  });

  return response;
}
Enter fullscreen mode Exit fullscreen mode

Step 2 — Verify token in middleware:

// middleware.ts (runs before every request)
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify } from 'jose'; // jose works in Edge runtime; jsonwebtoken doesn't

const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('auth-token')?.value;

  // Protect /dashboard and all routes under it
  if (request.nextUrl.pathname.startsWith('/dashboard')) {
    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    try {
      await jwtVerify(token, JWT_SECRET);
      return NextResponse.next(); // Token valid — let the request through
    } catch {
      // Token invalid or expired
      const response = NextResponse.redirect(new URL('/login', request.url));
      response.cookies.delete('auth-token');
      return response;
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*'], // Only run middleware on these routes
};
Enter fullscreen mode Exit fullscreen mode

Step 3 — Logout:

// app/api/auth/logout/route.ts
import { NextResponse } from 'next/server';

export async function POST() {
  const response = NextResponse.json({ success: true });
  response.cookies.delete('auth-token');
  return response;
}
Enter fullscreen mode Exit fullscreen mode

🌐 OAuth: Google & GitHub Login in Minutes

OAuth is the "Sign in with Google" button your users already expect. In Next.js, NextAuth.js (Auth.js v5) handles the entire OAuth dance for you.

npm install next-auth@beta
Enter fullscreen mode Exit fullscreen mode

Configure Auth.js:

// auth.ts (project root)
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
    GitHub({
      clientId: process.env.GITHUB_CLIENT_ID!,
      clientSecret: process.env.GITHUB_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    // Add custom data to the session
    session({ session, token }) {
      if (session.user && token.sub) {
        session.user.id = token.sub;
      }
      return session;
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

Mount the handler:

// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth';
export const { GET, POST } = handlers;
Enter fullscreen mode Exit fullscreen mode

Protect a page with the session:

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

export default async function DashboardPage() {
  const session = await auth();

  if (!session) {
    redirect('/login'); // Not signed in — send them away
  }

  return (
    <div>
      <h1>Welcome, {session.user?.name}</h1>
      <p>Logged in as {session.user?.email}</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Sign in / sign out buttons:

'use client';
import { signIn, signOut } from 'next-auth/react';

export function AuthButtons() {
  return (
    <div>
      <button onClick={() => signIn('google')}>Sign in with Google</button>
      <button onClick={() => signIn('github')}>Sign in with GitHub</button>
      <button onClick={() => signOut()}>Sign Out</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, and NEXTAUTH_SECRET to your .env.local, and you have fully working social login.


🔄 CI/CD with GitHub Actions

Shipping manually is how bugs reach production. A CI/CD pipeline runs your tests, checks your build, and deploys automatically — every single time you push code.

What the Pipeline Does

Developer pushes code to GitHub
         ↓
GitHub Actions triggers automatically
         ↓
Install dependencies
         ↓
Run linter (ESLint)
         ↓
Run type checker (TypeScript)
         ↓
Run test suite (Jest)
         ↓
Build the app
         ↓
If all green → Deploy to Vercel ✅
If any red   → Block deploy, notify developer ❌
Enter fullscreen mode Exit fullscreen mode

Full GitHub Actions Workflow

# .github/workflows/ci.yml

name: CI / CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  quality:
    name: Lint, Type Check & Test
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run ESLint
        run: npm run lint

      - name: Run TypeScript check
        run: npx tsc --noEmit

      - name: Run tests
        run: npm test -- --coverage --watchAll=false
        env:
          CI: true

      - name: Build app
        run: npm run build
        env:
          NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}

  deploy:
    name: Deploy to Vercel
    needs: quality          # Only runs if the quality job passes
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' # Only deploy from main branch

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v25
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: '--prod'
Enter fullscreen mode Exit fullscreen mode

Add VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_PROJECT_ID to your GitHub repository's Secrets (Settings → Secrets and variables → Actions). Get the token from Vercel's dashboard.

Now every pull request runs the full quality check. Only clean, tested, built code reaches production.


🚨 Error Monitoring with Sentry

Tests catch bugs before users see them. Sentry catches the ones that slip through anyway — and tells you exactly where, why, and how often.

Setup

npm install @sentry/nextjs
npx @sentry/wizard@latest -i nextjs
Enter fullscreen mode Exit fullscreen mode

The wizard creates sentry.client.config.ts, sentry.server.config.ts, and sentry.edge.config.ts automatically. You just need your DSN from the Sentry dashboard.

sentry.client.config.ts:

import * as Sentry from '@sentry/nextjs';

Sentry.init({
  dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
  tracesSampleRate: 0.1,     // Capture 10% of transactions for performance
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0, // Always capture replay when there's an error
  environment: process.env.NODE_ENV,
});
Enter fullscreen mode Exit fullscreen mode

Capturing Errors Manually

import * as Sentry from '@sentry/nextjs';

async function processPayment(orderId: string) {
  try {
    const result = await paymentGateway.charge(orderId);
    return result;
  } catch (error) {
    // Send to Sentry with context
    Sentry.captureException(error, {
      tags: { component: 'checkout', action: 'processPayment' },
      extra: { orderId },
    });
    throw error; // Re-throw — don't silently swallow it
  }
}
Enter fullscreen mode Exit fullscreen mode

Setting User Context

After login, tell Sentry who the user is — errors become instantly attributable:

import * as Sentry from '@sentry/nextjs';

function onLoginSuccess(user: User) {
  Sentry.setUser({
    id: user.id,
    email: user.email,
    username: user.name,
  });
}

function onLogout() {
  Sentry.setUser(null); // Clear on logout
}
Enter fullscreen mode Exit fullscreen mode

Now when an error hits Sentry, you see exactly which user experienced it, what they were doing, and the full stack trace — no guessing.


🗂️ Production Environment Variables

Your app has three environments, and each needs its own config:

# .env.local (development — never commit this)
DATABASE_URL=postgresql://localhost:5432/myapp_dev
NEXT_PUBLIC_API_URL=http://localhost:3000
JWT_SECRET=dev-secret-not-for-production

# Vercel → Project Settings → Environment Variables → Preview
DATABASE_URL=postgresql://host/myapp_staging
NEXT_PUBLIC_API_URL=https://staging.myapp.com
JWT_SECRET=staging-secret-32-chars-minimum

# Vercel → Project Settings → Environment Variables → Production
DATABASE_URL=postgresql://host/myapp_prod
NEXT_PUBLIC_API_URL=https://myapp.com
JWT_SECRET=prod-secret-64-chars-cryptographically-random
Enter fullscreen mode Exit fullscreen mode

Rules to live by:

// ✅ NEXT_PUBLIC_ prefix = visible in the browser (safe for public URLs)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;

// ✅ No prefix = server-only (safe for secrets, DB URLs, API keys)
const dbUrl = process.env.DATABASE_URL;

// ❌ Never put secrets in NEXT_PUBLIC_ variables
// ❌ Never commit .env.local to Git (add it to .gitignore)
// ❌ Never hardcode secrets — even "temporarily"
Enter fullscreen mode Exit fullscreen mode

🧱 Production-Ready Checklist

Before you consider any app production-ready, run through this honestly:

Security

□ All auth routes protected by middleware
□ Passwords hashed with bcrypt (never stored plain)
□ httpOnly cookies used (not localStorage for tokens)
□ Environment variables never committed to Git
□ API routes validate and sanitize all input
□ Rate limiting on login and sensitive endpoints
□ HTTPS enforced (Vercel does this automatically)
Enter fullscreen mode Exit fullscreen mode

Reliability

□ Error Boundaries wrap independent UI sections
□ Sentry (or equivalent) capturing all unhandled errors
□ API routes return meaningful error messages + HTTP codes
□ Loading and error states handled in every data component
□ CI pipeline blocks deploys when tests fail
Enter fullscreen mode Exit fullscreen mode

Performance

□ Images use next/image (WebP, lazy loading, no CLS)
□ Fonts loaded via next/font (no layout shift)
□ Route-based code splitting in place
□ No console.log left in production code
□ Largest Contentful Paint (LCP) < 2.5s
Enter fullscreen mode Exit fullscreen mode

Observability

□ Error monitoring active (Sentry)
□ Performance monitoring configured
□ Structured logging in API routes
□ Alerts set up for error spikes
Enter fullscreen mode Exit fullscreen mode

💣 Production Mistakes Worth Learning From

These are real patterns that get real apps in trouble — most of them avoidable with about 20 minutes of setup.

Mistake 1: Storing tokens in localStorage

// ❌ XSS attack can steal this
localStorage.setItem('token', jwtToken);

// ✅ httpOnly cookie — JavaScript can't touch it
response.cookies.set('auth-token', token, { httpOnly: true, secure: true });
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Not validating API inputs

// ❌ Trusting anything the client sends
export async function POST(req: NextRequest) {
  const { email, role } = await req.json();
  await db.users.create({ data: { email, role } }); // 😱 user just gave themselves 'admin'
}

// ✅ Validate the shape and strip unexpected fields
export async function POST(req: NextRequest) {
  const body = await req.json();
  const email = String(body.email ?? '').trim();

  if (!email || !email.includes('@')) {
    return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
  }

  await db.users.create({ data: { email, role: 'user' } }); // role is hardcoded
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Exposing server errors to clients

// ❌ Leaks your stack trace and internals
return NextResponse.json({ error: error.message }, { status: 500 });

// ✅ Log the real error server-side, send a generic message
console.error('[API Error]', error);
Sentry.captureException(error);
return NextResponse.json({ error: 'Something went wrong.' }, { status: 500 });
Enter fullscreen mode Exit fullscreen mode

Mistake 4: No rate limiting on auth routes

// Install: npm install @upstash/ratelimit @upstash/redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'), // 5 attempts per minute
});

export async function POST(request: NextRequest) {
  const ip = request.ip ?? '127.0.0.1';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json({ error: 'Too many attempts.' }, { status: 429 });
  }

  // ... rest of login logic
}
Enter fullscreen mode Exit fullscreen mode

Mistake 5: Forgetting to handle token expiry on the client

// ✅ Intercept 401 responses globally and redirect to login
export async function fetchWithAuth(url: string, options?: RequestInit) {
  const response = await fetch(url, options);

  if (response.status === 401) {
    // Token expired — send user back to login
    window.location.href = '/login';
    return;
  }

  return response;
}
Enter fullscreen mode Exit fullscreen mode

🎓 The Full Journey — What You've Built Over This Series

You started with "What is React?" and you're finishing with production deployments, auth systems, and error monitoring. That's not a small leap. Here's everything you've learned across all six parts:

Part 1 — Foundations
   React, components, hooks, setup, file structure

Part 2 — Advanced Hooks & State
   Custom hooks, useReducer, Context, Redux, Zustand, Jotai

Part 3 — Performance
   React.memo, useMemo, useCallback, code splitting, virtualization

Part 4 — Full-Stack with Next.js
   App Router, Server Components, API routes, deployment

Part 5 — Testing & Debugging
   Jest, React Testing Library, async tests, Error Boundaries

Part 6 — Production
   Auth, OAuth, CI/CD, Sentry, security, production patterns
Enter fullscreen mode Exit fullscreen mode

You now have the full picture. Not just "React developer" — full-stack, production-capable, test-covered React engineering.


🔗 Quick Resources


💬 You've Reached the End — What's Next for You?

Six articles. A complete React engineering curriculum. What are you building with it?

If you've followed this series from Part 1, I'd genuinely love to hear what you took away most — a concept that finally clicked, a project you shipped, or even something I got wrong. Drop it in the comments.

And if you're hungry for what comes after production React, the natural next steps are: React Native for mobile, Turborepo for monorepos, and tRPC for end-to-end type safety without REST boilerplate.


Go build something real. 🚀

Top comments (0)