---
title: "How I Made My Next.js Portfolio Actually Production-Ready (For $0)"
author: "Rantideb Howlader"
date: "2026-04-09T00:00:00.000Z"
canonical_url: "https://www.ranti.dev/blog/production-ready-nextjs-ci-cd-edge-firewall"
license: "CC-BY-4.0"
---


I'll be honest. For a long time, my portfolio was held together with hope.

![Production-Ready Architecture](/blog/production-ready-hero.webp)

The animations were smooth. The Lighthouse scores were green. It looked great in a demo. But if you pulled back the curtain, the reality was embarrassing: no CI pipeline, no automated tests, no rate limiting on the API routes, and nothing stopping a broken import from nuking the entire live site.

I'd push to `main`, refresh the browser, and pray.

That's not how real production software works. At any serious company, code goes through layers of automated checks before it reaches users. Bad formatting gets rejected at commit time. Builds are verified in isolated environments. API endpoints are protected from abuse. These aren't luxuries. They're table stakes.

So I decided to build all of it into my portfolio. Not to over-engineer a personal site, but to actually understand, hands-on, what production infrastructure feels like when you're the one setting it up.

The surprising part? Every tool I used is open-source, and I didn't spend a single dollar.

Here's the full breakdown.

## What I Was Actually Solving For

Before I started, I wrote down the specific failure modes I wanted to eliminate:

```mermaid
graph TD
    A["My Portfolio Before"] --> B["No CI Pipeline"]
    A --> C["No Automated Tests"]
    A --> D["Open API Routes"]
    A --> E["Analytics Blocking UI"]

    B --> F["Broken commits reach production"]
    C --> G["Silent regressions on 40+ routes"]
    D --> H["Bots can drain Firebase quota"]
    E --> I["Animations stutter on cheap phones"]

    style A fill:#991b1b,stroke:#fca5a5,color:#fca5a5
    style F fill:#7f1d1d,stroke:#f87171,color:#fca5a5
    style G fill:#7f1d1d,stroke:#f87171,color:#fca5a5
    style H fill:#7f1d1d,stroke:#f87171,color:#fca5a5
    style I fill:#7f1d1d,stroke:#f87171,color:#fca5a5
```

Each of these is a real problem. Not theoretical. The Firebase one especially. A basic `while true; do curl` loop pointed at my `/api/spotify` route could burn through my entire daily quota in under 10 minutes.

I started with five layers of defense, each catching a different class of failure.

## Layer 1: Kill Bad Code at the Keyboard (Husky + Prettier)

The cheapest bug to fix is the one that never makes it into your Git history. That's what "shift left" means in practice: move your quality gates as early in the pipeline as possible.

I set up three tools that chain together:

```mermaid
sequenceDiagram
    participant Dev as Developer
    participant Git as git commit
    participant Husky as Husky Hook
    participant LS as lint-staged
    participant P as Prettier + ESLint

    Dev->>Git: git commit -m "fix header"
    Git->>Husky: Pre-commit hook triggered
    Husky->>LS: Identify staged files only
    LS->>P: Format & lint staged files

    alt Code passes
        P-->>Git: Allow commit
    else Code fails
        P-->>Dev: Reject commit with errors
    end
```

**Husky** intercepts `git commit` before Git records anything. **lint-staged** identifies only the files you've changed (no point scanning your entire codebase every time). **Prettier** reformats those files to a strict, consistent style.

If there's a syntax issue or ESLint violation, the commit gets rejected before it even exists in your local history.

### Setup

```bash
pnpm add -D husky prettier lint-staged
npx husky init
```

The pre-commit hook (`.husky/pre-commit`):

```bash
#!/bin/sh
pnpm exec lint-staged
```

The lint-staged config (`.lintstagedrc.js`):

```javascript
module.exports = {
  "*.{js,jsx,ts,tsx,json,css,md}": ["prettier --write"],
};
```

