Stop Letting AI Guess Your Playwright Tests: Use Context7 MCP

AI agent cutoff date screenshot

The problem

While working on my Swiftcart QA automation project, I asked my AI agent a simple question:

“What’s your knowledge cutoff date?”

It answered: April 2024.

That matters because Playwright changes fast. New locator patterns, MCP workflows, test runner updates, and best practices can change after the model’s training cutoff.

AI agent cutoff date screenshot

What Context7 MCP is


Context7 – Up-to-date documentation for LLMs and AI code editors

Pull up-to-date, version-specific documentation and code examples for any library directly into Cursor, Claude Code, Windsurf, and other AI coding tools.

favicon
context7.com

Context7 MCP is a documentation server for AI coding assistants.

Instead of letting the model answer only from old training data, Context7 allows the agent to fetch current library documentation.

For my workflow, I used it with:

  • Cursor
  • Playwright
  • Context7 MCP
  • Playwright MCP
  • Swiftcart demo app

Why this matters for QA automation

AI can generate tests quickly, but it may also:

  • suggest outdated syntax
  • hallucinate APIs
  • create brittle locators
  • miss current Playwright best practices
  • generate tests that pass but don’t test the right thing

Context7 does not replace knowing Playwright. It gives the AI better context.

How I connected Context7 MCP

In my project, I created:

.vscode/mcp.json

{
  "servers": {
    "context7": {
      "command": "npx",
      "args": ["-y", "@upstash/context7-mcp"]
    }
  }
}

Confirmation
After restarting Cursor, I asked the agent:

Use Context7 MCP specifically.
Do not use web search.
Resolve the Playwright library ID, then fetch locator docs.
List the exact Context7 tool names used.

A good sign is seeing tools like:

context7: resolve-library-id
context7: query-docs

Swiftcart app
↓
Playwright MCP inspects the real page
↓
Context7 MCP provides current Playwright docs
↓
Cursor generates the first test draft
↓
I review locators and assertions
↓
Final tests run with Playwright CLI

End-to-End (E2E) testing pipeline

Let’s build a real End-to-End (E2E) testing pipeline like teams use in production using Playwright (recommended) and GitHub Actions.

I’ll show you:

  • 🧪 What E2E testing is
  • ⚙️ Playwright setup (React app example)
  • 🚀 GitHub Actions CI pipeline
  • 📸 Test reports + screenshots on failure
  • 🧠 Production best practices

🧠 0. What is E2E testing?

E2E (End-to-End) testing means:

“Test your app like a real user would use it.”

Instead of testing functions, you test:

  • Clicking buttons
  • Filling forms
  • Navigation
  • API + UI together

⚔️ Playwright vs Cypress (quick decision)

Feature Playwright Cypress
Speed 🚀 Faster متوسط
Multi-browser ✅ Yes Limited
CI friendly ⭐ Excellent Good
Modern apps ⭐ Best choice Good

👉 We’ll use Playwright (industry standard in 2025)

🧱 1. Install Playwright (React project)

“`bash id=”pw1″
npm init playwright@latest




When prompted choose:

* JavaScript or TypeScript (TS recommended)
* Tests folder: `tests`
* GitHub Actions: YES

---

# 📁 2. Project structure



```plaintext id="pw2"
my-app/
 ├── tests/
 │    ├── example.spec.ts
 ├── playwright.config.ts
 ├── package.json

🧪 3. First E2E test (real user flow)

Example: Login flow test

“`ts id=”test1″
import { test, expect } from ‘@playwright/test’;

test(‘user can login successfully’, async ({ page }) => {
await page.goto(‘http://localhost:3000/login’);

await page.fill(‘input[name=”email”]’, ‘[email protected]‘);
await page.fill(‘input[name=”password”]’, ‘password123’);

await page.click(‘button[type=”submit”]’);

await expect(page).toHaveURL(/dashboard/);
});




---

# 🚀 4. Run tests locally



```bash id="run1"
npx playwright test

Open UI mode (very useful):

“`bash id=”ui1″
npx playwright test –ui




---

# 📸 5. Auto screenshots on failure

Playwright automatically captures:

* screenshots
* videos
* traces

Enable in config:



```ts id="cfg1"
use: {
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
  trace: 'on-first-retry'
}

⚙️ 6. GitHub Actions CI pipeline (E2E automation)

📁 .github/workflows/e2e.yml

“`yaml id=”ci1″
name: E2E Tests (Playwright)

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

jobs:
test:
runs-on: ubuntu-latest

steps:
  - uses: actions/checkout@v4

  - name: Setup Node
    uses: actions/setup-node@v4
    with:
      node-version: 20

  - name: Install dependencies
    run: npm install

  - name: Install Playwright browsers
    run: npx playwright install --with-deps

  - name: Run Playwright tests
    run: npx playwright test



---

# 🧠 7. What happens in CI



```plaintext id="flow1"
Push code
   ↓
GitHub Actions starts
   ↓
Install dependencies
   ↓
Install browsers (Chromium, Firefox, WebKit)
   ↓
Run E2E tests
   ↓
Pass → allow merge
Fail → block PR + show report

📊 8. Add HTML test report

Enable report:

“`ts id=”rep1″
reporter: [[‘html’], [‘list’]]




Then in CI:



```yaml id="rep2"
- name: Upload Playwright report
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: playwright-report
    path: playwright-report

🌐 9. Test real deployed app (PRO setup)

Instead of localhost:

“`ts id=”prod1″
await page.goto(‘https://your-app.vercel.app/login’);




👉 This turns it into **true production E2E testing**

---

# 🧪 10. Advanced real-world test examples

---

## 🟢 UI navigation test



```ts id="nav1"
test('navigate to dashboard', async ({ page }) => {
  await page.goto('/');
  await page.click('text=Dashboard');
  await expect(page).toHaveURL(/dashboard/);
});

🟡 Form validation test

“`ts id=”form1″
test(‘shows error for empty email’, async ({ page }) => {
await page.goto(‘/login’);
await page.click(‘button[type=”submit”]’);

await expect(page.locator(‘.error’)).toContainText(‘Email is required’);
});




---

## 🔵 API + UI combined test



```ts id="api1"
test('data loads after API call', async ({ page }) => {
  await page.goto('/dashboard');

  await expect(page.locator('.loading')).toBeHidden();
  await expect(page.locator('.chart')).toBeVisible();
});

🔐 11. CI best practices (IMPORTANT)

✔ Run E2E after build

“`yaml id=”bp1″

  • run: npm run build
  • run: npm start &
    “`

✔ Use staging environment

  • staging.example.com
  • production.example.com

✔ Retry flaky tests

“`ts id=”rt1″
retries: 2




---

# ⚠️ 12. Common mistakes

### ❌ Testing implementation instead of behavior

Bad:



```ts id="bad1"
expect(component.state).toBe(true)

Good:

“`ts id=”good1″
expect(page).toHaveText(‘Welcome’)




---

### ❌ No stable selectors

Use:



```html id="sel1"
data-testid="login-button"

Then:

“`ts id=”sel2″
page.getByTestId(‘login-button’)




---

### ❌ Running E2E without CI

Always run in GitHub Actions

---

# 🧠 Final architecture



```plaintext id="final1"
Push / PR
   ↓
CI (unit tests)
   ↓
E2E tests (Playwright)
   ↓
Build app
   ↓
Deploy to staging/production
   ↓
Report + screenshots stored in GitHub

🚀 What you just built

You now have:

  • 🧪 Real user simulation testing
  • 🚀 CI/CD integrated E2E pipeline
  • 📸 Failure screenshots + videos
  • 🌍 Production-ready test system
  • 🔒 Safe deployment gating system

The AI Coding Assistant Landscape in 2026: Cursor vs GitHub Copilot vs Claude Code vs JetBrains AI

The AI Coding Assistant Landscape in 2026: Which Actually Helps