Three files. That's it. Every developer who touches the repo now has automatic formatting enforced at the Git level. No IDE plugins required, no "please remember to run prettier" messages in Slack.

### The First Run Is Painful (And That's the Point)

When I first ran Prettier across my codebase, it flagged **326 files** with inconsistencies. Tabs vs spaces, trailing commas, quote style. Years of accumulated drift. I ran `pnpm prettier --write` once, committed the mass-format, and never thought about it again.

That single commit was probably the highest-impact code quality improvement I've ever made.

## Layer 2: Verify Every Push in Isolation (GitHub Actions)

Pre-commit hooks run on your laptop. But what if someone clones the repo without Husky installed? What if they force-push? You need a second gate that runs on neutral ground.

```mermaid
graph LR
    A["git push"] --> B["GitHub Actions Triggered"]
    B --> C["Install Dependencies"]
    C --> D["Run ESLint"]
    D --> E["Check Prettier"]
    E --> F["Full Production Build"]

    F -->|Pass| G["✅ Green Checkmark"]
    F -->|Fail| H["❌ PR Blocked"]

    class G mm-green
    style H fill:#991b1b,stroke:#f87171,color:#fca5a5
```

Every push to `main` and every pull request triggers this pipeline. It installs dependencies from scratch (no cache shortcuts), lints the code, checks formatting in read-only mode, and runs a full production build.

```yaml
name: CI

on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]

jobs:
  build-and-lint:
    runs-on: ubuntu-latest
    env:
      FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v3
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "pnpm"

      - run: pnpm install
      - run: pnpm lint
      - run: pnpm prettier --check "**/*.{js,jsx,ts,tsx,json,css,md}"
      - run: pnpm build
```

If any step fails, the commit gets a red X and merging is blocked.

### Quick Note: The Node.js Version Confusion

You might notice `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` alongside `node-version: "20"`. These are two completely different things.

GitHub Actions uses Node.js internally to run its own action scripts (`actions/checkout`, etc.). That internal runtime is migrating to Node 24. The env flag tells GitHub's scaffolding to use the newer version, which suppresses deprecation warnings.

Your actual application still builds on Node 20. They're separate execution contexts running on the same machine.

## Layer 3: Catch What Linting Can't (Vitest + Playwright)

Linting catches syntax problems. The build step catches type errors. But neither catches behavioral regressions, the kind where a change to a shared utility function silently breaks three pages you haven't opened in months.

I set up two layers of testing:

```mermaid
graph TD
    A["Testing Strategy"] --> B["Unit Tests - Vitest"]
    A --> C["E2E Tests - Playwright"]

    B --> D["Component isolation"]
    B --> E["Utility function logic"]
    B --> F["Mocked JSDOM environment"]
    B --> G["Runs in milliseconds"]

    C --> H["Real browser engines"]
    C --> I["Full navigation flows"]
    C --> J["Network request assertions"]
    C --> K["Multi-browser: Chromium + WebKit"]

    style B fill:#1e3a5f,stroke:#60a5fa,color:#bfdbfe
    style C fill:#3b1f5e,stroke:#a78bfa,color:#ddd6fe
```

**Vitest** handles unit tests. Isolated component rendering, utility function logic, expected return values. It uses JSDOM to simulate a browser environment without actually opening one, so tests complete in milliseconds.

**Playwright** handles the scary stuff. It launches real Chromium and WebKit browsers, navigates your routes, clicks buttons, waits for API responses, and asserts that specific elements appeared on the page. If a hydration mismatch or CSS layout break happens, Playwright catches it.

```typescript
// playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  webServer: {
    command: "pnpm dev",
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
  projects: [
    { name: "chromium", use: { browserName: "chromium" } },
    { name: "webkit", use: { browserName: "webkit" } },
  ],
});
```

Together, these two layers mean I can refactor a shared component and know within seconds whether I've broken anything downstream.

## Layer 4: Stop Bots at the Door (Upstash Edge Rate Limiting)

This is the part that I'm genuinely proud of. Not because the code is complicated. It's shockingly simple. But because the architecture is so clean.

### The Problem

My portfolio makes real API calls. `/api/spotify` fetches my currently playing track. `/api/gallery` pulls photo metadata from Cloudinary. `/api/push` handles Firebase push notifications.

Each of these calls a downstream service with usage quotas. Firebase's free tier gives you 50,000 reads per day. A bot running `curl` in a loop at 100 requests/second would exhaust that quota in under **9 minutes**.

### The Solution

Instead of adding rate limiting to each individual route (fragile, repetitive), I put it in the **middleware layer**. In Next.js, middleware runs at Vercel's Edge, geographically close to the user, before the request ever reaches your serverless function.

Here's how the request flow works at a high level:

```mermaid
sequenceDiagram
    participant User
    participant MW as Vercel Edge (Middleware)
    participant Redis as Upstash Redis
    participant API as Next.js API

    User->>MW: Request /api/data
    MW->>Redis: Check slidingWindow(IP)
    Redis-->>MW: Return status (Limit exceeded?)

    alt If Remaining > 0
        MW->>API: Pass traffic
        API-->>User: Return 200 OK
    else If Limit Exceeded
        MW-->>User: Return 429 Too Many Requests
    end
```

And here's the expanded version showing how the database stays protected:

```mermaid
sequenceDiagram
    participant User
    participant Edge as Vercel Edge
    participant Redis as Upstash Redis
    participant API as Next.js API Route
    participant DB as Firebase / Cloudinary

    User->>Edge: GET /api/spotify
    Edge->>Redis: Check IP rate (slidingWindow)
    Redis-->>Edge: 7/10 remaining

    alt Within Limit
        Edge->>API: Forward request
        API->>DB: Fetch data
        DB-->>API: Return data
        API-->>User: 200 OK + data
    else Limit Exceeded
        Edge-->>User: 429 Too Many Requests
        Note right of Edge: API never executes.<br/>DB quota untouched.
    end
```

The critical insight here: when the rate limit triggers, **the API route never executes**. The request gets bounced at the network edge. Your Firebase quota, your Cloudinary bandwidth, your serverless function invocations. None of them are touched.

### The Code

```typescript
// src/middleware.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = process.env.UPSTASH_REDIS_REST_URL
  ? new Ratelimit({
      redis: Redis.fromEnv(),
      limiter: Ratelimit.slidingWindow(10, "10 s"),
      analytics: true,
    })
  : null;

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Only rate-limit API routes
  if (request.nextUrl.pathname.startsWith("/api") && ratelimit) {
    const ip = request.ip ?? "127.0.0.1";
    const { success, limit, reset, remaining } = await ratelimit.limit(ip);

    // Professional rate limit headers (same pattern as GitHub/Stripe APIs)
    response.headers.set("X-RateLimit-Limit", limit.toString());
    response.headers.set("X-RateLimit-Remaining", remaining.toString());
    response.headers.set("X-RateLimit-Reset", reset.toString());

    if (!success) {
      return new NextResponse("Too many requests, slow down.", {
        status: 429,
      });
    }
  }

  return response;
}
```

`slidingWindow(10, '10 s')` means: 10 requests per 10-second rolling window, per IP. The "sliding" part matters. It's not a hard reset every 10 seconds. It continuously tracks request frequency, so you can't game it by timing your bursts.

### Why Upstash Specifically

Three reasons:

1. **Serverless-native.** Upstash exposes Redis over a REST API (HTTPS), which means it works in Vercel Edge functions. Regular Redis uses TCP connections, which Edge functions don't support.
2. **Fast.** Rate limit checks complete in under 5 milliseconds because Upstash replicates data globally.
3. **Free.** 10,000 commands/day, 256 MB storage. For a rate limiter storing IP addresses (~15 bytes each), that's enough to track millions of concurrent visitors.