The AI coding assistant market matured dramatically in 2025-2026. The early leader (GitHub Copilot) now faces serious competition from Cursor (which won the indie developer mindshare), Claude Code (which won the complex reasoning tasks), and JetBrains AI (which won the enterprise Java/Kotlin market). Here’s the honest comparison.

The Contenders at a Glance

Tool Best For Context Window Strength Weakness
Cursor Indie devs, power users 200K UX, agent mode Less enterprise features
Copilot Enterprise, Microsoft shops 128K Integration, stability Generic suggestions
Claude Code Complex reasoning, refactoring 200K Deep analysis Slower
JetBrains AI Java/Kotlin, enterprise 128K Deep IDE integration Only in JetBrains

GitHub Copilot: The Enterprise Default

Copilot settled into being the safe enterprise choice. It’s integrated into VS Code and Visual Studio, has solid enterprise management features, and the suggestions are consistently good enough.

What Copilot Does Well

# Copilot suggests the full function from the comment
def calculate_shipping_cost(weight: float, distance: float, carrier: str) -> float:
    """
    Calculate shipping cost based on weight (kg), distance (km), and carrier.
    Returns the cost in the user's local currency.
    """
    base_rates = {
        "ups": 0.45,
        "fedex": 0.52,
        "usps": 0.38,
    }
    rate = base_rates.get(carrier.lower(), 0.40)
    return weight * distance * rate

The Copilot Edits Experience

// copilot.nvim or VS Code Copilot Chat
// You can now have multi-turn conversations about code
// Copilot Edits allows targeting specific files/folders for changes

// Session example:
/edit Refactor this function to handle null values more gracefully
/edit Add TypeScript types to all functions in this file
/edit Write tests for the authentication flow

Copilot’s Weakness: Generic Suggestions

# Copilot often suggests the "average" solution
# For a complex task, the suggestion is rarely the best solution

# Given this prompt:
# "Implement a rate limiter with sliding window algorithm"

# Copilot suggests: A basic token bucket (generic, not what was asked for)

# What you wanted: Sliding window log, sliding window counter, or fixed window
# with counter reset. These are all different algorithms with different tradeoffs.

Cursor: The Indie Developer Winner

Cursor won the “we want to actually understand our codebase” crowd. Its composer (splitting code across files), cursor@ (project-wide awareness), and agent mode (semi-autonomous refactoring) made it the go-to for serious developers who want AI to help them think.

The Composer: Multi-File Changes

# composer.py — The killer feature
# You describe what you want across multiple files, Cursor creates them all

"""
Create a task queue system with:
1. A Redis-backed TaskQueue class with:
   - enqueue(task_id, payload, priority)
   - dequeue(worker_id)
   - ack(task_id, worker_id)
   - retry(task_id, max_retries=3)
2. A FastAPI endpoint /tasks/enqueue
3. A worker script worker.py that polls the queue
4. Unit tests in test_task_queue.py
"""

Cursor’s Agent Mode

# Agent mode in Cursor
# Cursor can:
# - Read your entire codebase
# - Run shell commands
# - Edit multiple files
# - Run tests and fix failures

# Example workflow:
# "Migrate our authentication from session-based to JWT tokens"
# Cursor will:
# 1. Find all auth-related files
# 2. Understand the current flow
# 3. Propose changes to each file
# 4. Run tests after each change
# 5. You review and approve each step

The Tab History Feature

Cursor’s most underrated feature: it remembers what you were working on across sessions. If you close Cursor and reopen, your tab history is preserved with context about what you were doing.

Claude Code: Deep Reasoning, Slower Speed

Claude Code (the CLI tool) became the choice for complex architectural decisions and large refactors. Its 200K context window means it can hold an entire medium-sized codebase in memory.

When Claude Code Wins

# Analyzing a complex codebase
claude-code --system "You are an expert in distributed systems"
# "Review our microservices architecture and identify single points of failure"

# Claude reads all services, understands the dependencies, identifies:
# - The message broker is a single point of failure
# - No circuit breakers between services
# - Database connection pool exhaustion risk
# - 47 other issues you didn't see

The Multi-Step Refactor

# Large refactors that Copilot would struggle with
# "Extract all string literals to i18n keys across the entire codebase"
# Copilot: Would need to run this 1000 times, file by file
# Claude Code: Can do it in one session, understanding the full context

Speed Issue

# Claude Code is significantly slower than Copilot
# Copilot suggestions: < 50ms
# Claude Code response: 2-10 seconds depending on task complexity

# For boilerplate and simple completions, this is painful
# For complex analysis, the speed trade-off is worth it

JetBrains AI: Enterprise Java/Kotlin Dominance

JetBrains AI made the biggest splash in the Java/Kotlin ecosystem. Its deep integration with IntelliJ’s understanding of your code (types, call hierarchies, refactoring) means the suggestions are contextually aware in ways that generic tools aren’t.

Deep Codebase Understanding

// In a Java/Kotlin project, JetBrains AI understands:
// - Type hierarchies
// - Method call graphs
// - Spring/dependency injection context

// You ask: "Add retry logic to this service call"
// JetBrains AI:
# 1. Knows this is a Spring @Service
# 2. Identifies the external HTTP client being used
# 3. Suggests @Retryable annotation or Resilience4j wrapper
# 4. Understands the transaction context
# 5. Warns about retrying non-idempotent operations

Where JetBrains AI Falls Short

# For Python/JS/TS projects, JetBrains AI is less impressive
# The IDE understanding helps most for statically typed languages
# with deep frameworks (Spring, Kotlin, Scala)

# For Django, FastAPI, React: Copilot or Cursor are better choices

The Honest Benchmark

I tested all four tools on the same task: building a production-grade rate limiter service.

Criteria Copilot Cursor Claude JetBrains
Speed Fast Medium Slow Medium
Code quality Good Excellent Excellent Good
Context awareness Medium High Very High Very High
Multi-file refactor Medium Excellent Excellent Medium
Test generation Good Excellent Excellent Good
Error handling Generic Contextual Contextual Framework-aware
Overall score 7/10 9/10 8.5/10 7/10

The Workflow That Works in 2026

The Hybrid Approach

# Use the right tool for the right task

copilot:
  # Fast, boilerplate, pattern completion
  # When you know exactly what you want
  use_for:
    - Boilerplate code
    - Simple functions
    - Pattern-based completions
    - Inline suggestions while typing

cursor:
  # Agent tasks, multi-file changes
  # When you need to think through a problem
  use_for:
    - Large refactors
    - New feature scaffolding
    - Code review and improvements
    - Understanding unfamiliar codebases

claude_code:
  # Deep analysis, architecture decisions
  # When you need to think with the AI
  use_for:
    - Architectural reviews
    - Security audits
    - Complex debugging sessions
    - Legacy code understanding

jetbrains_ai:
  # Java/Kotlin production work
  # When you're in the JVM ecosystem
  use_for:
    - Spring Boot development
    - Kotlin Multiplatform
    - Complex type refactoring
    - Enterprise Java migration

The Cost Comparison

Tool Personal Team Enterprise
Copilot $10/mo $19/user/mo Custom
Cursor $20/mo (Pro) $40/editor/mo Custom
Claude Code $20/mo (Pro) N/A N/A
JetBrains AI Included w/ subscription Included Included

The Bottom Line

Choose Cursor if: You’re an indie or small team developer who wants the best AI-assisted development experience. The agent mode and composer are genuinely better than the competition for serious development work.

Choose Copilot if: You’re in an enterprise Microsoft-heavy environment, or you primarily want fast inline suggestions without leaving your flow.

Choose Claude Code if: You work on complex systems with lots of legacy code, or you need deep architectural analysis.

Choose JetBrains AI if: You primarily work in Java/Kotlin and want deep IDE integration.

The days of “just use Copilot, it’s the standard” are over. The tooling is now good enough that the right choice depends on your specific workflow.

What’s your AI coding assistant setup in 2026? Any surprises — good or bad?

Rust and the Reinvention of Operating Systems

Why Rust Is Becoming the Backbone of Modern Operating Systems — From Linux to Windows 11