### Graceful Degradation

Notice the conditional: `process.env.UPSTASH_REDIS_REST_URL ? new Ratelimit(...) : null`. If the environment variables aren't set (local development, for instance), the rate limiter just doesn't activate. The middleware still runs, still sets security headers, but skips rate limiting entirely.

No crashes. No errors. Features should degrade gracefully, not explode loudly.

## Layer 5: Lock Down the Browser (Content Security Policy)

While I was already inside `middleware.ts`, I hardened the **Content Security Policy** (CSP). This is probably the most underrated security mechanism in web development.

CSP tells the browser exactly which domains are allowed to load scripts, styles, images, and frames on your page. Without it, a cross-site scripting (XSS) attack could inject a `<script>` tag that exfiltrates user data. With CSP, the browser itself blocks anything that doesn't come from a whitelisted source.

```mermaid
graph LR
    A["Incoming Request"] --> B["Middleware"]
    B --> C["Inject CSP Header"]
    B --> D["Inject HSTS Header"]
    B --> E["Inject X-Frame-Options"]
    B --> F["Inject Referrer-Policy"]
    B --> G["Inject Permissions-Policy"]

    C --> H["Browser enforces:<br/>Only whitelisted domains<br/>can load scripts/styles"]
    E --> I["Prevents clickjacking<br/>via iframe embedding"]

    style B fill:#1e3a5f,stroke:#60a5fa,color:#bfdbfe
    class H mm-green
    class I mm-green
```

Here's a simplified version of my CSP:

```typescript
const CSP_HEADER =
  "default-src 'self'; " +
  "script-src 'self' 'unsafe-inline' https://www.googletagmanager.com " +
  "https://challenges.cloudflare.com; " +
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " +
  "img-src 'self' data: https: blob:; " +
  "connect-src 'self' https://*.googleapis.com wss://*.firebaseio.com; " +
  "frame-src 'self' https://challenges.cloudflare.com; " +
  "frame-ancestors 'none';";
```

Every domain in that list is one I explicitly trust. If someone manages to inject a script pointing to `sketchy-domain.com`, the browser refuses to execute it before it even loads.

The `frame-ancestors 'none'` directive is particularly important. It prevents anyone from embedding your site inside an iframe, which is the primary vector for clickjacking attacks.

## April 2026 Update: Five More Layers I Added

After shipping the first version of this setup, I realized something important: production-ready is not the finish line. Release-discipline is.

So I added five more guardrails.

### Layer 6: Dependency Hygiene as a Failing CI Gate

I added a dependency policy file and a dedicated checker script so package hygiene is enforced, not implied.

- Blocks forbidden packages.
- Blocks risky version ranges like `*` and `latest`.
- Prevents duplicate packages across `dependencies` and `devDependencies`.
- Requires a lockfile.

```json
{
  "forbiddenPackages": ["@types/mapbox-gl", "@qwik.dev/partytown"],
  "forbiddenVersionRanges": ["*", "latest"]
}
```

This runs in CI through `pnpm deps:check`, so violations fail before merge.

![CI quality job succeeded - dependency, lint, type, test, build, and performance gates](/blog/production-ready-ci-quality-success.webp)

### Layer 7: Route-Level Performance Budgets in CI

I added a build budget script that reads `.next/app-build-manifest.json`, sums JS payload per route, and fails CI if a route exceeds budget.

```json
{
  "defaultRouteJsKb": 900,
  "routeBudgetsKb": {
    "/world/page": 2300,
    "/blog/[slug]/page": 1300,
    "/page": 950
  }
}
```

This stopped us from shipping accidental JS bloat on high-traffic routes.

### Layer 8: Critical User Journeys, Not Just Generic E2E

I expanded Playwright coverage with explicit high-value flows:

- Home route renders hero + booking entrypoint.
- Booking deep-link lands on details step.
- Blog index exposes post navigation.
- Admin route shows auth prompt for unauthenticated users.
- World page mounts with core controls.

These tests are designed around user outcomes, not just DOM smoke checks.

![Playwright Chromium run showing all critical journeys failed before the browser/runtime fix](/blog/production-ready-e2e-failed-before-fix.webp)

![Playwright Chromium run showing all critical journeys passed after fixes](/blog/production-ready-e2e-passed-after-fix.webp)

### Layer 9: Request-Level Observability + Error Budgets

I introduced shared observability utilities so API logs are structured and safe by default:

- Every critical API request gets `requestId`, event name, route, and duration.
- Sensitive keys are redacted.
- Oversized context strings are truncated.
- Responses include `x-request-id` for cross-log correlation.

Then I documented explicit SLOs and incident thresholds for `/api/chat` and `/api/admin/push`.

### Layer 10: Security + Release Governance

I added repo-level release discipline artifacts:

- Security and rollback playbook.
- Pull request template with risk, validation, security, observability, and rollback fields.
- CODEOWNERS baseline.

This changed release quality from "looks fine to me" to "prove it, then merge it."

### What I Deliberately Did Not Change (Yet)

I intentionally deferred the large file-splitting refactor for now. That work is valuable, but high-risk during a reliability hardening cycle, so I prioritized deterministic gates first.

## The Complete Pipeline

Here's what happens now when I write code and push it to production:

```mermaid
graph TD
  A["Write Code"] --> B["Layer 1: Pre-commit gate (Husky + lint-staged + Prettier)"]
  B -->|Fail| Z1["❌ Commit Rejected"]
  B -->|Pass| C["git push / open PR"]

  C --> D["Layer 2: GitHub Actions CI"]
  D --> E["Layer 6: Dependency hygiene (deps:check)"]
  E -->|Fail| Z2["❌ PR Blocked"]
  E -->|Pass| F["Layer 3a: Lint + Typecheck + Unit Tests"]
  F -->|Fail| Z2
  F -->|Pass| G["Layer 7: Route JS performance budgets"]
  G -->|Fail| Z2
  G -->|Pass| H["Layer 8: Critical journey E2E"]
  H -->|Fail| Z2
  H -->|Pass| I["Layer 10: Branch protection + CODEOWNERS + PR checklist"]

  I --> J["✅ Merge to main"]
  J --> K["Vercel deploy"]
  K --> L["Production live"]

  L --> M["Layer 4: Edge middleware gate"]
  M --> N["Rate limiting + security headers"]
  M --> O["Layer 5: CSP enforcement"]

  L --> P["Layer 9: Structured logs + x-request-id"]
  P --> Q["SLOs + error budgets + incident playbook"]

  style Z1 fill:#991b1b,stroke:#f87171,color:#fca5a5
  style Z2 fill:#991b1b,stroke:#f87171,color:#fca5a5
  class J mm-green
  class L mm-green
```

The diagram above is the current end-to-end flow with all ten layers active, from commit-time checks to runtime protections and incident readiness. Every single tool in this stack is free.

## What I Actually Learned

Building this infrastructure taught me something that frontend development alone hadn't: **the most important code in a production system is the code that prevents other code from breaking things.**

Prettier doesn't ship features. GitHub Actions doesn't improve animations. Rate limiting doesn't make your page load faster. Dependency policy won't wow anyone in a demo. Performance budgets aren't flashy. But together, they create a system where you can ship with confidence, iterate without fear, and go to sleep knowing regressions, abuse traffic, and risky merges are all less likely to make it to production.

If you're building a portfolio or side project and you want it to feel professionally built, not just professionally designed, start here. Not with the UI. Start with the infrastructure that makes everything else reliable.

## FAQ

### Is this overkill for a portfolio site?