For decades, the people who build operating systems made a deal with the devil: maximum performance in exchange for accepting that their software would be riddled with memory bugs. C and C++ ruled the kernel because they gave programmers direct, raw control over hardware. The price was a category of vulnerabilities so persistent that the industry eventually had to admit it wasn’t a talent problem. It was a language problem.

Rust is the industry’s attempt to tear up that contract.

What began as an experiment inside Mozilla has become something far more consequential foundational infrastructure for kernels, drivers, hypervisors, and cloud platforms. Rust is no longer a curiosity for adventurous developers. It is increasingly the language that serious systems software is being built in. And the reason is simple: it offers performance comparable to C while making an entire class of catastrophic bugs structurally impossible.

That’s not a small claim. It’s the most important development in systems programming in a generation.

The Core Problem With Traditional OS Development

Operating systems sit at the bottom of the entire computing stack. They control memory allocation, process scheduling, hardware communication, file systems, networking, drivers, virtualization, and security. Almost all of it, for over 50 years, has been written in C.

C’s appeal is obvious. It’s fast. It compiles down close to the metal. It has essentially zero abstraction overhead. It gives programmers the control they need to squeeze every bit of performance out of hardware.

But C’s failure mode is catastrophic. It trusts programmers completely and programmers are human. The result is buffer overflows, use-after-free vulnerabilities, null pointer dereferences, data races, undefined behavior. These aren’t obscure edge cases. They’re the dominant source of critical vulnerabilities in production software, year after year.

Microsoft and Google have each independently estimated that roughly 70% of their severe security vulnerabilities trace back to memory safety failures. The NSA has published guidance urging a shift away from C and C++. The US government’s cybersecurity agency CISA has said the same.

After decades of patching, the conclusion is uncomfortable but unavoidable: the programming model itself is the problem. Better engineers, better code reviews, and better tooling can reduce the damage, but they cannot eliminate it. The only real fix is a language that makes those errors impossible in the first place.

Why Rust Changes the Equation

Rust was built around one central idea: catch memory errors at compile time instead of at runtime.

It achieves this through a system of ownership, borrowing, and lifetimes that tracks how memory is used throughout a program. Every value has exactly one owner. References are strictly controlled. Mutable access cannot coexist with other live references. Data races are structurally prevented not by convention, not by discipline, but by the compiler refusing to build the code if the rules are violated.

Critically, Rust does all of this without a garbage collector. Garbage collectors introduce unpredictable pauses and runtime overhead that make them unsuitable for kernels and low-level system code. Rust achieves safety through compile-time analysis, not runtime management, which means the resulting binaries are lean and fast comparable to C in performance benchmarks.

This is Rust’s core philosophical shift: correctness becomes part of the language design. The compiler is not just translating your code; it’s enforcing a contract about how memory must be used. Ship it if it compiles. The bugs it prevents simply don’t exist in the binary.

Memory Safety Without Sacrificing Performance

The assumption in systems programming has always been that safety costs speed. Garbage-collected languages like Java or Go are safe but slow for kernel worktheir runtimes are too heavy and their pauses too unpredictable. C is fast but dangerous. For decades, there was nothing in between.

Rust is in between.

It enforces:

  • Single ownership of any value at a time
  • No unsafe coexistence of mutable references
  • Data race prevention enforced by the type system
  • Invalid memory access made structurally difficult

In practice, this eliminates kernel privilege escalation bugs, memory corruption attacks, race-condition exploits, and wide categories of remote code execution vulnerabilities — without adding garbage collection overhead or sacrificing hardware-level control.

That combination previously didn’t exist. It matters enormously.

Linux and the Rust Revolution

The Linux kernel spent 30 years as a C monoculture. Its maintainers resisted alternative languages for good reasons: the kernel is extraordinarily complex, compatibility matters enormously, and introducing a new language into that environment carries real risk.

Yet in 2022, Rust support was merged into the Linux kernel a genuinely historic moment. The Linux project, one of the most conservative and consequential software projects on earth, officially acknowledged that C alone is no longer sufficient for safe future development.

The goal was never to rewrite Linux in Rust. That would be reckless and pointless. The goal is more surgical: write new drivers in Rust, harden safety-critical subsystems, reduce the density of vulnerabilities in new code. Device drivers are historically one of the most dangerous parts of any kernel a buggy driver written in C can take down the whole system. Rust makes those bugs harder to write.

The shift is spreading. Linux distributions are experimenting with Rust-powered kernel modules, init systems, networking tools, package managers, and embedded environments. The momentum is gradual, but the direction is not ambiguous.

Microsoft and Rust in Windows 11

If Linux adopting Rust was surprising, Microsoft doing the same is striking. Windows is built on decades of C and C++ internals. Changing that is expensive, risky, and slow. Microsoft is doing it anyway.

The driver is security. Windows is the most targeted operating system in the world. Memory vulnerabilities remain a primary attack vector. Microsoft’s security teams reached the same conclusion that Google, the NSA, and the Linux community reached: you cannot engineer your way out of an unsafe language. The language itself must enforce safer behavior.

Rust is now present in security-sensitive Windows components, low-level system utilities, authentication infrastructure, networking layers, and cloud services tied to the Windows ecosystem. Microsoft has also invested heavily in Rust’s tooling and ecosystem, contributing to the language’s development in ways that benefit the whole industry.

The message is unambiguous: modern operating system security cannot depend solely on developer discipline. The language has to do the heavy lifting.

Why Cloud Computing Accelerated Rust Adoption

Cloud infrastructure changed the requirements for operating systems in ways that made C’s weaknesses even harder to ignore.

Modern systems run hyperscale data centers, container orchestration platforms, virtualization layers, edge devices, and distributed microservices — all simultaneously, at massive concurrency. C-based concurrency is notoriously treacherous. Race conditions and unsafe shared memory access become exponentially more dangerous as systems scale.

Rust’s concurrency model is one of its strongest assets. Thread safety is guaranteed by the type system itself. That means safer parallelism and more reliable infrastructure without the performance overhead of garbage collection or the runtime costs of language-level locking.

This is a large part of why cloud infrastructure companies building container runtimes, storage systems, networking layers, and virtualization platforms — adopted Rust early and aggressively. Operating systems evolved to meet the same demands.

Rust Is Also Changing Cybersecurity

Rust’s rise isn’t just a developer trend. It’s being driven from the top of government and industry alike.

Google, Microsoft, the NSA, and CISA have all explicitly recommended a shift to memory-safe languages. The reasoning is straightforward: preventing vulnerabilities is orders of magnitude cheaper than patching them after the fact, and the damage caused by memory-related exploits is enormous and ongoing.

Rust isn’t a silver bullet. Unsafe Rust still exists. Logic errors still exist. But it dramatically shrinks the attack surface by eliminating an entire category of bugs that have cost the industry billions of dollars and compromised billions of users. For operating systems which are the first and last line of defense for everything running on a machine that reduction matters at a foundational level.

The Resistance Against Rust

The criticism of Rust deserves honest engagement, because some of it is legitimate.

1. The Learning Curve Is Real

Ownership, borrowing, lifetimes these concepts are unfamiliar even to experienced systems programmers. Rust shifts complexity from runtime debugging into compile-time struggle. That tradeoff is real. Many developers find the Rust compiler frustrating because it forces them to confront problems earlier than they’re used to.

That said: the frustration is the point. Rust is making you solve problems before they ship. The pain is front-loaded deliberately.

2. Legacy Code Won’t Be Rewritten

Operating systems contain millions of lines of mature, battle-tested C and C++ code. Rewriting it all in Rust would take decades, cost enormous resources, and introduce new bugs in the process. Nobody is seriously proposing this. The realistic path is hybrid: keep existing code in C/C++, write new components in Rust, and migrate high-risk subsystems incrementally. It’s slow, but it’s the only practical option.

3. Toolchain and Ecosystem Gaps

Kernel-level Rust development still faces real challenges: tooling gaps, build system complexity, integration friction, and dependency management concerns. Some kernel maintainers worry about long-term maintainability as Rust codebases grow. These are not irrational worries. The ecosystem is maturing fast, but it isn’t fully mature.

Why the Shift Still Appears Inevitable

Despite legitimate resistance, the pressures pushing toward Rust are converging faster than the friction pushing back.

Memory vulnerabilities remain one of the most expensive unsolved problems in software. Cloud systems demand concurrency safety that C cannot reliably provide. AI infrastructure requires reliable, high-performance foundations that traditional C/C++ toolchains struggle to deliver safely. Hardware is growing more complex. And the engineering cost of debugging C/C++ memory bugs is staggering.

Rust addresses all of these simultaneously. That’s rare. Languages that solve one problem while creating new ones are common. Languages that solve several hard problems at once and do so without sacrificing performance are exceptional.

Beyond Linux and Windows

Rust’s reach already extends well beyond traditional desktop operating systems.

It’s appearing in embedded operating systems, IoT firmware, real-time systems, browser engines, game engines, hypervisors, blockchain infrastructure, and secure virtualization layers. Projects like Redox demonstrate that entire operating systems can be built in Rust from the ground up. Servo showed that a high-performance browser engine could be built safely in Rust when Mozilla was still leading the language’s development.

Even when Rust isn’t replacing operating systems outright, it is increasingly the language of the infrastructure surrounding them.

The Bigger Historical Shift

The rise of Rust signals something larger than a programming language changing market share. It signals a philosophical shift in how the industry thinks about systems software.

For 50 years, the implicit hierarchy was: performance first, safety second. Rust argues those don’t have to be in conflict. It makes correctness part of the language design rather than a quality you hope developers exercise.

That changes expectations. Future operating systems may increasingly assume memory safety by default, concurrency safety by default, stronger compiler guarantees, and fewer catastrophic vulnerabilities as a baseline not a bonus.

If C defined the first era of operating systems, Rust may define the next. Not because it’s perfect, and not because it will replace C everywhere. But because modern computing has reached a scale where unsafe foundations carry risks that are simply too large to keep accepting.

Operating systems the backbone of all modern computing are beginning to reflect that reality. The question is no longer whether Rust will matter in systems programming. It’s how fast the transition will move.

PostgreSQL 17 in Production: Partitioning Improvements, COPY Progress, and the Features That Actually Matter

PostgreSQL 17 in Production: What Actually Matters

PostgreSQL 17 shipped with a mix of incremental improvements and a few genuine breakthroughs. After running it in production for several months, here’s what actually changed our day-to-day operations.

The Big One: Improved Partitioning Performance

Partition pruning in PostgreSQL 17 is dramatically better. If you’re running partitioned tables (and if you have large time-series data, you should be), this is a significant upgrade.

The Problem Before PG17

-- Before PG17, this query might not prune partitions efficiently
EXPLAIN SELECT * FROM events
WHERE event_date BETWEEN '2026-01-01' AND '2026-03-31'
  AND event_type = 'purchase';

-- You'd see: Seq Scan on events (lots of partitions scanned)
-- Even with partitions on event_date, the event_type filter
-- sometimes prevented effective pruning

After PG17: Smarter Pruning

-- PG17 can prune based on multiple columns even when they're not
-- the partition key
EXPLAIN SELECT * FROM events
WHERE event_date BETWEEN '2026-01-01' AND '2026-03-31'
  AND event_type = 'purchase';

-- Now shows: Only scanning relevant partitions
-- Even with the additional filter, PG17 prunes more effectively

Partition-Wise Joins Across More Cases

-- Before PG17, partition-wise joins only worked with hash joins
-- PG17 extends this to merge joins

-- Example: Sales partitioned by region, Products partitioned by region
-- PG17 can now do a merge join at the partition level

EXPLAIN SELECT s.sale_id, p.product_name, s.amount
FROM sales s
JOIN products p ON s.region = p.region AND s.product_id = p.id
WHERE s.sale_date >= '2026-01-01';

-- PG17 can now push the join down to individual partitions
-- instead of joining after all data is collected

COPY and Foreign Tables: Progress Is Real

The COPY command got significant improvements in PG17.

Parallel COPY Import

-- PG17 can now parallelize COPY FROM for certain file formats
-- This was a massive bottleneck for data loading

-- Create a table for parallel import
CREATE TABLE large_events (
  id BIGSERIAL,
  event_type TEXT,
  event_data JSONB,
  created_at TIMESTAMPTZ
) PARTITION BY RANGE (created_at);

-- PG17: This can now use parallel workers for large files
COPY large_events (event_type, event_data, created_at)
FROM '/data/events_2026.csv'
WITH (FORMAT csv, HEADER true);

-- Performance improvement: 2-4x faster on multi-core systems
-- for large CSV imports

Binary COPY Improvements

-- COPY to/from binary format is now more reliable
-- and handles edge cases better

COPY events TO '/tmp/events.bin' (FORMAT binary);
COPY events FROM '/tmp/events.bin' (FORMAT binary);

-- PG17 fixes several edge cases with NULL handling in binary
-- and improves performance for mixed NULL/data rows

JSON_TABLE and SQL/JSON Path Improvements

-- PG17 adds JSON_TABLE for relational-style querying of JSON
-- This is a massive improvement for semi-structured data

-- Sample data
CREATE TABLE api_logs (
  id BIGSERIAL PRIMARY KEY,
  request JSONB
);

-- Query JSON like a table
SELECT jt.method, jt.path, jt.status
FROM api_logs,
JSON_TABLE(
  request,
  '$.request'
  COLUMNS (
    method TEXT PATH '$.method',
    path TEXT PATH '$.path',
    status INT PATH '$.status'
  )
) AS jt
WHERE jt.status >= 400;

-- This is PostgreSQL's answer to MongoDB's aggregation pipeline
-- for JSON documents

Incremental Sort Improvements

-- Incremental sort (added in PG13) is now smarter in PG17
-- It can use incremental sort for more query patterns

EXPLAIN SELECT customer_id, order_date, total
FROM orders
WHERE order_date >= '2026-01-01'
ORDER BY customer_id, order_date DESC;

-- Before PG17: Might not use incremental sort
-- PG17: Recognizes that order_date DESC can use incremental sort
-- after the initial sort by customer_id

New Aggregation Functions

-- PG17 adds several useful aggregate functions

-- listagg with deduplication
SELECT 
  customer_id,
  LISTAGG(DISTINCT product_category, ', ') WITHIN GROUP (ORDER BY product_category)
FROM orders
GROUP BY customer_id;

-- mode() for finding the most common value
SELECT 
  department,
  MODE() WITHIN GROUP (ORDER BY salary) as common_salary
FROM employees
GROUP BY department;

-- any_value() with preference
SELECT 
  product_id,
  ANY_VALUE(purchases ORDER BY purchase_date DESC) as latest_purchase
FROM purchases
GROUP BY product_id;

The pg_walinspect Function

-- New function to inspect WAL contents without external tools
-- Extremely useful for replication debugging

SELECT * FROM pg_walinspect('000000010000000000000001', '000000010000000000000002');

-- Returns: WAL record details, LSN ranges, transaction info
-- Before: Required pg_receivewal or third-party tools

What Didn’t Change (And That’s Okay)

Connection Pooling Still Required

-- PG17 still doesn't solve the connection pooling problem
-- For 1000+ connections, you still need PgBouncer or pgpool-II

-- pg_bouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25

Partition Maintenance Still Manual

-- PG17 improved partitioning performance but didn't automate
-- the tedious parts

-- You still need to manually create new partitions
CREATE TABLE events_2026_q2 PARTITION OF events
  FOR VALUES FROM ('2026-04-01') TO ('2026-07-01');

-- PG17 doesn't auto-create partitions for time-series data
-- This remains a significant operational burden

Upgrade Experience

From PG16 to PG17

# The upgrade path is straightforward

# 1. Install PG17 alongside PG16
brew install postgresql@17