Technically? Sure. But the setup took an afternoon, and the ongoing maintenance cost is zero. More importantly, it's a working demonstration that you understand production infrastructure, not just React components. Hiring managers notice that.

### How much does all of this cost?

Nothing. GitHub Actions is free for public repos. Upstash's free tier covers 10,000 commands/day. Husky, Prettier, Vitest, and Playwright are all open-source. The only investment is a few hours of your time.

### Will the rate limiter accidentally block real users?

No. The limit is 10 API requests per 10 seconds per IP. Normal browsing generates 2-3 API calls per page load. You'd have to deliberately hammer the refresh button for 10 straight seconds to trigger it.

### Can I use this setup outside Next.js?

The CI/CD layer (Husky, Prettier, GitHub Actions) works with any JavaScript framework. The Edge rate limiting is specific to Next.js middleware, but Upstash provides adapters for Cloudflare Workers, Deno, and standard Node.js servers.

### Does the rate limiter work locally?

By design, no. The middleware checks for `UPSTASH_REDIS_REST_URL`, which only exists in production (Vercel). Locally, limiting is silently skipped. Your dev workflow stays completely unaffected.

### What about observability and incident response?

I now use structured API logging with `x-request-id` traceability, redaction for sensitive context, and documented SLO/error-budget policies on critical APIs. I'm still using **Microsoft Clarity** for session replays, and **Sentry** is still on my shortlist for deeper cross-service error aggregation.


---

<!-- METADATA_START -->
## Metadata & Citations

### Further Reading
- [Goodbye reCAPTCHA, Hello Turnstile: Why I Switched (Next.js Guide)](https://www.ranti.dev/blog/migrating-from-recaptcha-to-turnstile.md)
- [The Silent Killer in Your AWS IAM Policies: Escalating Privileges via PassRole](https://www.ranti.dev/blog/aws-iam-passrole-vulnerability.md)
- [Next.js 15 on Azure Container Apps: A Production-Ready Deployment Guide](https://www.ranti.dev/blog/nextjs-15-azure-container-apps-guide.md)

### Navigation
- [Back to Bio Hub](https://www.ranti.dev/.md)
- [Full Site Manifest](https://www.ranti.dev/llms.txt)

```json
{
  "@context": "https://schema.org",
  "@type": "TechArticle",
  "headline": "How I Made My Next.js Portfolio Actually Production-Ready (For $0)",
  "author": {
    "@type": "Person",
    "name": "Rantideb Howlader"
  },
  "datePublished": "2026-04-09T00:00:00.000Z",
  "url": "https://www.ranti.dev/blog/production-ready-nextjs-ci-cd-edge-firewall",
  "license": "https://creativecommons.org/licenses/by/4.0/",
  "isAccessibleForFree": true
}
```

### BibTeX
```bibtex
@article{production-ready-nextjs-ci-cd-edge-firewall_2026,
  author = {Rantideb Howlader},
  title = {How I Made My Next.js Portfolio Actually Production-Ready (For $0)},
  journal = {Rantideb Howlader Portfolio},
  year = {2026},
  url = {https://www.ranti.dev/blog/production-ready-nextjs-ci-cd-edge-firewall},
  note = {Accessed: 2026-05-31}
}
```

### IEEE
Rantideb Howlader, "How I Made My Next.js Portfolio Actually Production-Ready (For $0)," Rantideb Howlader Portfolio, 2026. [Online]. Available: https://www.ranti.dev/blog/production-ready-nextjs-ci-cd-edge-firewall. [Accessed: 2026-05-31].

### APA
Rantideb Howlader. (2026). How I Made My Next.js Portfolio Actually Production-Ready (For $0). Rantideb Howlader. Retrieved from https://www.ranti.dev/blog/production-ready-nextjs-ci-cd-edge-firewall

--- 
*This content is provided in research-grade Markdown format. Required Attribution: Cite as Rantideb Howlader (2026).*
<!-- METADATA_END -->