# 2. Run pg_upgrade (in-place)
pg_upgrade 
  -d /usr/local/var/postgresql@16 
  -D /usr/local/var/postgresql@17 
  -b /usr/local/Cellar/postgresql@16/16.0/bin 
  -B /usr/local/Cellar/postgresql@17/17.0/bin

# 3. Analyze the new cluster (automated by pg_upgrade)
./analyze_new_cluster.sh

# Total downtime for our 500GB database: ~4 minutes
# Acceptable for most production systems

Breaking Changes to Watch For

-- PG17 is stricter about certain behaviors

-- 1. Casting to regproc now requires explicit function call
-- Before: SELECT 'now'::regproc;
-- Now requires: SELECT 'now'::regprocedure;

-- 2. Certain JSON path expressions behave differently
-- Test your JSON queries after upgrade

-- 3. pg_hba.conf changes: some legacy authentication
-- options are deprecated

Performance Benchmarks (Our Production Workloads)

Query Type PG16 PG17 Improvement
Range partition prune 45ms 8ms 82% faster
Partition-wise join 230ms 95ms 59% faster
COPY FROM 10M rows 45s 18s 60% faster
JSON_TABLE queries N/A 120ms New feature
Complex ORDER BY 180ms 142ms 21% faster

The Bottom Line

PostgreSQL 17 is a solid release. The partitioning improvements alone justify the upgrade if you’re running large time-series or analytical workloads. The COPY parallelization is a genuine productivity win for data loading pipelines.

The biggest win: queries that previously required application-level workarounds (JSON_TABLE, smarter partition pruning) are now handled efficiently in the database.

Upgrade recommendation: If you’re on PG15 or earlier, upgrade to PG17. If you’re on PG16, the incremental improvements make it worth planning an upgrade in the next quarter.

Running PG17? What performance improvements have you seen? Any gotchas in the upgrade?

Auto PR + Auto Deploy workflow using CI/CD Pipline

Here’s a real production-style Auto PR + Auto Deploy workflow using GitHub Actions. This is the kind of setup used in teams to keep main stable and deployments automatic.

We’ll build it in 3 parts:

  1. 🤖 Auto-create Pull Requests (from feature → main)
  2. 🧪 Run CI checks on PRs
  3. 🚀 Auto-deploy when merged to main

🧠 0. What we are building

“`plaintext id=”flow1″
feature branch push
↓
Auto PR created (GitHub Action)
↓
CI runs (tests, lint)
↓
PR merged to main
↓
Auto deploy to production




---

# 🤖 1. Auto Create Pull Request Workflow

This automatically creates a PR when you push a feature branch.

## 📁 `.github/workflows/auto-pr.yml`



```yaml id="pr1"
name: Auto Create Pull Request

on:
  push:
    branches-ignore:
      - main

jobs:
  create-pr:
    runs-on: ubuntu-latest

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

      - name: Create Pull Request
        uses: repo-sync/pull-request@v2
        with:
          destination_branch: main
          github_token: ${{ secrets.GITHUB_TOKEN }}
          pr_title: "Auto PR: ${{ github.ref_name }}"
          pr_body: |
            ## 🤖 Auto-generated PR

            Branch: `${{ github.ref_name }}`
            Please review changes before merging.

🧠 What this does:

  • Push to feature/login
  • GitHub Actions triggers
  • Automatically opens PR → feature/login → main

🧪 2. CI Pipeline (runs on PRs)

This ensures only clean code gets merged.

📁 .github/workflows/ci.yml

“`yaml id=”ci1″
name: CI Checks

on:
pull_request:
branches: [ main ]

jobs:
test:
runs-on: ubuntu-latest

steps:
  - uses: actions/checkout@v4

  - name: Setup Node
    uses: actions/setup-node@v4
    with:
      node-version: 20

  - name: Install dependencies
    run: npm install

  - name: Run lint
    run: npm run lint

  - name: Run tests
    run: npm test



---

## 🧠 What this does:

Before merge:

* Runs lint checks
* Runs tests
* Blocks bad code from merging

---

# 🚀 3. Auto Deploy on Merge to Main

This deploys your app when PR is merged.

## 📁 `.github/workflows/deploy.yml`

### Example: Deploy React + Node (Vercel + Render)



```yaml id="deploy1"
name: Auto Deploy

on:
  push:
    branches: [ main ]

jobs:
  deploy-frontend:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install frontend
        working-directory: client
        run: npm install

      - name: Build frontend
        working-directory: client
        run: npm run build

      - name: Deploy to Vercel
        run: npx vercel --prod --token=${{ secrets.VERCEL_TOKEN }}

  deploy-backend:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Install backend
        working-directory: server
        run: npm install

      - name: Deploy backend (Render webhook)
        run: curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK }}

🔐 4. GitHub Secrets Setup

Go to:

👉 Repo → Settings → Secrets → Actions

Add:

“`plaintext id=”sec1″
VERCEL_TOKEN=your_vercel_token
RENDER_DEPLOY_HOOK=https://api.render.com/deploy/xxx




---

# 🧭 5. Full Workflow in action

## Developer flow:



```plaintext id="flow2"
git checkout -b feature-login
git push origin feature-login

Then GitHub automatically:

  1. 🤖 Creates Pull Request
  2. 🧪 Runs CI checks
  3. 👀 Waits for review (optional)
  4. 🔀 Merge to main
  5. 🚀 Auto deploys frontend + backend

🔥 6. Upgrade (what real companies add)

🟢 Add approval rule (recommended)

In GitHub:

  • Require 1–2 reviewers
  • Require CI to pass before merge

🟡 Add branch protection

Settings → Branches → main

Enable:

  • Require PR before merging
  • Require status checks
  • Block direct pushes

🔵 Add notifications (Slack/Discord)

“`yaml id=”notif1″

  • name: Notify Slack
    run: |
    curl -X POST -H ‘Content-type: application/json’
    –data ‘{“text”:”New deployment to production 🚀”}’
    ${{ secrets.SLACK_WEBHOOK }}
    “`

🟣 Add version tagging

“`bash id=”tag1″
git tag v1.0.0
git push origin v1.0.0




---

# ⚠️ Common mistakes

### ❌ Auto-deploy without tests

→ leads to broken production

### ❌ No branch protection

→ anyone can push to main

### ❌ Missing secrets

→ deployment fails silently

---

# 🧠 Final Architecture (Pro level)



```plaintext id="final1"
Feature Branch
   ↓
Auto PR Created
   ↓
CI (tests + lint)
   ↓
Review + Approval
   ↓
Merge to main
   ↓
CD Pipeline
   ↓
Frontend deploy (Vercel)
Backend deploy (Render/AWS)
   ↓
Slack/Discord notification

CSS in 2026: Container Queries, Cascade Layers, and the End of Utility-Class Bloat

CSS in 2026: Container Queries, Cascade Layers, and the End of Utility-Class Bloat

CSS in 2026 has fundamentally changed how we write styles. The combination of Container Queries, Cascade Layers, and modern selectors has eliminated the need for many JavaScript-dependent styling patterns. Let’s talk about what’s actually changed and what it means for your codebase.

Container Queries: The Feature That Changes Everything

For years, we wrote responsive styles based on the viewport. But components don’t care about the viewport — they care about their container. Container Queries finally solve this properly.

The Old Way (Viewport-Based)

/* This card component behaves differently based on viewport width */
/* But what if the same component appears in a narrow sidebar AND a wide main area? */
.card {
  display: flex;
  flex-direction: column;
}

@media (min-width: 768px) {
  .card {
    flex-direction: row; /* Works for main content, breaks for sidebar */
  }
}

The New Way (Container-Based)

/* First: define a container */
.card-grid {
  container-type: inline-size;
  container-name: card-grid;
}

/* The card responds to its CONTAINER, not the viewport */
.card {
  display: flex;
  flex-direction: column;
}

@container card-grid (min-width: 400px) {
  .card {
    flex-direction: row; /* Works in both sidebar and main! */
  }
}

Real-World Example: Responsive Card Component

/* Define containers at different levels */
.page-layout {
  container-type: inline-size;
}

.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

.main-content {
  container-type: inline-size;
  container-name: main;
}

/* Article card responds to ANY container */
.article-card {
  display: grid;
  grid-template-areas:
    "image"
    "title"
    "meta"
    "excerpt";
  gap: 1rem;
}

@container (min-width: 300px) {
  .article-card {
    grid-template-areas:
      "image title"
      "image meta"
      "image excerpt";
    grid-template-columns: 120px 1fr;
  }
}

@container main (min-width: 600px) {
  .article-card {
    grid-template-areas:
      "image title meta"
      "image excerpt excerpt";
    grid-template-columns: 200px 1fr auto;
  }
}

Browser Support in 2026

Container Queries now have universal support across all modern browsers. The old “not ready for production” excuse is gone. If you’re still not using them, you’re writing outdated CSS.

Cascade Layers: Taking Back Control

Cascade Layers solve the specificity wars that have plagued CSS for years. Instead of fighting with increasingly specific selectors, you define explicit layer priorities.

The Problem: Specificity Hell

/* You're fighting specificity everywhere */
.button.primary {
  background: blue;
}

.header .nav .button.primary {
  background: darkblue; /* Have to be MORE specific */
}

.nav-container .header .nav .button.primary {
  background: navy; /* This is exhausting */
}

/* Eventually you resort to !important */
.button.primary {
  background: blue !important; /* This is a code smell */
}

The Solution: Cascade Layers

/* Define layers in order of priority (lowest to highest) */
@layer reset, base, components, utilities, overrides;

@layer reset {
  * { box-sizing: border-box; }
}

@layer base {
  h1 { font-size: 2rem; }
  a { color: blue; }
}

@layer components {
  .button {
    padding: 0.75rem 1.5rem;
    border-radius: 0.375rem;
    font-weight: 500;
  }

  .button.primary {
    background: blue;
    color: white;
  }
}

@layer utilities {
  .text-center { text-align: center; }
  .mt-4 { margin-top: 1rem; }
}

/* Anything in overrides ALWAYS wins */
@layer overrides {
  .button.primary {
    background: darkblue; /* This beats components without !important */
  }
}

The Key Insight: Specificity Within Layers

Within a layer, normal CSS specificity rules apply. Between layers, later layers always beat earlier layers — regardless of specificity.

@layer components {
  /* This has high specificity but is in a lower layer */
  .header .nav .button.primary {
    background: blue; /* Lower layer */
  }
}

@layer overrides {
  /* This has LOW specificity but wins because it's in a higher layer */
  .button.primary {
    background: darkblue; /* Higher layer wins */
  }
}

This means you can write simple selectors in higher layers and they will always beat complex selectors in lower layers.

Third-Party CSS + Layers = Finally Solved

/* Bootstrap/Tailwind in a low layer, your code in a higher layer */
@layer vendors, base, components, custom;

@layer vendors {
  /* Import Bootstrap but put it in vendors layer */
  @import url('bootstrap.css') layer(vendors);
}

@layer base {
  /* Your base styles */
}

@layer components {
  /* Your components */
}

@layer custom {
  /* Overrides everything — put your customizations here */
}

/* Any .button override in custom layer beats Bootstrap's .button */
@layer custom {
  .button {
    background: custom-blue; /* Always wins */
  }
}

The :has() Selector: Parent Selection and Beyond

The :has() selector is now universally supported and fundamentally changes what’s possible in CSS.

Parent Selection

/* Style a form group if it contains an invalid input */
.form-group:has(:invalid) {
  border-color: red;
  background: rgba(255, 0, 0, 0.05);
}

/* Style a card differently if it has an image */
.card:has(.card-image) {
  grid-template-columns: 200px 1fr;
}

/* Style a nav differently if it has a dropdown */
.nav:has(.dropdown) {
  position: relative;
}

Relational Logic

/* Style an article if the previous sibling is also an article */
.article:not(:has(+ .article)) {
  border-bottom: none; /* Last article in a list */
}

/* Style a label if its input is checked */
.checkbox-label:has(input:checked) {
  font-weight: bold;
  color: green;
}

Counter-Patterns

/* Style a list that has more than 5 items */
.list:has(> :nth-child(5)) {
  columns: 2; /* Multi-column for long lists */
}

/* Style a grid differently based on item count */
.grid:has(> :nth-child(4)) {
  grid-template-columns: repeat(2, 1fr);
}

The @property Rule: Typed Custom Properties

CSS custom properties were always untyped. @property changes that.

@property --gradient-angle {
  syntax: '<angle>';
  initial-value: 0deg;
  inherits: false;
}

@property --gradient-position {
  syntax: '<percentage>';
  initial-value: 0%;
  inherits: false;
}

.gradient-animated {
  background: linear-gradient(
    var(--gradient-angle),
    red,
    blue
  );
  animation: rotate-gradient 3s linear infinite;
}

@keyframes rotate-gradient {
  to {
    --gradient-angle: 360deg;
  }
}

/* Now animations work because TypeScript knows it's an angle */

Modern CSS Patterns That Replace JavaScript

Sticky Headers Without JavaScript

.header {
  position: sticky;
  top: 0;
  z-index: 100;
  background: white;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

Scroll Snap (Replaces Carousel Libraries)

.carousel {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  gap: 1rem;

  /* Hide scrollbar but keep functionality */
  scrollbar-width: none;
  &::-webkit-scrollbar { display: none; }
}

.carousel-item {
  flex: 0 0 300px;
  scroll-snap-align: start;
}

Field Validation States (No JavaScript)

input:valid {
  border-color: green;
}

input:invalid:not(:placeholder-shown) {
  border-color: red;
}

input:out-of-range {
  background: rgba(255, 0, 0, 0.1);
}

The Death of Utility-Class Bloat

Here’s the pattern I’ve seen work in 2026: Cascade Layers + Container Queries + Modern Selectors = No Tailwind Required.

Before (Tailwind-Heavy)

<div class="flex flex-col md:flex-row gap-4 p-4 md:p-6 lg:p-8 bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow">
  <div class="w-full md:w-1/3">
    <img class="w-full h-48 md:h-full object-cover rounded-t-lg md:rounded-l-lg md:rounded-tr-none" src="..." />
  </div>
  <div class="flex-1 p-4">
    <h3 class="text-xl font-bold text-gray-900 mb-2">Title</h3>
    <p class="text-gray-600 text-sm mb-4">Content</p>
  </div>
</div>

After (Native CSS with Layers)

@layer components {
  .feature-card {
    display: grid;
    grid-template-areas:
      "image"
      "content";
    background: white;
    border-radius: 0.5rem;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    transition: box-shadow 0.2s;

    &:hover {
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }
  }

  @container (min-width: 400px) {
    .feature-card {
      grid-template-areas: "image content";
      grid-template-columns: 1fr 2fr;
    }
  }

  .feature-card-image {
    grid-area: image;
    width: 100%;
    height: 12rem;
    object-fit: cover;

    @container (min-width: 400px) {
      height: 100%;
      border-radius: 0.5rem 0 0 0.5rem;
    }
  }

  .feature-card-content {
    grid-area: content;
    padding: 1rem;
  }
}
<div class="feature-card">
  <img class="feature-card-image" src="..." />
  <div class="feature-card-content">
    <h3>Title</h3>
    <p>Content</p>
  </div>
</div>

The CSS Stack in 2026

Modern CSS (2026):
├── Container Queries     → Component-level responsiveness
├── Cascade Layers        → Specificity control
├── :has() Selector       → Relational styling
├── @property            → Typed animations
├── @layer               → Import ordering
├── gap in flexbox       → No more margin hacks
├── subgrid              → Aligned nested grids
└── color-mix()          → Dynamic color utilities

Supporting Tools:
├── PostCSS with autoprefixer (minimal)
├── Lightning CSS (10x faster than PostCSS)
└── Vanilla Extract (type-safe CSS-in-TS, when needed)

My Recommendation

Stop reaching for Tailwind by default. For most projects, modern CSS with Cascade Layers gives you:

  • Better maintainability (readable class names)
  • Smaller bundle (no utility CSS to download)
  • No build step for basic projects
  • True cascade inheritance

Use Tailwind when:

  • You need rapid prototyping with a team unfamiliar with CSS
  • You’re building a design system with a dedicated CSS expert
  • The project genuinely needs 500+ utility classes

Use native CSS when:

  • You’re building a product that needs maintainable styles
  • Your team knows CSS
  • You want smaller bundles and no runtime dependency

The era of “CSS is too hard, let’s use a utility framework” is over. Modern CSS has caught up.

What’s your CSS stack in 2026? Have you moved back to vanilla CSS, or are you still all-in on utility frameworks?

Multi-tenant SaaS for $30/mo: wildcard SSL + nginx vhost generator + Flask routing

Multi-tenant SaaS for $30/mo: the actual architecture

Every “build a SaaS” tutorial reaches for Kubernetes, managed Postgres, and three cloud services before the first user signs up. You don’t need that. Here’s a multi-tenant setup that runs 50+ tenants on subdomains, on one $30/mo VPS.

The requirement

White-label partnership program. Each partner gets a storefront at partner-{slug}.guardlabs.online with our catalog, their referral IDs baked in. New partner → automatic provisioning after payment webhook.

Phase 1 (0-50 tenants): wildcard SSL + nginx + Flask

Wildcard SSL via Let’s Encrypt — one cert covers *.guardlabs.online:

certbot certonly --manual --preferred-challenges dns 
  -d "*.guardlabs.online" -d "guardlabs.online"

(DNS-01 challenge — add the TXT record your DNS provider, certbot validates.)

nginx server block routing by $host:

server {
    listen 443 ssl;
    server_name ~^partner-(?<partner_slug>.+).guardlabs.online$;
    ssl_certificate     /etc/letsencrypt/live/guardlabs.online/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/guardlabs.online/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:8090;
        proxy_set_header Host $host;
        proxy_set_header X-Partner-Slug $partner_slug;
    }
}

Flask reads the header, scopes everything to that partner:

@app.before_request
def load_partner():
    slug = request.headers.get("X-Partner-Slug")
    if slug:
        g.partner = db.execute(
            "SELECT * FROM partners WHERE slug=?", (slug,)
        ).fetchone()
        if not g.partner:
            abort(404)

@app.route("/")
def storefront():
    products = get_catalog()  # global catalog
    return render_template("store.html",
        products=products,
        ref_id=g.partner["ref_id"],  # injected into every buy link
        partner_brand=g.partner["brand_name"])

Provisioning on webhook — when Whop sends “new subscription”:

@app.route("/webhook/whop", methods=["POST"])
def whop_webhook():
    event = verify_and_parse(request)
    if event["type"] == "subscription.created":
        slug = slugify(event["user"]["username"])
        # 1. DB row
        db.execute("INSERT INTO partners (slug, ref_id, ...) VALUES (...)")
        # 2. nginx — no per-partner config needed! Regex server_name catches it.
        # 3. Done. partner-{slug}.guardlabs.online works immediately.
    return "", 200

That’s the trick: the regex server_name means zero new nginx config per partner. Wildcard SSL means zero new certs. The DB row is the only write.

Capacity: one VPS, monolith Flask, SQLite. ~50 partners with 10 concurrent users each = 500 concurrent. A $30/mo Hetzner CCX handles that without breathing hard.

Phase 2 (50-200 tenants): per-tenant SQLite

Move from shared DB to /data/partners/{partner_id}/store.db. Flask switches connection by X-Partner-Slug. ~16 hours of refactor. SQLite holds 10k+ rows per file fine; 200 partners × 10k = 2M rows total, no problem.

Phase 3 (200-500+): PostgreSQL schema-per-tenant

Now you migrate. One Postgres, schema per partner. 40 hours. Now you might want Kubernetes. Not before.

The point

I provisioned this whole thing in about 8 hours. Total infra budget for 12 months: under $5K. The “you need microservices and managed everything” advice is for companies with funding and a platform team. For a solo founder: nginx regex + wildcard SSL + a Flask before_request hook gets you to 50 paying tenants.

Try it

The partnership it powers: 7-day free trial, no card, $29/mo. guardlabs.online/partner. And the bot the whole thing grew out of: Phantom paper-trader (384 trades, 57% win-rate, public).

Question

What’s your “we over-engineered this” story? When did you reach for Kubernetes before you needed it?

Code and full launch log in public. Following along.

📥 Free chapter — 20 no-budget growth tactics

This launch log runs on a playbook. If you want the actual tactics — Google-ecosystem hacks, trend-jacking, the HARO authority play — grab two free sections of the Blueprint. No PDF wall, no login: it opens in your browser. Real numbers, real code, no fluff.

→ guardlabs.online/free-pdf

JWT Auth in Express with TS

Project Structure

└── src/
    ├── @types/
    ├── controllers/
    ├── middlewares/
    ├── models/
    ├── routes/
    ├── utils/
    ├── app.ts
    └── index.ts

Install Required Packages

npm install jsonwebtoken bcrypt mongoose cookie-parser
npm install -D @types/jsonwebtoken @types/bcrypt

Environment Variables

Create a .env file:

ACCESS_TOKEN_SECRET=
ACCESS_TOKEN_EXPIRY=

REFRESH_TOKEN_SECRET=
REFRESH_TOKEN_EXPIRY=

Custom ApiResponse & ApiError

Before building authentication, I use custom helper classes for consistent API responses and error handling.

Benefits:

  • Cleaner controller code
  • Consistent API structure
  • Easier frontend handling
  • Better debugging
  • Scalable architecture

ApiError Helper

src/utils/ApiError.ts

export class ApiError extends Error {
  statusCode: number;
  message: string;
  errors: any[];
  stack?: string;
  data: any;
  success: boolean;

  constructor(
    statusCode: number,
    message = "Something went wrong",
    errors = [],
    stack = ""
  ) {
    super(message)
    this.statusCode = statusCode
    this.data = null
    this.message = message
    this.success = false
    this.errors = errors

    if (stack) {
      this.stack = stack
    } else {
      Error.captureStackTrace(this, this.constructor)
    }
  }
}

ApiResponse Helper

src/utils/ApiResponse.ts

export class ApiResponse {
  statusCode: number;
  data: any;
  message: string;
  success: boolean;

  constructor(
    statusCode: number,
    data: any,
    message: string = "Success"
  ) {
    this.statusCode = statusCode
    this.data = data
    this.message = message
    this.success = statusCode < 400
  }
}

Why Use This Pattern?

Instead of manually writing responses like:

return res.status(200).json({
  success: true,
  message: "Logged in",
  data,
});

everywhere, helper classes keep responses standardized across the entire backend.

Why Use Access & Refresh Tokens?

A common mistake beginners make is using only one JWT token.

Instead, we use:

  • Access Token → short-lived authentication
  • Refresh Token → generates new access tokens

Benefits:

  • Better security
  • Users stay logged in
  • Easier session invalidation
  • Refresh token rotation support

This is the same architecture used in many production apps.

User Model

src/models/User.model.ts

import { model, Schema, type HydratedDocument } from "mongoose";
import jwt, { type Secret, type SignOptions } from "jsonwebtoken";
import bcrypt from "bcrypt";

export interface IUser {
  username: string;
  fullName: string;
  email: string;
  password: string;
  refreshToken?: string;

  generateAccessToken: () => string;
  generateRefreshToken: () => string;
  isPasswordCorrect: (password: string) => Promise<boolean>;
}

export type IUserDocument = HydratedDocument<IUser>;

const userSchema = new Schema<IUser>({
  username: String
  fullName: String
  email: String
  password: String
  refreshToken: String
});

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

userSchema.methods.generateAccessToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
      email: this.email,
      username: this.username,
      fullName: this.fullName,
    },
    process.env.ACCESS_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.ACCESS_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

userSchema.methods.generateRefreshToken = function (): string {
  return jwt.sign(
    {
      _id: this._id,
    },
    process.env.REFRESH_TOKEN_SECRET!! as Secret,
    {
      expiresIn: process.env.REFRESH_TOKEN_EXPIRY!!,
    } as SignOptions
  );
};

export const User = model<IUser>("User", userSchema);

Why Use Mongoose Methods?

Instead of creating separate utility functions, we attach methods directly to the schema.

Benefits:

  • Cleaner code
  • Better TypeScript support
  • Easier reusability
  • Logic stays close to the model

Password Hashing with Mongoose Middleware

userSchema.pre("save", async function (): Promise<void> {
  if (!this.isModified("password")) return;

  this.password = await bcrypt.hash(this.password, 10);
});

Why this is good:

  • Password hashing becomes automatic
  • Prevents accidental plaintext passwords
  • Avoids duplicate hashing logic

Compare Passwords

userSchema.methods.isPasswordCorrect = async function (
  password: string
): Promise<boolean> {
  return await bcrypt.compare(password, this.password);
};

We use bcrypt.compare() because hashed passwords cannot be decrypted.

Token Generation Helper

const generateTokens = async (userId: Types.ObjectId | string): Promise<{ accessToken: string; refreshToken: string }> => {
  try {
    const user = await User.findById(userId);
    if (!user) {
      throw new ApiError(404, "User not found");
    }

    const refreshToken = user.generateRefreshToken();
    const accessToken = user.generateAccessToken();

    user.refreshToken = refreshToken;
    await user.save({ validateBeforeSave: false });

    return { accessToken, refreshToken };
  } catch (err) {
    console.log(err)
    throw new ApiError(500, "Error while generating tokens");
  }
}

Why Store Refresh Tokens in Database?

Many tutorials skip this.

Storing refresh tokens allows us to:

  • Logout users properly
  • Invalidate sessions
  • Rotate refresh tokens
  • Detect token reuse attacks

This is much safer than purely stateless JWT auth.

Register User Controller

export const registerUser = async (req: Request, res: Response) => {
  const { username, email, fullName, password } = req.body;

  if (
    [fullName, email, username, password].some((field) => field?.trim() === "")
  ) {
    throw new ApiError(400, "All fields are required");
  }

  const userExists = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (userExists) {
    throw new ApiError(409, "User with the same username or email already exists");
  }


  const user = await User.create({
    fullName,
    email,
    password,
    username: username.toLowerCase(),
  });

  const createdUser = await User.findById(user._id).select("-password -refreshToken");

  if (!createdUser) {
    throw new ApiError(500, "Error while creating user");
  }

  return res.status(201).json(
    new ApiResponse(201, createdUser, "User registered successfully")
  )

};

Why Remove Password & Refresh Token?

.select("-password -refreshToken")

Never send sensitive fields back to the client.

Even hashed passwords should never leave the backend.

Login Controller

export const loginUser = async (req: Request, res: Response) => {

  const { username, email, password } = req.body;

  if (!username && !email) {
    throw new ApiError(400, "Username or email is required");
  } 

  if (!password) {
    throw new ApiError(400, "Password is required");
  }

  const user = await User.findOne({
    $or: [{ username }, { email }]
  });

  if (!user) {
    throw new ApiError(404, "User not found");
  }

  const isPassValid: boolean = await user.isPasswordCorrect(password);
  if (!isPassValid) {
    throw new ApiError(401, "Invalid password");
  }

  const { accessToken, refreshToken } = await generateTokens(user._id);

  const userData = {
    _id: user._id,
    fullName: user.fullName,
    username: user.username,
    email: user.email,
    avatarUrl: user.avatarUrl,
  }

  return res
    .status(200)
    .cookie("accessToken", accessToken, cookieOptions)
    .cookie("refreshToken", refreshToken, cookieOptions)
    .json(
      new ApiResponse(200, {
        user: userData,
        accessToken,
        refreshToken
      }, "User logged in successfully")
    );
};

Why Use Cookies Instead of LocalStorage?

We use:

httpOnly: true

Benefits:

  • Protects against XSS attacks
  • JavaScript cannot access tokens
  • More secure than localStorage

Cookie Options

const cookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === "production",
  sameSite: "strict" as const,
};

Verify JWT Middleware

src/middlewares/auth.middleware.ts

import type { NextFunction, Request, Response } from "express";
import { User } from "../models/User.model.js";
import { ApiError } from "../utils/ApiError.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import jwt, { type JwtPayload } from "jsonwebtoken";

export const verifyJWT = async (req: Request, _: Response, next: NextFunction) => {
  try {
    const accessToken = req.cookies?.accessToken || req.header("Authorization")?.replace("Bearer ", "");

    if (!accessToken) {
      throw new ApiError(401, "Access token is missing");
    }

    const decodedToken = jwt.verify(accessToken, process.env.ACCESS_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken._id).select("-password -refreshToken");

    if (!user) {
      throw new ApiError(401, "Invalid Access Token");
    }

    req.user = user;
    next();
  } catch (error: any) {
    throw new ApiError(401,  error?.message || "Invalid Access Token");
  }
};

Why Check Database After JWT Verification?

Some tutorials only verify the JWT.

We additionally verify if the user still exists.

Benefits:

  • Prevents deleted-user access
  • Safer authentication
  • Better security

Refresh Access Token Controller

export const refreshAccessToken = async (req: Request, res: Response) => {
  const incomingRefreshToken = req.cookies.refreshToken || req.body.refreshToken

  if (!incomingRefreshToken) {
    throw new ApiError(401, "Refresh token is missing");
  }

  try {
    const decodedToken = jwt.verify(incomingRefreshToken, process.env.REFRESH_TOKEN_SECRET!!) as JwtPayload;

    const user = await User.findById(decodedToken?._id);

    if (!user) {
      throw new ApiError(401, "Invalid refresh token - user not found");
    }

    if (user.refreshToken !== incomingRefreshToken) { 
      throw new ApiError(401, "Invalid refresh token");
    }

    const { accessToken, refreshToken: newRefreshToken } = await generateTokens(user._id);

    return res
      .status(200)
      .cookie("accessToken", accessToken, cookieOptions)
      .cookie("refreshToken", newRefreshToken, cookieOptions)
      .json(
        new ApiResponse(200, {
          accessToken,
          refreshToken: newRefreshToken,
        }, "Access token refreshed successfully"
        )
      );
  } catch (err: any) {
    throw new ApiError(401, err?.message || "Invalid refresh token");
  }
};

Logout Controller

export const logoutUser = async (req: Request, res: Response) => {
  const userId = req.user?._id;

  if (!userId) {
    throw new ApiError(400, "User ID is required");
  }

  await User.findByIdAndUpdate(userId, {
    $unset: { refreshToken: 1 }
  }, { returnDocument: "after" });  

  return res
    .status(200)
    .clearCookie("accessToken", cookieOptions)  
    .clearCookie("refreshToken", cookieOptions)
    .json(new ApiResponse(200, {}, "User logged out successfully"));
};

Extend Express Request Types

src/@types/express/index.d.ts

import type { IUserDocument } from "../../models/User.model.js";

declare module "express-serve-static-core" {
  interface Request {
    user?: IUserDocument;
  }
}

export {};

tsconfig.json

{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./src/@types"
    ]
  },

  "include": [
    "src/**/*",
    "src/@types/**/*.d.ts"
  ]
}

Final Thoughts

You now have a production-style JWT Authentication system with:

  • Access Tokens
  • Refresh Tokens
  • HTTP-only cookies
  • Protected routes
  • Password hashing
  • Token rotation
  • Secure logout

You can further improve this by adding:

  • Email verification
  • Password reset
  • Rate limiting
  • 2FA authentication
  • OAuth logins
  • Redis session storage

Happy coding 🚀