<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Next.js - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Next.js - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 05 Oct 2026 05:48:03 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/nextjs/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Support System That Automatically Routes Bugs to GitHub with Next.js and Jev ]]>
                </title>
                <description>
                    <![CDATA[ Every website gets feedback, and most of it ends up somewhere awkward. A visitor finds a broken button and emails you. Someone else leaves a comment on social media about a page that won't load on the ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-an-ai-support-system-that-automatically-routes-bugs-to-github/</link>
                <guid isPermaLink="false">6abfc515257f8ade20662b79</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Andrew Baisden ]]>
                </dc:creator>
                <pubDate>Fri, 02 Oct 2026 14:52:05 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/13431986-02ba-4353-8fa7-793542e0e03f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every website gets feedback, and most of it ends up somewhere awkward. A visitor finds a broken button and emails you. Someone else leaves a comment on social media about a page that won't load on their phone. A third person fills in your contact form with a feature idea, and it sits in your inbox between a newsletter and a receipt.</p>
<p>When you finally sit down to fix things, the bug reports are scattered across three places. Half of them are missing details, and the ones that do make it into GitHub were copied there by hand, sometimes with the visitor's email address still pasted into a public issue.</p>
<p>I wanted something better for my own projects, so I built it. <strong>IssueRelay</strong> gives any React website a small support widget where visitors can ask a question, report a bug, or suggest a feature. Every report is saved to your own database first. Then an AI model called Jev classifies it, a set of plain rules in code decides where it goes, and you review it in a private dashboard.</p>
<p>When you confirm that a report really is a bug, IssueRelay creates one clean GitHub issue for it, with the visitor's private details removed. When you later close that issue on GitHub, the support ticket closes too.</p>
<p>In this tutorial, you'll learn how the whole system works, from the widget in the browser to the webhook that keeps GitHub and the dashboard in sync. You'll also see how to deploy your own copy in about 15 minutes.</p>
<p>IssueRelay is open source on GitHub at <a href="https://github.com/andrewbaisden/issuerelay">andrewbaisden/issuerelay</a>, the widget is published on npm as <a href="https://www.npmjs.com/package/@issuerelay/widget"><code>@issuerelay/widget</code></a>, and it's running in production on my portfolio website right now.</p>
<p>I won't paste the whole codebase into this article. The repository has every file, and the setup guide walks through installation step by step. Instead, I'll show you the small pieces of code that carry the important ideas, explain what each one does, and share what I learned while building, testing, and deploying it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/41588a15-244b-496e-b48e-a284a26d2526.png" alt="The IssueRelay support widget open on a website, showing the Ask a question, Report a bug, and Suggest a feature options" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-table-of-contents">Table of Contents</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-an-ai-support-system-can-help-any-website">How an AI Support System Can Help Any Website</a></p>
</li>
<li><p><a href="#heading-what-well-build">What We'll Build</a></p>
</li>
<li><p><a href="#heading-what-is-jev">What Is Jev?</a></p>
</li>
<li><p><a href="#heading-the-tech-stack">The Tech Stack</a></p>
</li>
<li><p><a href="#heading-how-a-report-travels-through-the-system">How a Report Travels Through the System</a></p>
</li>
<li><p><a href="#heading-how-to-deploy-your-own-issuerelay">How to Deploy Your Own IssueRelay</a></p>
</li>
<li><p><a href="#heading-running-it-on-a-real-website">Running It on a Real Website</a></p>
</li>
<li><p><a href="#heading-testing-it-end-to-end-and-what-i-learned">Testing It End to End (and What I Learned)</a></p>
</li>
<li><p><a href="#heading-how-it-was-built-phases-and-ai-assisted-development">How It Was Built: Phases and AI Assisted Development</a></p>
</li>
<li><p><a href="#heading-publishing-the-widget-to-npm">Publishing the Widget to npm</a></p>
</li>
<li><p><a href="#heading-what-is-next">What Is Next</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along and deploy your own copy, you should have:</p>
<ul>
<li><p><strong>Working knowledge of React, Next.js, and TypeScript:</strong> the platform uses the Next.js App Router, and the widget is a React component.</p>
</li>
<li><p><strong>Node.js 24 and pnpm:</strong> installed if you want to run the project locally or use the command that creates your GitHub App.</p>
</li>
<li><p><strong>A GitHub account:</strong> plus the repository for the website or app where you want to install the widget. Confirmed bugs become issues there.</p>
</li>
<li><p><strong>A Vercel account:</strong> The free Hobby plan is enough. You'll add a Neon PostgreSQL database through Vercel's marketplace, and Neon also has a free plan.</p>
</li>
<li><p><strong>A TypeSafe account:</strong> at <a href="https://typesafe.ai">typesafe.ai</a> for Jev, the AI model that triages reports. You need an API key from the <a href="https://console.typesafe.ai/keys">TypeSafe console</a> before any report can become a GitHub issue.</p>
</li>
<li><p><strong>A React website:</strong> where you can add a component. A Next.js site is the easiest place to start.</p>
</li>
<li><p><strong>Optional: a Resend account:</strong> if you want account emails such as password resets.</p>
</li>
</ul>
<p>You don't need to be an AI expert to follow along. Jev is used through a small, typed SDK, and most of the interesting work is ordinary web engineering: databases, validation, authentication, and webhooks.</p>
<h2 id="heading-how-an-ai-support-system-can-help-any-website">How an AI Support System Can Help Any Website</h2>
<p>A support system sounds like something only big companies need, but the problem it solves shows up on almost every website:</p>
<ul>
<li><p><strong>Portfolio sites</strong> get messages from recruiters, questions about projects, and reports about pages that break on a particular browser.</p>
</li>
<li><p><strong>SaaS products</strong> get bug reports mixed with billing questions and feature requests, and each one needs a different person or process.</p>
</li>
<li><p><strong>Documentation sites</strong> get "this example doesn't work" reports that are really bugs in the product.</p>
</li>
<li><p><strong>Open source projects</strong> get users who won't open a GitHub issue themselves but will happily click a button on the website.</p>
</li>
<li><p><strong>Client sites</strong> you built for someone else get feedback that the client forwards to you days later with no details.</p>
</li>
</ul>
<p>A good system gives you one place where every report arrives, keeps each report safe even when other services fail, and sorts reports so you spend your time on the ones that matter.</p>
<p>The AI part helps with the sorting, but it should never be in charge. A model can be confidently wrong, and a public GitHub issue isn't something you want to create on a guess. So IssueRelay follows one simple rule throughout: AI recommends, a human confirms, and code enforces the rules.</p>
<h2 id="heading-what-well-build">What We'll Build</h2>
<p>Here's the journey of a single report through IssueRelay:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/69c55783-997b-4f80-876b-22721315e898.png" alt="Here is the journey of a single report through IssueRelay" style="display: block;" width="600" height="400" loading="lazy">

<p>A visitor opens the widget on your site and picks a topic:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/2a45c808-676c-4a10-8b55-93a0e393e262.png" alt="The widget open on a demo site with its three topics: Ask a question, Report a bug, and Suggest a feature" style="display: block;" width="600" height="400" loading="lazy">

<p>They describe the problem and can optionally leave a name and email so you can follow up:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/cdcf5b75-44da-47f5-9eeb-21583dcfae0c.png" alt="The Report a bug form in the widget with a message, a name, and an email address filled in" style="display: block;" width="600" height="400" loading="lazy">

<p>The widget sends the report to your IssueRelay platform, which saves it and replies with a support reference the visitor can quote later:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/39e72c56-5dc2-4c76-bc86-fc9d65e71bc4.png" alt="The widget confirming Message received with the support reference SUP-9" style="display: block;" width="600" height="400" loading="lazy">

<p>From there, the report becomes a ticket in your dashboard. Jev classifies it, you review it, and if it is a real bug, one click creates a GitHub issue in your repository.</p>
<p>Here's the full feature list:</p>
<ul>
<li><p><strong>An embeddable widget:</strong> Built as a React component inside a Shadow DOM, so it needs no CSS setup and never clashes with your site's styles. It works in the Next.js App Router and under a strict Content Security Policy.</p>
</li>
<li><p><strong>Durable intake:</strong> Every report is stored in PostgreSQL before anything else runs, with protection against duplicates, per project rate limits, and a list of allowed site addresses.</p>
</li>
<li><p><strong>Bounded AI triage:</strong> Jev recommends a type and severity from the message alone.</p>
</li>
<li><p><strong>A private dashboard:</strong> With filters, classification history, and human review decisions that are stored separately from the AI's output.</p>
</li>
<li><p><strong>Careful GitHub escalation:</strong> Issues are created by a GitHub App only after an owner confirms a preview, and contact details never leave IssueRelay.</p>
</li>
<li><p><strong>Two way sync:</strong> Closing or reopening the issue on GitHub updates the ticket through signed webhooks.</p>
</li>
<li><p><strong>Self hosting:</strong> A Deploy button, a first run setup page, and a settings page make it possible to run your own copy without touching the database.</p>
</li>
</ul>
<h2 id="heading-what-is-jev">What Is Jev?</h2>
<p>Jev is a model from <a href="https://typesafe.ai">TypeSafe</a> that's built for what TypeSafe calls "System One" tasks: quick, bounded judgments as opposed to long, open ended writing.</p>
<p>Instead of asking a model to write a paragraph and then trying to parse it, you give Jev some state and a set of questions, and each question has a fixed list of possible answers. Jev picks an answer for each question and returns the probability it assigned to every option.</p>
<p>That shape is exactly what support triage needs. A ticket is a bug, a question, a feature request, a billing problem, or spam. It's low, medium, high, or critical. There's no text for the model to invent, no prompt injection that can make it write an issue title, and no free text to clean up afterwards. The output is a label and a number, and your code can check both.</p>
<p>It's also cheap and fast. At the time of writing, TypeSafe lists Jev at $42 per billion input tokens, and a support message is a few dozen tokens. The IssueRelay integration sends Jev only the visitor's message and the topic they picked. It never sends names, email addresses, ticket IDs, or anything else that identifies a person.</p>
<h2 id="heading-the-tech-stack">The Tech Stack</h2>
<p>IssueRelay is built with a modern TypeScript stack, and it's the same stack I use for my own projects. If you've read <a href="https://www.freecodecamp.org/news/author/andrewbaisden/">my other articles</a>, a lot of it will look familiar:</p>
<ul>
<li><p><strong>Next.js 16 (App Router) and React 19</strong> for the platform and the dashboard</p>
</li>
<li><p><strong>Strict TypeScript</strong> everywhere, with <strong>Zod</strong> checking every input that crosses a trust boundary: public API requests, environment variables, AI output, and GitHub webhook payloads</p>
</li>
<li><p><strong>PostgreSQL with Drizzle ORM</strong> and reviewed SQL migrations</p>
</li>
<li><p><strong>Better Auth</strong> for dashboard accounts</p>
</li>
<li><p><strong>The official TypeSafe SDK</strong> for Jev, and <strong>Octokit</strong> for the GitHub App</p>
</li>
<li><p><strong>Vitest, React Testing Library, and Playwright</strong> for tests, and <strong>Biome</strong> for linting and formatting</p>
</li>
<li><p><strong>pnpm workspaces</strong> to hold everything in one monorepo</p>
</li>
<li><p><strong>Vercel, Neon, and Resend</strong> in production</p>
</li>
</ul>
<p>The monorepo is split into small packages, each with a strict job:</p>
<table>
<thead>
<tr>
<th>Package</th>
<th>Responsibility</th>
</tr>
</thead>
<tbody><tr>
<td><code>apps/web</code></td>
<td>The platform: the public ticket API, the dashboard, setup, and the GitHub webhook</td>
</tr>
<tr>
<td><code>packages/widget</code></td>
<td>The browser widget published to npm. It never imports server code.</td>
</tr>
<tr>
<td><code>packages/support-contracts</code></td>
<td>The request and response shapes shared by the widget and the API</td>
</tr>
<tr>
<td><code>packages/db</code></td>
<td>The Drizzle schema, migrations, and every database query</td>
</tr>
<tr>
<td><code>packages/ai</code></td>
<td>The Jev adapter, the triage service, and the routing policy</td>
</tr>
<tr>
<td><code>packages/github</code></td>
<td>The GitHub App client, issue drafts, the privacy gate, and webhook handling</td>
</tr>
<tr>
<td><code>packages/auth</code></td>
<td>Better Auth setup, sessions, and workspace membership checks</td>
</tr>
</tbody></table>
<p>The boundaries matter more than they might look like they do. React components never talk to GitHub, Jev, or the database directly. Browser code never contains a secret. The AI package can't import the database.</p>
<p>Keeping those lines strict made the system much easier to test and to reason about, and it's the reason the widget can be published to npm without dragging any server code along with it.</p>
<h2 id="heading-how-a-report-travels-through-the-system">How a Report Travels Through the System</h2>
<p>Let's follow one report from the visitor's browser all the way to a closed GitHub issue.</p>
<h3 id="heading-step-1-the-widget">Step 1: The Widget</h3>
<p>The widget is a normal React component that you install from npm:</p>
<pre><code class="language-shell">npm install @issuerelay/widget
</code></pre>
<p>Then you render it once, for example from a client component in your root layout:</p>
<pre><code class="language-typescript">"use client";

import {
  HttpSupportSubmissionClient,
  SupportWidget,
} from "@issuerelay/widget";

const submissionClient = new HttpSupportSubmissionClient({
  apiBaseUrl: "https://your-issuerelay.vercel.app",
});

export function Support() {
  return (
    &lt;SupportWidget
      projectKey="pk_your_project_key"
      submissionClient={submissionClient}
      theme="system"
      position="bottom-right"
    /&gt;
  );
}
</code></pre>
<p><code>HttpSupportSubmissionClient</code> is the part that talks to your platform. It posts each report to your IssueRelay API without cookies or credentials, and it gives every report a submission ID so that a retry after a network error doesn't create a second ticket.</p>
<p><code>SupportWidget</code> is the button and panel your visitors see. The <code>projectKey</code> tells the platform which project the report belongs to. It's public identification, not a password, so it's safe to put in your site's code. The real protection is on the server, which only accepts reports from the site addresses you list for that project.</p>
<p>The <code>"use client"</code> line is there because the submission client is created in the browser. In the Next.js App Router, you wrap the widget in your own small client component like this and render that component from your layout.</p>
<p>Under the hood, the widget renders inside a Shadow DOM with its own bundled styles, so your site doesn't need Tailwind or a CSS import, and your styles can't accidentally restyle it.</p>
<p>You don't have to write this code by hand, either. IssueRelay's project settings page shows this exact snippet with your platform address and project key already filled in.</p>
<h3 id="heading-step-2-save-first-think-later">Step 2: Save First, Think Later</h3>
<p>When the report reaches the API, the first thing IssueRelay does is save it. Not classify it, not send it anywhere, just store it in PostgreSQL inside a transaction.</p>
<p>This is the most important design decision in the whole system. AI providers have outages. GitHub has outages. If the platform called Jev before saving the report and Jev timed out, the visitor's message would be lost, and they would never know.</p>
<p>So the rule is simple: <strong>a report is accepted only after it's safely stored, and a failure in any later step can never erase it.</strong> If Jev is down, the ticket waits in the dashboard until you run triage again.</p>
<p>Before saving, the API checks a few things:</p>
<ul>
<li><p>The request body matches the shared Zod contract, so bad input is rejected with a clear error.</p>
</li>
<li><p>The project key exists, and the request's origin is one of the project's allowed site addresses.</p>
</li>
<li><p>The project is under its rate limit.</p>
</li>
<li><p>The submission ID hasn't been used before. A repeated submission returns the original ticket reference instead of creating a duplicate.</p>
</li>
</ul>
<h3 id="heading-step-3-triage-with-jev">Step 3: Triage with Jev</h3>
<p>Once a ticket is stored, the triage service asks Jev to classify it. Here's the heart of the Jev adapter, from <code>packages/ai/src/jev-classifier.ts</code> (trimmed a little for space):</p>
<pre><code class="language-typescript">const response = await this.client.systemOne({
  state: {
    message: input.message,
    category_hint: input.categoryHint ?? null,
  },
  questions: {
    ticket_type: choice(
      "What kind of support ticket is this? The visitor-selected category hint is a weak signal, not ground truth: judge from the message content.",
      {
        question: "The visitor asks how something works or what something is.",
        bug: "Something is broken, errors, or behaves incorrectly.",
        feature_request: "The visitor requests new functionality or an improvement.",
        spam: "Unsolicited advertising, scams, or irrelevant bulk content.",
        // ...account, billing, feedback, and other
      },
    ),
    severity: choice("How urgent is this ticket?", {
      low: "Minor inconvenience, cosmetic issue, or general question.",
      medium: "Broken functionality with a workaround, or a routine request.",
      high: "Major functionality unavailable, no workaround, time-sensitive.",
      critical: "Security breach, data loss, privacy exposure, or billing harm.",
    }),
  },
});
</code></pre>
<p><code>systemOne</code> is the TypeSafe SDK call for bounded questions. The <code>state</code> object is everything Jev is allowed to see: the message and the topic the visitor picked.</p>
<p>Notice what's missing. There's no name, no email, and no ticket ID, because none of them help with classification and all of them would be private data leaving your platform.</p>
<p>Each <code>choice</code> defines one question and its possible answers. The descriptions next to each label tell Jev what the label means. The ticket type question also tells Jev to treat the visitor's chosen topic as a weak hint, because people often pick "Report a bug" for a question, or "Ask a question" for something that's clearly broken.</p>
<p>What comes back isn't trusted automatically. The adapter validates the response with a Zod schema, checks that both answers are labels from the allowed lists, and uses the probability Jev gave to the chosen type label as the confidence score.</p>
<p>If that probability is missing or outside the range 0 to 1, the result is rejected, and the ticket stays in review instead of getting a made up number.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/2530f001-0850-4603-8694-5125766b421c.png" alt="A ticket in the dashboard after triage: Jev classified it as a medium severity bug with a confidence of 1.00 and recommended it for GitHub" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-step-4-code-makes-the-decisions">Step 4: Code Makes the Decisions</h3>
<p>Jev recommends a type and a severity. It doesn't decide where a ticket goes or whether it becomes a GitHub issue. That job belongs to plain functions in <code>packages/ai/src/policy.ts</code>:</p>
<pre><code class="language-typescript">export function routeForType(type: TicketType): TicketRoute {
  switch (type) {
    case "bug":
      return "engineering";
    case "feature_request":
      return "product";
    case "spam":
      return "ignore";
    default:
      return "support";
  }
}

export function evaluateGitHubEscalation(input: {
  type: TicketType;
  route: TicketRoute;
  confidence: number;
}): EscalationEvaluation {
  const reasons: string[] = [];
  if (input.type !== "bug") reasons.push(`type is ${input.type}, not bug`);
  if (input.route !== "engineering") reasons.push(`route is ${input.route}, not engineering`);
  if (!(input.confidence &gt;= GITHUB_ESCALATION_CONFIDENCE_THRESHOLD)) {
    reasons.push(`confidence ${input.confidence} is below ${GITHUB_ESCALATION_CONFIDENCE_THRESHOLD}`);
  }
  return { eligible: reasons.length === 0, reasons };
}
</code></pre>
<p><code>routeForType</code> maps each ticket type to a queue. Bugs go to engineering, feature requests go to product, spam is quarantined, and everything else goes to support. Because this is a normal <code>switch</code> statement, you can read it, test it, and change it without touching the AI.</p>
<p><code>evaluateGitHubEscalation</code> decides whether a ticket is even allowed to become a GitHub issue. It must be a bug, it must be in the engineering queue, and its confidence must be at least 0.9. Instead of returning a bare <code>true</code> or <code>false</code>, it collects the reasons a ticket failed, which the dashboard shows so you always know why the <strong>Create GitHub issue</strong> button is missing.</p>
<p>The 0.9 threshold lives in one configuration file with a comment that says it's an uncalibrated starting point, not a measured accuracy. I wanted that to be honest in the code: a model score of 0.99 doesn't mean the model is right 99 percent of the time.</p>
<h3 id="heading-step-5-human-review-in-the-dashboard">Step 5: Human Review in the Dashboard</h3>
<p>Every ticket lands in a private dashboard. The projects page shows how many tickets are in each state:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/f9381b89-d338-48d9-8c47-e8f872674416.png" alt="The dashboard projects page showing two projects with ticket counts for each workflow state" style="display: block;" width="600" height="400" loading="lazy">

<p>Each project has a ticket list with filters for status, route, type, severity, and reference:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/f44934f1-fdd9-4c6a-ad46-f5a32d6d056f.png" alt="The ticket list for a project, with filters and a table of tickets showing their status, route, AI type, severity, and confidence" style="display: block;" width="600" height="400" loading="lazy">

<p>Opening a ticket shows the visitor's report, the current AI classification, the full classification history, and a timeline of everything that happened. You can run triage again, resolve the ticket, or record a review decision that changes the route, the status, or the GitHub recommendation.</p>
<p>One detail I care about: human decisions are stored in their own table, with the author and a required reason. The AI's history is never rewritten. If you override Jev, you can still see exactly what Jev said and when, which is important when you want to know how well the model is really doing.</p>
<p>The dashboard is protected by Better Auth, and every read and write is scoped to a workspace. Mutations require a same origin request, and only workspace owners can publish to GitHub.</p>
<h3 id="heading-step-6-from-bug-report-to-github-issue">Step 6: From Bug Report to GitHub Issue</h3>
<p>When a ticket passes the policy, the dashboard shows a preview of the exact issue that will be created:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/6b77db48-f122-4dcf-9da5-d858f7a6eb5d.png" alt="The GitHub escalation section showing a preview of the issue title and body, with the visitor's name and email absent from the issue" style="display: block;" width="600" height="400" loading="lazy">

<p>Look closely at that screenshot. The visitor left their name and email, and both are visible in the dashboard above, but neither appears anywhere in the issue preview.</p>
<p>That's not a coincidence. Before any issue is created, the report passes through a privacy gate in <code>packages/github/src/privacy.ts</code> that looks for email addresses, phone numbers, card numbers, private keys, API tokens, JSON web tokens, and password assignments. It also checks the report against the contact details the visitor submitted, so "Hi, Sam Visitor here" can't leak a name into a public issue. If anything is found, the preview is blocked and nothing is published.</p>
<p>When you click <strong>Create GitHub issue</strong>, a few more safeguards run:</p>
<ul>
<li><p><strong>A GitHub App, not a personal token:</strong> The App is installed only on the repositories you choose, with permission to write issues and read metadata, and nothing else.</p>
</li>
<li><p><strong>Claim first, then create:</strong> The ticket is marked as <code>creating</code> in the database before GitHub is called, so two clicks can never create two issues.</p>
</li>
<li><p><strong>A hidden marker:</strong> Each issue body ends with an opaque HTML comment tied to the ticket. If a request times out and the result is unknown, IssueRelay searches the repository for that exact marker from its own App before it ever tries again. It never blindly retries an issue it might already have created.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/dc142997-48e0-4b03-8046-6ad5fe4e3320.png" alt="The ticket after escalation, showing the linked GitHub issue and the escalation events in the timeline" style="display: block;" width="600" height="400" loading="lazy">

<p>Here's a real issue that IssueRelay created on my portfolio's public repository from a visitor report. It was created by the App's bot, labelled <code>bug</code>, and contains the report and the AI's classification, but no contact details:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/ec38844a-648e-4b98-b971-45edcadcf968.png" alt="A real GitHub issue created by the IssueRelay bot on a public repository, with a summary, the report, context, and a note that contact details are never published" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-step-7-keeping-github-and-the-dashboard-in-sync">Step 7: Keeping GitHub and the Dashboard in Sync</h3>
<p>The last piece closes the loop. When you close the issue on GitHub, GitHub sends a webhook to IssueRelay, and the ticket moves to resolved. Reopen the issue and the ticket goes back into the queue.</p>
<p>A webhook endpoint is public by definition, so the first thing it does is prove the request really came from GitHub. This is the verification function from <code>packages/github/src/webhook-auth.ts</code>:</p>
<pre><code class="language-typescript">export function verifyWebhookSignature(input: {
  secret: string;
  rawBody: Uint8Array;
  signatureHeader: string | null;
}): boolean {
  const { secret, rawBody, signatureHeader } = input;
  if (!secret || !signatureHeader?.startsWith("sha256=")) {
    return false;
  }
  const hex = signatureHeader.slice("sha256=".length);
  if (!/^[0-9a-f]{64}$/.test(hex)) return false;
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  const actual = Buffer.from(hex, "hex");
  if (expected.length !== actual.length) return false;
  return timingSafeEqual(expected, actual);
}
</code></pre>
<p>GitHub signs every delivery with a secret that only GitHub and your platform know, and sends the signature in the <code>X-Hub-Signature-256</code> header. This function computes its own HMAC SHA256 signature over the <strong>raw request bytes</strong> and compares the two.</p>
<p>Two details are easy to get wrong. First, the signature has to be computed over the exact bytes GitHub sent, before any JSON parsing, because parsing and reformatting would change the bytes. Second, the comparison uses <code>timingSafeEqual</code>, which takes the same amount of time whether the first byte or the last byte differs, so an attacker can't guess the signature one character at a time by measuring response times. The function also returns <code>false</code> for every kind of failure without saying which one, so it leaks nothing.</p>
<p>After the signature check, IssueRelay stores each delivery ID, so a repeated delivery is ignored. It updates only an issue that belongs to the matching App installation and repository, and it applies events in the order they happened on GitHub, not the order they arrived.</p>
<h2 id="heading-how-to-deploy-your-own-issuerelay">How to Deploy Your Own IssueRelay</h2>
<p>You can run your own IssueRelay on Vercel and Neon in about 15 minutes. The complete walkthrough, including troubleshooting, is in <a href="https://github.com/andrewbaisden/issuerelay/blob/main/docs/SELF_HOSTING.md">docs/SELF_HOSTING.md</a>. Here's the short version.</p>
<h3 id="heading-step-1-deploy"><strong>Step 1: Deploy</strong></h3>
<p>The recommended path is the <strong>Deploy with Vercel</strong> button in the README. It copies the repository into your GitHub account, adds a Neon database, and asks for three random secrets.</p>
<p>The first build fails on purpose because Vercel's clone screen has no Root Directory setting, so you set Root Directory to <code>apps/web</code> in the project settings and redeploy. The production build then creates every database table for you.</p>
<p>The guide also describes a <strong>Fork and Import</strong> path that makes future updates a single click, but that path hasn't been tested end to end yet.</p>
<h3 id="heading-step-2-run-the-setup-page"><strong>Step 2: Run the Setup Page</strong></h3>
<p>Open <code>/setup</code> on your new site. It only works while the database has no accounts and you enter the <code>SETUP_TOKEN</code> you created during the deploy, so nobody who finds your URL first can claim your platform. It creates your owner account and your first project, then shows your widget key and the ready to paste widget code.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/6b09f257-4b29-41bd-8c6b-e95db8200504.png" alt="The first run setup page with fields for the setup token, owner account, workspace, site name, and site addresses" style="display: block;" width="600" height="400" loading="lazy">

<p>The site address field starts with <code>http://localhost:3000</code>, which is where a Next.js app runs on your computer. Add your live address too, such as <code>https://my-site.vercel.app</code> or your own domain. If you forget, the widget will politely tell visitors "We couldn't send your message," so this is the first thing to check when a report doesn't arrive.</p>
<h3 id="heading-step-3-create-the-github-app"><strong>Step 3: Create the GitHub App</strong></h3>
<p>Setting up a GitHub App by hand has a few easy mistakes in it. The worst one is forgetting to subscribe to the Issues event, which I did myself during testing. So IssueRelay includes a command that creates the App for you from a manifest:</p>
<pre><code class="language-shell">pnpm github:create-app --platform https://your-issuerelay.vercel.app
</code></pre>
<p>This opens GitHub in your browser with everything already filled in: a private App with permission to write issues and read metadata, subscribed to the Issues event, with its webhook pointing at your platform.</p>
<p>You click <strong>Create GitHub App</strong>, GitHub redirects back to a temporary local server started by the command, and the command writes the App ID, private key, and webhook secret to a file that git ignores. It never prints them in your terminal. The terminal then lists the next setup phase.</p>
<h3 id="heading-step-4-add-your-keys-and-redeploy"><strong>Step 4: Add Your Keys and Redeploy</strong></h3>
<p>Add the three GitHub App values and your <code>TYPESAFE_API_KEY</code> to your Vercel project's environment variables, then redeploy. Jev is required for GitHub issues: without it, reports still arrive in your dashboard, but none can become an issue.</p>
<h3 id="heading-step-5-connect-your-repository"><strong>Step 5: Connect Your Repository</strong></h3>
<p>Install the App on the repository of the website where the widget will live, then open your project's <strong>Settings</strong> page in the dashboard and connect it. The page asks GitHub which installation and repository ID belong to that name, so a typo can't link the wrong repository.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f46a01aa639932bd830f982/d7ca9ddf-9774-4031-a67e-51f4fbec8781.png" alt="The project settings page with the widget key, the ready to paste widget code, the allowed site addresses, and the connected GitHub repository" style="display: block;" width="600" height="400" loading="lazy">

<h3 id="heading-step-6-install-the-widget"><strong>Step 6: Install the Widget</strong></h3>
<p>Install the widget on your site with the code from the settings page, and send your first report.</p>
<p>To keep your copy up to date later, pull changes from the main repository. The guide covers the one time step needed for copies made with the Deploy button, because those copies are not GitHub forks.</p>
<h2 id="heading-running-it-on-a-real-website">Running It on a Real Website</h2>
<p>A demo is one thing, but I wanted to use IssueRelay for real, so the widget now runs on my portfolio at <a href="https://andrewbaisden.com/">andrewbaisden.com</a>:</p>
<p><img src="align=%22center%22" alt="The IssueRelay widget open in the corner of the author's portfolio website, over an illustrated London street scene" width="600" height="400" loading="lazy"></p>
<p>The website design will likely change, so if you're reading this article in the future, previous builds can be found on my GitHub.</p>
<p>Installing it taught me a few things. My portfolio was still on React 18 for its tests, while the App Router was already rendering with React 19. So I upgraded it to React 19 first and made sure every existing test passed before adding the widget. The widget matches the site's light and dark themes, sits in the bottom right corner, and has its own unit test and browser test in the portfolio repository.</p>
<p>Then I tested it like a visitor would. I sent three real reports from the live site: a question, a bug, and a feature request. Jev classified all three the way I intended, with scores between 0.95 and 1.00, and the policy routed them to support, engineering, and product. The bug became issue #3 in my public portfolio repository, which is the issue shown in the screenshot earlier. I had included my name and email with that report, and neither appears in the public issue.</p>
<h2 id="heading-testing-it-end-to-end-and-what-i-learned">Testing It End to End (and What I Learned)</h2>
<p>I didn't want a project that only worked on my machine, so testing was part of every phase instead of something saved for the end.</p>
<p>The test suite has several layers:</p>
<ul>
<li><p><strong>Unit tests</strong> for the widget, the API contract, the AI policy, the privacy gate, the setup page, and more. There are 180 of them, and none need a database.</p>
</li>
<li><p><strong>Database integration tests</strong> that run against a separate PostgreSQL test database, including concurrency tests that prove two clicks can't create two GitHub issues.</p>
</li>
<li><p><strong>Browser tests with Playwright</strong> that start their own servers on separate ports, with a separate database that is recreated for every run, so a test can never touch real data. One of those servers runs against an empty database to test the first run setup page.</p>
</li>
<li><p><strong>A package check</strong> that builds the exact npm tarball and installs it into a Vite app with a strict Content Security Policy and into a Next.js app, both outside the monorepo, then submits a report in each.</p>
</li>
<li><p><strong>A live journey test</strong> with 20 checks against a real GitHub App and a throwaway repository: submit a report, triage it, preview it, create the issue, check that no private data was published, close the issue on GitHub and wait for the webhook, reopen it, and check the timeline.</p>
</li>
</ul>
<p>I ran that live journey three times: first against my local machine through a tunnel, then against production, and finally against a completely fresh copy that I deployed by following only the setup guide. All three passed 20 out of 20.</p>
<p>More interesting than the passes, though, are the problems each stage uncovered:</p>
<ul>
<li><p><strong>The Issues event is easy to forget:</strong> The first time I created a GitHub App by hand, it had no event subscriptions, so GitHub never told IssueRelay when issues closed. That mistake is why the <code>create-app</code> command exists.</p>
</li>
<li><p><strong>Visitors mention their own names:</strong> A report like "Sarah here, the page is broken" from a visitor named Sarah would have put her name in a public issue. The privacy gate now compares every report against the contact details that came with it.</p>
</li>
<li><p><strong>Zod and strict CSP don't mix in the browser:</strong> Zod 4 briefly tests whether it can use <code>new Function</code>, and sites with a strict Content Security Policy report that as a violation. I removed Zod from the widget and wrote small validation checks instead, with a test that proves they agree with the server's Zod schemas on 270 form combinations.</p>
</li>
<li><p><strong>Vercel's clone flow has no Root Directory option, and Vercel picks the framework only once:</strong> My fresh deploy failed twice: once because Vercel built the repository root, and once because the framework was still set to "Other." The repository now pins Next.js in <code>vercel.json</code>, and the guide warns about the first failure.</p>
</li>
<li><p><strong>Deploy button copies aren't forks:</strong> A plain <code>git pull</code> from the main repository refuses to merge, so the guide now has a one time command to connect a copy to the main repository.</p>
</li>
<li><p><strong>GitHub issues need Jev:</strong> I originally listed Jev as optional. A careful review of the guide showed that without it, no real report can reach the confidence threshold. The guide and the settings page now say so clearly.</p>
</li>
<li><p><strong>Log noise matters:</strong> Every database connection logged an SSL warning at error level, which made a healthy deployment look broken. The fix was to spell out the SSL mode the driver was already using, so the warning disappeared while the certificate checks stayed exactly the same.</p>
</li>
</ul>
<p>The lesson that stuck with me most: <strong>deploying from your own documentation, word for word, finds bugs that no test will.</strong> Every one of the deployment problems above was invisible to the automated tests and obvious the moment a real person followed the guide.</p>
<h2 id="heading-how-it-was-built-phases-and-ai-assisted-development">How It Was Built: Phases and AI Assisted Development</h2>
<p>IssueRelay was built in small phases, and each phase ended with a written handoff before the next one could start:</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>Outcome</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>Product definition, architecture, decisions, security, and test plans</td>
</tr>
<tr>
<td>1 and 2</td>
<td>Monorepo foundation, domain model, PostgreSQL schema, and seed data</td>
</tr>
<tr>
<td>3 and 4</td>
<td>The widget, a demo site, and the public ticket API</td>
</tr>
<tr>
<td>5 and 6</td>
<td>AI triage with Jev and the operator dashboard</td>
</tr>
<tr>
<td>7 and 8</td>
<td>Confirmed GitHub escalation and signed webhook sync</td>
</tr>
<tr>
<td>9</td>
<td>Live validation of the full journey in a throwaway repository</td>
</tr>
<tr>
<td>10</td>
<td>Production hardening</td>
</tr>
<tr>
<td>11 and 12</td>
<td>Validating and publishing the widget to npm</td>
</tr>
<tr>
<td>Deploy</td>
<td>Vercel, Neon, and Resend in production</td>
</tr>
<tr>
<td>13</td>
<td>Installing the widget on my portfolio</td>
</tr>
<tr>
<td>14 and 15</td>
<td>Dogfooding (ongoing)</td>
</tr>
<tr>
<td>16</td>
<td>Self hosting: the Deploy button, the setup page, project settings, and the App manifest command</td>
</tr>
</tbody></table>
<h3 id="heading-my-developer-setup">My Developer Setup</h3>
<p>I did most of the work in the terminal. My setup is:</p>
<ul>
<li><p>Ghostty as my terminal, running Claude Code, Codex, and OpenCode</p>
</li>
<li><p>Cursor as my editor</p>
</li>
<li><p>The native desktop apps for ChatGPT, Claude, and OpenCode</p>
</li>
</ul>
<p>My main model for building IssueRelay was <strong>Claude Opus 5.5</strong> in Claude Code. For code reviews and for checking a phase before I signed it off, I used other models, including <strong>GPT-6 Sol</strong> and Grok, along with various other frontier and free models.</p>
<p>A second model reading the same code with fresh eyes caught real problems. For example, a Grok review of Phases 7 and 8 raised 15 findings. Seven were valid, including a race in claiming issue creation and issue markers that could be guessed, and all seven were fixed before I moved on. Three more were partly valid and five were deferred with written reasons.</p>
<p>Anthropic's newly released <strong>Sonnet 5.5</strong> and OpenAI's <strong>GPT-6.1 Sol</strong> weren't used in this project.</p>
<h3 id="heading-how-better-prompts-improved-the-codebase">How Better Prompts Improved the Codebase</h3>
<p>The biggest improvement in quality didn't come from a smarter model. It came from giving the model better instructions and a better structure to work in. Here is what worked:</p>
<ul>
<li><p><strong>One phase at a time:</strong> Each prompt asked for exactly one phase with a clear outcome, and the AI wasn't allowed to start the next phase until I approved it. Small, reviewable changes were much easier to check than one giant feature.</p>
</li>
<li><p><strong>A plan before any code:</strong> For bigger phases I asked for a plan first ("Create a plan and then go ahead with it once I approve it"). Reading a plan takes two minutes. Unpicking a wrong implementation takes an afternoon.</p>
</li>
<li><p><strong>Rules that live in the repository:</strong> An <code>AGENTS.md</code> file holds the project's rules, such as "persist an accepted ticket before external AI or GitHub calls," "never publish contact data to GitHub," and "do not blindly retry an ambiguous GitHub issue creation." Every AI session reads it, so the rules don't depend on me remembering to repeat them.</p>
</li>
<li><p><strong>Honest reporting:</strong> The instructions say never to report an unrun check as passing, and every handoff records the commands that were run and their real results, including failures.</p>
</li>
<li><p><strong>Clear conditions for committing:</strong> Prompts like "commit and push when tests pass and there are no other issues" meant the full test suite ran before anything reached the main branch.</p>
</li>
<li><p><strong>Asking for proof, not promises:</strong> Instead of asking "does self hosting work?", I asked the AI to verify it by following the guide on a fresh deployment. That single request uncovered seven documentation and configuration problems.</p>
</li>
<li><p><strong>Feeding back real use:</strong> When I deployed a test site myself and wrote down everything that confused me, those notes went straight back into the guide, the setup page, and the settings page.</p>
</li>
</ul>
<h2 id="heading-publishing-the-widget-to-npm">Publishing the Widget to npm</h2>
<p>The widget is the only part of IssueRelay that is published, as <a href="https://www.npmjs.com/package/@issuerelay/widget"><code>@issuerelay/widget</code></a>. Everything else stays private inside the monorepo.</p>
<p>I didn't want to publish something that only worked inside my own workspace, so the release check builds the exact tarball that npm will receive and inspects it.</p>
<p>It must contain only five files. It must not reference private packages, Node built ins, environment variables, or anything that looks like a key. It must then install and work in two brand new apps outside the repository, one of them under a strict Content Security Policy, with zero policy violations.</p>
<p>Releases are published from GitHub Actions with npm trusted publishing and provenance, so there is no long lived npm token to leak.</p>
<p>The result is a package of about 10 KB compressed that needs no CSS setup and depends only on React and React Hook Form. The <a href="https://github.com/andrewbaisden/issuerelay/tree/main/packages/widget#readme">widget README</a> documents every prop.</p>
<h2 id="heading-what-is-next">What Is Next</h2>
<p>IssueRelay is complete for self hosting, and I'm using it every day on my portfolio. Some things I would like to explore next:</p>
<ul>
<li><p><strong>A hosted version</strong> of IssueRelay, so you could sign up and add the widget without deploying anything yourself</p>
</li>
<li><p><strong>Testing the Fork and Import path</strong> end to end so it can become the recommended way to deploy</p>
</li>
<li><p>Ideas from the roadmap, such as detecting duplicate reports, linking several reports to one issue, notifications, and syncing GitHub comments</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you saw how to build an AI support system that turns scattered website feedback into reviewed tickets and routes confirmed bugs to GitHub. Along the way, you learned how to:</p>
<ul>
<li><p>Build an embeddable React widget that works on any site without CSS setup or style clashes</p>
</li>
<li><p>Save every report before calling any external service, so provider outages never lose data</p>
</li>
<li><p>Use Jev for bounded, validated classification that returns labels and probabilities instead of free text</p>
</li>
<li><p>Keep routing and publishing decisions in plain, testable code, with a human in the loop</p>
</li>
<li><p>Create GitHub issues safely with a GitHub App, a privacy gate, and a marker that prevents duplicates</p>
</li>
<li><p>Keep GitHub and your dashboard in sync with signed, verified webhooks</p>
</li>
<li><p>Deploy your own copy on Vercel and Neon, and test it end to end, including against your own documentation</p>
</li>
</ul>
<p>The best way to understand IssueRelay is to try it. You can <a href="https://github.com/andrewbaisden/issuerelay">explore the code on GitHub</a>, deploy your own copy with the <a href="https://github.com/andrewbaisden/issuerelay/blob/main/docs/SELF_HOSTING.md">self hosting guide</a>, and add the widget to your site with <code>npm install @issuerelay/widget</code> from <a href="https://www.npmjs.com/package/@issuerelay/widget">npm</a>. If it helps you, a star on the repository is always appreciated.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Add shadcn Charts to a Next.js App Without Writing Recharts Boilerplate ]]>
                </title>
                <description>
                    <![CDATA[ Charts look like a small task on a ticket. Then you open the Recharts docs and remember how much setup every chart needs: a config object for labels and colors, axes, a tooltip, a legend, colors that  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-add-shadcn-ui-charts-to-nextjs/</link>
                <guid isPermaLink="false">6abf4b9d63317980cdaece08</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ charts ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ data visualization tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Fri, 02 Oct 2026 06:13:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a6e5c1d7-5d61-4cc0-94d8-3a3e068d97e4.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Charts look like a small task on a ticket. Then you open the Recharts docs and remember how much setup every chart needs: a config object for labels and colors, axes, a tooltip, a legend, colors that work in dark mode, and a container that resizes properly. Most of us copy all of that from the last project and rename the keys.</p>
<p>The shadcn/ui chart component removes part of this work. It wraps Recharts with theme-aware colors and tooltips. But you still write every chart by hand.</p>
<p>So I built ChartCN, a free and open-source <a href="https://www.shadcndeck.com/chartcn">shadcn chart generator</a>. You paste your data, pick a chart, and copy one TSX file that depends only on the shadcn/ui chart component and Recharts. It's MIT licensed, and there's no account or extra package.</p>
<p>In this tutorial, you'll learn how to add a revenue-versus-expenses bar chart to a fresh Next.js app. We'll walk through what each part of the generated code does, and then connect the chart to data loaded in a Server Component. The tool saves you the typing, but Steps 5 and 6 apply to any shadcn/ui chart, whether you generate it or write it yourself.</p>
<p><strong>Prerequisites:</strong> You should be comfortable with React and have Node.js installed. Basic familiarity with the Next.js App Router helps, but every step is explained.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-step-1-create-a-nextjs-app-and-initialize-shadcnui">Step 1: Create a Next.js App and Initialize shadcn/ui</a></p>
</li>
<li><p><a href="#heading-step-2-add-the-shadcnui-chart-component">Step 2: Add the shadcn/ui Chart Component</a></p>
</li>
<li><p><a href="#heading-step-3-generate-the-chart-component-in-chartcn">Step 3: Generate the Chart Component in ChartCN</a></p>
</li>
<li><p><a href="#heading-step-4-add-the-chart-to-your-page">Step 4: Add the Chart to Your Page</a></p>
</li>
<li><p><a href="#heading-step-5-understand-the-generated-code">Step 5: Understand the Generated Code</a></p>
</li>
<li><p><a href="#heading-step-6-pass-live-data-as-a-prop">Step 6: Pass Live Data as a Prop</a></p>
</li>
<li><p><a href="#heading-common-issues-and-how-to-fix-them">Common Issues and How to Fix Them</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end, you'll have two pages:</p>
<ul>
<li><p><code>/</code> shows a grouped bar chart with the data written directly in the component. This is the fastest way to get a chart on screen.</p>
</li>
<li><p><code>/live</code> shows the same chart, but the data comes from an async function running on the server. This is the version you'd use with a real database or API.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6a4ea012258c41204fefcf8b/546e7105-3d43-4bd0-a064-a877fffe930a.png" alt="Shadcn Charts - Finished Bar Chart" style="display: block;" width="600" height="400" loading="lazy">

<p>The chart has gradient bars, compact axis labels (40K instead of 40000), a legend, and a tooltip that shows the month's total and each series' share of it.</p>
<h2 id="heading-step-1-create-a-nextjs-app-and-initialize-shadcnui">Step 1: Create a Next.js App and Initialize shadcn/ui</h2>
<p>Create a new app and move into it:</p>
<pre><code class="language-bash">npx create-next-app@latest my-charts-app
cd my-charts-app
</code></pre>
<p>Accept the recommended defaults. You need TypeScript, Tailwind CSS, and the App Router.</p>
<p>Next, initialize shadcn/ui:</p>
<pre><code class="language-bash">npx shadcn@latest init
</code></pre>
<p>This command does three things you'll rely on later:</p>
<ol>
<li><p>It creates <code>components.json</code>, which tells the shadcn/ui CLI where to put components.</p>
</li>
<li><p>It adds <code>lib/utils.ts</code> with the <code>cn()</code> class-merging helper.</p>
</li>
<li><p>It writes theme variables into <code>app/globals.css</code>, including five chart colors: <code>--chart-1</code> through <code>--chart-5</code>, with separate values for light and dark mode.</p>
</li>
</ol>
<p>Those five chart variables matter. Every generated chart uses them, so changing a chart color across your whole app means editing one line of CSS.</p>
<h2 id="heading-step-2-add-the-shadcnui-chart-component">Step 2: Add the shadcn/ui Chart Component</h2>
<p>Now add the chart component:</p>
<pre><code class="language-bash">npx shadcn@latest add chart
</code></pre>
<p>This installs <code>recharts</code> and creates <code>components/ui/chart.tsx</code>. That file exports the building blocks you'll see in the generated code:</p>
<ul>
<li><p><code>ChartContainer</code> wraps Recharts' <code>ResponsiveContainer</code>, so the chart fills its parent.</p>
</li>
<li><p><code>ChartConfig</code> is the type for the object that maps each data key to a label and a color.</p>
</li>
<li><p><code>ChartTooltip</code>, <code>ChartTooltipContent</code>, <code>ChartLegend</code>, and <code>ChartLegendContent</code> are styled versions of the Recharts tooltip and legend.</p>
</li>
</ul>
<p>Before moving on, check which Recharts version was installed:</p>
<pre><code class="language-bash">npm ls recharts
</code></pre>
<p>The code ChartCN generates targets <strong>Recharts 3</strong>. If you see a 2.x version, upgrade it:</p>
<pre><code class="language-bash">npm install recharts@latest
</code></pre>
<p><strong>Takeaway:</strong> the shadcn/ui chart component is a thin layer over Recharts. It doesn't replace Recharts. It gives Recharts your theme.</p>
<h2 id="heading-step-3-generate-the-chart-component-in-chartcn">Step 3: Generate the Chart Component in ChartCN</h2>
<p>Open the <a href="https://www.shadcndeck.com/chartcn/charts/bar">ChartCN bar chart page</a>. The data panel has three tabs: <strong>Paste data</strong>, <strong>Upload file</strong>, and <strong>Edit table</strong>. Paste this CSV into the <strong>Paste data</strong> tab:</p>
<pre><code class="language-plaintext">Month,Revenue,Expenses
Jan,42000,31000
Feb,58000,34000
Mar,51000,29000
Apr,67000,38000
May,72000,41000
Jun,69000,37000
</code></pre>
<p>ChartCN treats the first column as the category axis (the x-axis for a bar chart) and every other column as a numeric series. JSON, TSV, and Markdown tables also work, and the format is detected automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a4ea012258c41204fefcf8b/997a85da-f44d-4e88-8ffe-120ea66b8884.png" alt="Shadcn Charts - Bar Chart" style="display: block;" width="600" height="400" loading="lazy">

<p>Above the preview, set these options:</p>
<ul>
<li><p><strong>Layout:</strong> Grouped</p>
</li>
<li><p><strong>Tooltip:</strong> Breakdown</p>
</li>
<li><p><strong>Code:</strong> Inline data</p>
</li>
</ul>
<p>Then click <strong>Copy component</strong> in the <code>chart.tsx</code> panel below the preview.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6a4ea012258c41204fefcf8b/3317c6c9-e4f4-4b59-ad19-2076728048cd.png" alt="Shadcn Charts - ChartCn Copy component" style="display: block;" width="600" height="400" loading="lazy">

<h2 id="heading-step-4-add-the-chart-to-your-page">Step 4: Add the Chart to Your Page</h2>
<p>Create a new file at <code>components/revenue-chart.tsx</code> and paste the copied code into it.</p>
<p>Don't save it as <code>components/ui/chart.tsx</code>. ChartCN labels its output <code>chart.tsx</code>, but that path already holds the <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn component</a> from Step 2, and overwriting it breaks every chart in your app.</p>
<p>The generated component is always exported as <code>Chart</code>, so give it a clearer name when you import it. Replace <code>app/page.tsx</code> with this:</p>
<pre><code class="language-tsx">// app/page.tsx
import { Chart as RevenueChart } from "@/components/revenue-chart"

export default function Home() {
  return (
    &lt;main className="mx-auto max-w-3xl p-8"&gt;
      &lt;h1 className="mb-6 text-2xl font-semibold"&gt;Revenue vs. expenses&lt;/h1&gt;
      &lt;RevenueChart /&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>Run <code>npm run dev</code> and open <code>http://localhost:3000</code>. You should see the chart.</p>
<p><strong>Takeaway:</strong> a generated chart is just a component in your project. There's nothing to configure and no package to keep in sync.</p>
<h2 id="heading-step-5-understand-the-generated-code">Step 5: Understand the Generated Code</h2>
<p>You own this file now, so it's worth knowing what each part does. Here are the important pieces, trimmed for length.</p>
<p>The file starts with a directive and its imports:</p>
<pre><code class="language-tsx">// components/revenue-chart.tsx
"use client"

import { useId } from "react"
import { Bar, BarChart, CartesianGrid, Rectangle, XAxis, YAxis } from "recharts"
import {
  type ChartConfig,
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
} from "@/components/ui/chart"
</code></pre>
<p>Recharts measures the DOM and handles mouse events, so the chart must be a Client Component. <code>app/page.tsx</code> can stay a Server Component because it only renders the chart.</p>
<p>Next come the data and the config:</p>
<pre><code class="language-tsx">const data = [
  { Month: "Jan", Revenue: 42000, Expenses: 31000 },
  { Month: "Feb", Revenue: 58000, Expenses: 34000 },
  // ...
]

const chartConfig = {
  Revenue: { label: "Revenue", color: "var(--chart-1)" },
  Expenses: { label: "Expenses", color: "var(--chart-2)" },
} satisfies ChartConfig
</code></pre>
<p>The keys in <code>chartConfig</code> must match the keys in your data. <code>ChartContainer</code> reads this config and creates a CSS variable for each key, scoped to this chart: <code>--color-Revenue</code> and <code>--color-Expenses</code>. Each one points at a theme color, so the chart switches colors in dark mode on its own.</p>
<p>The chart itself uses those variables:</p>
<pre><code class="language-tsx">&lt;ChartContainer config={chartConfig} className="aspect-auto h-[350px] w-full"&gt;
  &lt;BarChart accessibilityLayer data={data} barCategoryGap="30%" barGap={4}&gt;
    {/* ...gradient &lt;defs&gt;, grid, axes, tooltip, legend... */}
    &lt;Bar
      dataKey="Revenue"
      fill="var(--color-Revenue)"
      shape={(props) =&gt; &lt;Rectangle {...props} fill={`url(#${uid}-fill-0)`} /&gt;}
      radius={[6, 6, 0, 0]}
      maxBarSize={36}
    /&gt;
  &lt;/BarChart&gt;
&lt;/ChartContainer&gt;
</code></pre>
<p>A few details are worth knowing:</p>
<ul>
<li><p><strong>Height</strong> is set by <code>h-[350px]</code> on <code>ChartContainer</code>. Change it there.</p>
</li>
<li><p><strong>The gradient</strong> is drawn by the <code>shape</code> prop, while <code>fill</code> stays a solid color. That's why the legend and tooltip dots show a clean color instead of a gradient.</p>
</li>
<li><p><code>uid</code> comes from <code>useId()</code>, so the gradient IDs stay unique if you render two charts on the same page.</p>
</li>
</ul>
<p>The file also includes a <code>ChartBreakdownTooltip</code> component, about 70 lines, that shows the total and each series' percentage. If you'd rather use the standard <a href="https://www.shadcndeck.com/blog/shadcn-tooltip-component">shadcn tooltip</a>, pick <strong>Tooltip: Simple</strong> in ChartCN before copying. The whole file then drops from about 120 lines to about 50.</p>
<p><strong>Takeaway:</strong> in shadcn/ui charts, colors flow from theme variables through <code>chartConfig</code> into <code>--color-&lt;key&gt;</code> variables. Once you understand that path, you can edit any chart by hand.</p>
<h2 id="heading-step-6-pass-live-data-as-a-prop">Step 6: Pass Live Data as a Prop</h2>
<p>Hardcoded data is fine for a demo. For real data, go back to ChartCN, switch <strong>Code</strong> to <strong>Data as prop</strong>, and copy again. Save this version as <code>components/revenue-chart-live.tsx</code>.</p>
<p>The chart markup is identical. Only the top of the file changes: the <code>data</code> array is replaced by a type, and the component accepts <code>data</code> as a prop:</p>
<pre><code class="language-tsx">// components/revenue-chart-live.tsx
export type ChartRow = { Month: string; Revenue: number | null; Expenses: number | null }

export interface ChartProps {
  data: ChartRow[]
}

// ...chartConfig is unchanged...

export function Chart({ data }: ChartProps) {
  // ...same JSX as before
}
</code></pre>
<p>The series are typed as <code>number | null</code> because real data has gaps. A <code>null</code> value renders as a missing bar, not as a bar of zero.</p>
<p>Now create a function that loads the data on the server:</p>
<pre><code class="language-ts">// lib/get-revenue.ts
import type { ChartRow } from "@/components/revenue-chart-live"

export async function getRevenue(): Promise&lt;ChartRow[]&gt; {
  // Replace this with your database query or API call.
  // For example: const res = await fetch("https://api.example.com/revenue")
  return [
    { Month: "Jan", Revenue: 42000, Expenses: 31000 },
    { Month: "Feb", Revenue: 58000, Expenses: 34000 },
    { Month: "Mar", Revenue: 51000, Expenses: 29000 },
    { Month: "Apr", Revenue: 67000, Expenses: 38000 },
    { Month: "May", Revenue: 72000, Expenses: null },
  ]
}
</code></pre>
<p>Then call it from a Server Component page:</p>
<pre><code class="language-tsx">// app/live/page.tsx
import { Chart as RevenueChart } from "@/components/revenue-chart-live"
import { getRevenue } from "@/lib/get-revenue"

export default async function LivePage() {
  const data = await getRevenue()

  return (
    &lt;main className="mx-auto max-w-3xl p-8"&gt;
      &lt;h1 className="mb-6 text-2xl font-semibold"&gt;Revenue vs. expenses&lt;/h1&gt;
      &lt;RevenueChart data={data} /&gt;
    &lt;/main&gt;
  )
}
</code></pre>
<p>Open <code>http://localhost:3000/live</code>. May shows a Revenue bar with no Expenses bar next to it, because that value is <code>null</code>.</p>
<p>The page fetches on the server and sends only the rows to the client component. Your database credentials and API keys stay on the server. When you swap in a real <code>fetch</code> or database call, check the <a href="https://nextjs.org/docs/app/getting-started/fetching-data">Next.js data fetching docs</a> to control how often the data refreshes.</p>
<p><strong>Takeaway:</strong> keep data loading in a Server Component and rendering in a Client Component. The exported <code>ChartRow</code> type makes TypeScript check that your data matches what the chart expects.</p>
<h2 id="heading-common-issues-and-how-to-fix-them">Common Issues and How to Fix Them</h2>
<p><strong>Type errors such as "Property 'itemSorter' does not exist".</strong> Your project has Recharts 2 installed. Run <code>npm install recharts@latest</code> to upgrade to Recharts 3.</p>
<p><strong>The bars are black and the legend dots are missing.</strong> Your <code>app/globals.css</code> doesn't define <code>--chart-1</code> through <code>--chart-5</code>. Run <code>npx shadcn@latest init</code> again, or copy the chart variables from the shadcn/ui theming docs.</p>
<p><strong>Every chart stopped working after you pasted one.</strong> You probably saved the generated file over <code>components/ui/chart.tsx</code>. Restore it with <code>npx shadcn@latest add chart --overwrite</code> and save your chart under a different name.</p>
<p><strong>Two charts on one page have the same name.</strong> Every generated component is exported as <code>Chart</code>. Rename it when you import it, for example <code>import { Chart as SignupsChart } from "@/components/signups-chart"</code>.</p>
<h2 id="heading-summary">Summary</h2>
<ol>
<li><p><strong>The shadcn/ui chart component themes Recharts.</strong> It doesn't replace it, and ChartCN's output requires Recharts 3.</p>
</li>
<li><p><strong>Colors flow from CSS variables.</strong> <code>--chart-1</code> becomes <code>--color-Revenue</code> through <code>chartConfig</code>, so dark mode works without extra code.</p>
</li>
<li><p><strong>Save generated charts under their own names.</strong> <code>components/ui/chart.tsx</code> belongs to shadcn/ui.</p>
</li>
<li><p><strong>Use Data as prop for real data.</strong> Load it in a Server Component and pass the typed rows to the chart.</p>
</li>
<li><p><code>null</code> <strong>means "no data".</strong> Gaps render as gaps, not as zeros.</p>
</li>
</ol>
<p>Because ChartCN is a <a href="https://www.shadcndeck.com/chartcn">shadcn chart generator</a> rather than a component library, there is no ChartCN package to install or keep updated. You can browse all 13 <a href="https://www.shadcndeck.com/chartcn/charts">shadcn charts</a> it supports, including line, area, pie, radar, KPI cards, waterfall, and a heatmap. They all follow the same paste, pick, and copy workflow you used here.</p>
<p>The source is on <a href="https://github.com/ShadcnDeck/chartcn-shadcn-chart-generator">GitHub</a> under the MIT license. If it's useful, a star helps other developers find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Flashcard Study App with Next.js and MongoDB ]]>
                </title>
                <description>
                    <![CDATA[ If you've ever crammed for an exam the night before, you know how hard it is to remember everything. Flashcards are one of the most effective study tools because they use active recall: you actively t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-flashcard-study-app-with-next-js-and-mongodb/</link>
                <guid isPermaLink="false">6a8f26c8d7f59479db2c21fd</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MongoDB ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Wed, 26 Aug 2026 17:47:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ed3602f9-f68c-4917-bc61-338d3ffba6e7.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've ever crammed for an exam the night before, you know how hard it is to remember everything.</p>
<p>Flashcards are one of the most effective study tools because they use <strong>active recall</strong>: you actively try to remember the answer instead of passively reading notes. Research shows this strengthens memory and helps information stick.</p>
<p>In this tutorial, you'll build a full-stack flashcard app that lets students:</p>
<ul>
<li><p><strong>Create subjects</strong> (like "Biology 101" or "Calculus")</p>
</li>
<li><p><strong>Add flashcards</strong> with a question on the front and answer on the back</p>
</li>
<li><p><strong>Study</strong> by flipping cards and marking them correct or wrong</p>
</li>
<li><p><strong>Track progress</strong> to see how well they're doing</p>
</li>
</ul>
<p>By the end, you'll have a working app that stores data in MongoDB and runs on Next.js. No prior experience with these tools is required. We'll explain everything as we go.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-tech-stack-overview">Tech Stack Overview</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
<ul>
<li><p><a href="#heading-step-1-create-the-nextjs-project">Step 1: Create the Next.js Project</a></p>
</li>
<li><p><a href="#heading-step-2-install-mongoose">Step 2: Install Mongoose</a></p>
</li>
<li><p><a href="#heading-step-3-set-up-mongodb">Step 3: Set Up MongoDB</a></p>
</li>
<li><p><a href="#heading-step-4-create-the-environment-file">Step 4: Create the Environment File</a></p>
</li>
<li><p><a href="#heading-step-5-understand-the-folder-structure">Step 5: Understand the Folder Structure</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-the-features">Building the Features</a></p>
<ul>
<li><p><a href="#heading-part-1-connecting-to-mongodb">Part 1: Connecting to MongoDB</a></p>
</li>
<li><p><a href="#heading-part-2-defining-the-data-models">Part 2: Defining the Data Models</a></p>
<ul>
<li><p><a href="#heading-subject-model">Subject Model</a></p>
</li>
<li><p><a href="#heading-flashcard-model">Flashcard Model</a></p>
</li>
<li><p><a href="#heading-progress-model">Progress Model</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-3-creating-subjects-and-flashcards-api-routes">Part 3: Creating Subjects and Flashcards (API Routes)</a></p>
<ul>
<li><p><a href="#heading-subjects-api-list-and-create">Subjects API – List and Create</a></p>
</li>
<li><p><a href="#heading-flashcards-api-list-and-create">Flashcards API – List and Create</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-editing-and-deleting-dynamic-api-routes">Part 4: Editing and Deleting (Dynamic API Routes)</a></p>
<ul>
<li><p><a href="#heading-subject-by-id-get-update-delete">Subject by ID – Get, Update, Delete</a></p>
</li>
<li><p><a href="#heading-flashcard-by-id-get-update-delete">Flashcard by ID – Get, Update, Delete</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-5-progress-tracking-api">Part 5: Progress Tracking API</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-ui-implementation">UI Implementation</a></p>
<ul>
<li><p><a href="#heading-the-subjects-page">The Subjects Page</a></p>
</li>
<li><p><a href="#heading-the-subject-detail-page-creating-and-editing-flashcards">The Subject Detail Page (Creating and Editing Flashcards)</a></p>
</li>
<li><p><a href="#heading-the-study-page-flipping-cards">The Study Page – Flipping Cards</a></p>
<ul>
<li><p><a href="#heading-the-flip-animation">The Flip Animation</a></p>
</li>
<li><p><a href="#heading-recording-progress">Recording Progress</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-progress-page">The Progress Page</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-best-practices">Best Practices</a></p>
<ul>
<li><p><a href="#heading-code-organization">Code Organization</a></p>
</li>
<li><p><a href="#heading-error-handling">Error Handling</a></p>
</li>
<li><p><a href="#performance-tips">Performance Tips</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>How to set up a Next.js project with TypeScript</p>
</li>
<li><p>How to connect to MongoDB and store data</p>
</li>
<li><p>How to build API routes for creating, reading, updating, and deleting data</p>
</li>
<li><p>How to build a React UI with forms, lists, and interactive flashcards</p>
</li>
<li><p>How to add a flip animation and progress tracking</p>
</li>
</ul>
<h2 id="heading-tech-stack-overview">Tech Stack Overview</h2>
<p>Before we start coding, here's what we're using and why.</p>
<h3 id="heading-nextjs">Next.js</h3>
<p>Next.js is a React framework for building web applications. It handles routing, server-side rendering, and API routes out of the box.</p>
<p>Instead of building a separate frontend and backend, Next.js lets us put both in one project. We can create API routes (like <code>/api/flashcards</code>) that talk to the database, and pages that display the UI, all in the same codebase.</p>
<h3 id="heading-mongodb">MongoDB</h3>
<p>MongoDB is a NoSQL database that stores data as JSON-like documents. Unlike traditional tables with rows and columns, you store flexible "documents" in "collections."</p>
<p>MongoDB is beginner-friendly, works well with JavaScript/TypeScript, and has a generous free tier (MongoDB Atlas) or can run locally with Docker.</p>
<h3 id="heading-mongoose">Mongoose</h3>
<p>Mongoose is a library that lets you define schemas and models for MongoDB. It adds structure and validation so you don't accidentally save invalid data.</p>
<p>Without Mongoose, you'd write raw MongoDB queries. With Mongoose, you define a "Flashcard" model once and use simple methods like <code>Flashcard.create()</code> or <code>Flashcard.find()</code>.</p>
<h3 id="heading-tailwind-css">Tailwind CSS</h3>
<p>Tailwind is a utility-first CSS framework. Instead of writing custom CSS, you add classes like <code>rounded-xl</code> or <code>bg-blue-500</code> directly in your HTML.</p>
<p>Tailwind speeds up styling and keeps the design consistent. Next.js supports it out of the box.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<h3 id="heading-step-1-create-the-nextjs-project">Step 1: Create the Next.js Project</h3>
<p>Open your terminal and run:</p>
<pre><code class="language-bash">npx create-next-app@latest flash-cards --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-npm
</code></pre>
<p>When prompted, choose:</p>
<ul>
<li><p>TypeScript: <strong>Yes</strong></p>
</li>
<li><p>ESLint: <strong>Yes</strong></p>
</li>
<li><p>Tailwind CSS: <strong>Yes</strong></p>
</li>
<li><p><code>src/</code> directory: <strong>Yes</strong></p>
</li>
<li><p>App Router: <strong>Yes</strong></p>
</li>
<li><p>Import alias: <strong>@/</strong>*</p>
</li>
</ul>
<p>This creates a new folder called <code>flash-cards</code> with a basic Next.js app.</p>
<h3 id="heading-step-2-install-mongoose">Step 2: Install Mongoose</h3>
<p>Mongoose is not included by default. Run the commands below to Install it.</p>
<pre><code class="language-bash">cd flash-cards
npm install mongoose
</code></pre>
<h3 id="heading-step-3-set-up-mongodb">Step 3: Set Up MongoDB</h3>
<p>You have two options:</p>
<h4 id="heading-option-a-docker-recommended-for-local-development">Option A: Docker (recommended for local development)</h4>
<p>Create a file called <code>docker-compose.yml</code> in your project root:</p>
<pre><code class="language-yaml">services:
  mongodb:
    image: mongo:7
    container_name: flashstudy-mongodb
    ports:
      - "27017:27017"
    volumes:
      - mongodb_data:/data/db

volumes:
  mongodb_data:
</code></pre>
<p>Then run:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p>This starts MongoDB in the background. Your data is stored in a Docker volume, so it persists even if you stop the container.</p>
<h4 id="heading-option-b-mongodb-atlas-cloud">Option B: MongoDB Atlas (cloud)</h4>
<ol>
<li><p>Go to <a href="https://www.mongodb.com/cloud/atlas">mongodb.com/cloud/atlas</a></p>
</li>
<li><p>Create a free account and cluster</p>
</li>
<li><p>Create a database user and get your connection string</p>
</li>
<li><p>Add your IP to the network access list</p>
</li>
</ol>
<h3 id="heading-step-4-create-the-environment-file">Step 4: Create the Environment File</h3>
<p>Create a file named <code>.env.local</code> in your project root (this file is ignored by Git for security):</p>
<pre><code class="language-env">MONGODB_URI=mongodb://localhost:27017/flashcards
</code></pre>
<p>If you're using Atlas, replace this with your connection string, for example:</p>
<pre><code class="language-env">MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/flashcards?retryWrites=true&amp;w=majority
</code></pre>
<h3 id="heading-step-5-understand-the-folder-structure">Step 5: Understand the Folder Structure</h3>
<p>After setup, your project looks like this:</p>
<pre><code class="language-plaintext">flash-cards/
├── src/
│   ├── app/              # Pages and API routes
│   │   ├── api/          # Backend API endpoints
│   │   ├── subjects/     # Subject list and detail pages
│   │   ├── study/        # Study mode page
│   │   └── progress/     # Progress tracking page
│   ├── components/       # Reusable UI components
│   └── lib/              # Utilities and database code
│       ├── db.ts         # MongoDB connection
│       └── models/       # Mongoose schemas
├── .env.local            # Environment variables (you create this)
├── docker-compose.yml    # Docker config for MongoDB
└── package.json
</code></pre>
<p>The <code>app</code> folder uses Next.js App Router: each folder can have a <code>page.tsx</code> (the UI) and <code>route.ts</code> (API endpoints). We'll build these step by step.</p>
<h2 id="heading-building-the-features">Building the Features</h2>
<h3 id="heading-part-1-connecting-to-mongodb">Part 1: Connecting to MongoDB</h3>
<p>Before we can store or retrieve flashcards, we need to connect our application to MongoDB.</p>
<p>We'll create a small database utility that handles this connection for us. Because Next.js can handle multiple requests and reload modules during development, we don't want to create a new MongoDB connection every time an API route runs. Instead, we'll cache the connection and reuse it whenever possible.</p>
<p>Let's start by creating a <code>db.ts</code> file inside the <code>src/lib</code> directory.</p>
<pre><code class="language-typescript">import mongoose from "mongoose";

const MONGODB_URI = process.env.MONGODB_URI || "mongodb://localhost:27017/flashcards";

interface MongooseCache {
  conn: typeof mongoose | null;
  promise: Promise&lt;typeof mongoose&gt; | null;
}

declare global {
  var mongoose: MongooseCache | undefined;
}

let cached: MongooseCache = global.mongoose || { conn: null, promise: null };

if (!global.mongoose) {
  global.mongoose = cached;
}

async function dbConnect(): Promise&lt;typeof mongoose&gt; {
  if (cached.conn) return cached.conn;

  if (!cached.promise) {
    cached.promise = mongoose.connect(MONGODB_URI, {
      bufferCommands: false,
    });
  }

  cached.conn = await cached.promise;
  return cached.conn;
}

export default dbConnect;
</code></pre>
<p>Here's a line-by-line explanation of this code:</p>
<ul>
<li><p><code>MONGODB_URI</code>: Reads the connection string from <code>.env.local</code>. Falls back to local MongoDB if not set.</p>
</li>
<li><p><code>MongooseCache</code>: A TypeScript interface describing our cache: we store either a connection (<code>conn</code>) or a promise that will eventually give us one.</p>
</li>
<li><p><code>global.mongoose</code>: In development, Next.js may reload modules. Using <code>global</code> keeps our cache across reloads so we don't create duplicate connections.</p>
</li>
<li><p><code>dbConnect()</code>: If we already have a connection, return it. Otherwise, create one, cache it, and return it. Every API route will call <code>await dbConnect()</code> before touching the database.</p>
</li>
</ul>
<h3 id="heading-part-2-defining-the-data-models">Part 2: Defining the Data Models</h3>
<p>Now that our application can connect to MongoDB, let's define the data we'll store in the database.</p>
<p>Our flashcard app needs three types of data:</p>
<ul>
<li><p><strong>Subjects</strong>: Categories such as Biology 101 or Calculus.</p>
</li>
<li><p><strong>Flashcards</strong>: Questions and answers that belong to a subject.</p>
</li>
<li><p><strong>Progress</strong>: Records of how well the user performs when studying.</p>
</li>
</ul>
<p>We'll use Mongoose schemas to define the structure of each type of data. A schema describes the fields a document can have and the type of data each field should contain.</p>
<p>Let's start with the <code>Subject</code> model.</p>
<h4 id="heading-subject-model">Subject Model</h4>
<p>A subject represents a category of flashcards. For example, a student might create a subject called <strong>Biology 101</strong> and use it to organize their biology flashcards.</p>
<p>Each subject will have a name, an optional description, and a color that we'll use when displaying the subject in the UI.</p>
<p>First, create a <code>models</code> directory inside <code>src/lib</code> if you haven't already. Then create a file named <code>Subject.ts</code> inside it.</p>
<pre><code class="language-typescript">import mongoose, { Schema, model, models } from "mongoose";

export interface ISubject {
  _id: string;
  name: string;
  description?: string;
  color: string;
  createdAt: Date;
  updatedAt: Date;
}

const SubjectSchema = new Schema(
  {
    name: { type: String, required: true },
    description: { type: String },
    color: { type: String, default: "#6366f1" },
  },
  { timestamps: true }
);

export default models.Subject || model&lt;ISubject&gt;("Subject", SubjectSchema);
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>ISubject</code>: TypeScript interface. Describes what a subject object looks like in our app.</p>
</li>
<li><p><code>SubjectSchema</code> – Mongoose schema. <code>name</code> is required, while <code>description</code> and <code>color</code> are optional. <code>color</code> defaults to a purple hex.</p>
</li>
<li><p><code>timestamps: true</code>: Mongoose automatically adds <code>createdAt</code> and <code>updatedAt</code> to every document.</p>
</li>
<li><p><code>models.Subject || model(...)</code>: In development, modules can reload. This prevents "model already defined" errors by reusing the existing model if it exists.</p>
</li>
</ul>
<h4 id="heading-flashcard-model">Flashcard Model</h4>
<p>A flashcard belongs to a subject and contains a question on the front and an answer on the back.</p>
<p>Next, let's create the <code>Flashcard</code> model. Inside <code>src/lib/models</code>, create a file named <code>Flashcard.ts</code> and add the code below:</p>
<pre><code class="language-typescript">import mongoose, { Schema, model, models } from "mongoose";

export interface IFlashcard {
  _id: string;
  subjectId: string;
  front: string;
  back: string;
  createdAt: Date;
  updatedAt: Date;
}

const FlashcardSchema = new Schema(
  {
    subjectId: { type: Schema.Types.ObjectId, ref: "Subject", required: true },
    front: { type: String, required: true },
    back: { type: String, required: true },
  },
  { timestamps: true }
);

export default models.Flashcard || model&lt;IFlashcard&gt;("Flashcard", FlashcardSchema);
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>subjectId</code>: References a Subject by its <code>_id</code>. <code>ref: "Subject"</code> lets Mongoose populate this field (replace the ID with the full subject object when we fetch).</p>
</li>
<li><p><code>front</code> and <code>back</code>: The question and answer text.</p>
</li>
</ul>
<h4 id="heading-progress-model">Progress Model</h4>
<p>When a user studies, we need to record whether they answered each flashcard correctly or incorrectly. We'll use this information to display their progress on the dashboard.</p>
<p>Next, let's create the <code>Progress</code> model. Inside <code>src/lib/models</code>, create a file named <code>Progress.ts</code> and add the code below:</p>
<pre><code class="language-typescript">import mongoose, { Schema, model, models } from "mongoose";

export interface IProgress {
  _id: string;
  flashcardId: string;
  subjectId: string;
  correct: boolean;
  reviewedAt: Date;
}

const ProgressSchema = new Schema(
  {
    flashcardId: { type: Schema.Types.ObjectId, ref: "Flashcard", required: true },
    subjectId: { type: Schema.Types.ObjectId, ref: "Subject", required: true },
    correct: { type: Boolean, required: true },
    reviewedAt: { type: Date, default: Date.now },
  },
  { timestamps: true }
);

export default models.Progress || model&lt;IProgress&gt;("Progress", ProgressSchema);
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>correct</code>: <code>true</code> if the user knew the answer, <code>false</code> if not.</p>
</li>
<li><p><code>reviewedAt</code>: When the review happened. We use this for sorting and future features like spaced repetition.</p>
</li>
</ul>
<h3 id="heading-subjects-api-list-and-create">Subjects API – List and Create</h3>
<p>Now that we've defined our data models, let's create the API routes that will allow the application to work with that data.</p>
<p>API routes handle requests from the frontend and communicate with MongoDB. In this section, we'll create routes for creating and retrieving subjects and flashcards.</p>
<p>We'll start with the subjects API. This route will support two operations:</p>
<ul>
<li><p><strong>GET</strong>: Retrieve all subjects.</p>
</li>
<li><p><strong>POST</strong>: Create a new subject.</p>
</li>
</ul>
<p>Inside <code>src/app/api/subjects</code>, create a file named <code>route.ts</code> and add the code below:</p>
<pre><code class="language-typescript">import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Subject from "@/lib/models/Subject";

export async function GET() {
  try {
    await dbConnect();
    const subjects = await Subject.find({}).sort({ createdAt: -1 });
    return NextResponse.json(subjects);
  } catch (error) {
    console.error("Error fetching subjects:", error);
    return NextResponse.json(
      { error: "Failed to fetch subjects" },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    await dbConnect();
    const body = await request.json();
    const { name, description, color } = body;

    if (!name) {
      return NextResponse.json(
        { error: "Subject name is required" },
        { status: 400 }
      );
    }

    const subject = await Subject.create({
      name,
      description: description || "",
      color: color || "#6366f1",
    });

    return NextResponse.json(subject);
  } catch (error) {
    console.error("Error creating subject:", error);
    return NextResponse.json(
      { error: "Failed to create subject" },
      { status: 500 }
    );
  }
}
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>GET</code>: Fetches all subjects, sorted by newest first. <code>find({})</code> means "find all." Returns them as JSON.</p>
</li>
<li><p><code>POST</code>: Creates a new subject. Reads <code>name</code>, <code>description</code>, and <code>color</code> from the request body. Validates that <code>name</code> exists. Uses <code>Subject.create()</code> to save to MongoDB. Returns the created subject.</p>
</li>
<li><p><code>status: 400</code>: Bad request (missing data). <code>status: 500</code>: Server error (for example, database failure).</p>
</li>
</ul>
<h4 id="heading-flashcards-api-list-and-create">Flashcards API – List and Create</h4>
<p>Next, let's create the API route for working with flashcards. This route will let us retrieve existing flashcards and create new ones.</p>
<p>Inside <code>src/app/api/flashcards</code>, create a file named <code>route.ts</code> and add the code block:</p>
<pre><code class="language-typescript">import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Flashcard from "@/lib/models/Flashcard";

export async function GET(request: Request) {
  try {
    await dbConnect();
    const { searchParams } = new URL(request.url);
    const subjectId = searchParams.get("subjectId");

    const query = subjectId ? { subjectId } : {};
    const flashcards = await Flashcard.find(query)
      .populate("subjectId", "name color")
      .sort({ createdAt: -1 });

    return NextResponse.json(flashcards);
  } catch (error) {
    console.error("Error fetching flashcards:", error);
    return NextResponse.json(
      { error: "Failed to fetch flashcards" },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    await dbConnect();
    const body = await request.json();
    const { subjectId, front, back } = body;

    if (!subjectId || !front || !back) {
      return NextResponse.json(
        { error: "Subject, front, and back are required" },
        { status: 400 }
      );
    }

    const flashcard = await Flashcard.create({
      subjectId,
      front,
      back,
    });

    const populated = await Flashcard.findById(flashcard._id).populate(
      "subjectId",
      "name color"
    );

    return NextResponse.json(populated);
  } catch (error) {
    console.error("Error creating flashcard:", error);
    return NextResponse.json(
      { error: "Failed to create flashcard" },
      { status: 500 }
    );
  }
}
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>searchParams.get("subjectId")</code>: For <code>GET /api/flashcards?subjectId=abc123</code>, we filter by that subject. If no <code>subjectId</code>, we return all flashcards.</p>
</li>
<li><p><code>.populate("subjectId", "name color")</code>: Replaces the raw ID with the subject object, but only includes <code>name</code> and <code>color</code>. Makes it easy to display the subject name in the UI.</p>
</li>
<li><p><code>POST</code>: Requires <code>subjectId</code>, <code>front</code>, and <code>back</code>. After creating, we fetch the flashcard again with <code>populate</code> so the response includes the subject details.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/f15cd9c8-87d1-461b-92ac-4ec091481338.jpg" alt="Flashcard-create-study-form" style="display: block;" width="3024" height="1656" loading="lazy">

<h3 id="heading-part-4-editing-and-deleting-dynamic-api-routes">Part 4: Editing and Deleting (Dynamic API Routes)</h3>
<p>For individual subjects and flashcards, we'll use <strong>dynamic routes</strong>. In Next.js, placing <code>[id]</code> in a folder name creates a route that can handle different IDs. For example, <code>/api/subjects/123</code> and <code>/api/subjects/456</code> can use the same route.</p>
<h4 id="heading-subject-api-route">Subject API Route</h4>
<p>Let's start by creating the dynamic route for individual subjects. This route will let us retrieve, update, or delete a subject.</p>
<p>Inside <code>src/app/api/subjects/[id]</code>, create a file named <code>route.ts</code> and add the code below:</p>
<pre><code class="language-typescript">import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Subject from "@/lib/models/Subject";
import Flashcard from "@/lib/models/Flashcard";
import Progress from "@/lib/models/Progress";

export async function GET(
  _request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const subject = await Subject.findById(id);

    if (!subject) {
      return NextResponse.json({ error: "Subject not found" }, { status: 404 });
    }

    return NextResponse.json(subject);
  } catch (error) {
    console.error("Error fetching subject:", error);
    return NextResponse.json(
      { error: "Failed to fetch subject" },
      { status: 500 }
    );
  }
}

export async function PUT(
  request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const body = await request.json();
    const { name, description, color } = body;

    const subject = await Subject.findByIdAndUpdate(
      id,
      { name, description, color },
      { new: true }
    );

    if (!subject) {
      return NextResponse.json({ error: "Subject not found" }, { status: 404 });
    }

    return NextResponse.json(subject);
  } catch (error) {
    console.error("Error updating subject:", error);
    return NextResponse.json(
      { error: "Failed to update subject" },
      { status: 500 }
    );
  }
}

export async function DELETE(
  _request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  try {
    await dbConnect();
    const { id } = await params;

    await Flashcard.deleteMany({ subjectId: id });
    await Progress.deleteMany({ subjectId: id });
    const subject = await Subject.findByIdAndDelete(id);

    if (!subject) {
      return NextResponse.json({ error: "Subject not found" }, { status: 404 });
    }

    return NextResponse.json({ message: "Subject deleted" });
  } catch (error) {
    console.error("Error deleting subject:", error);
    return NextResponse.json(
      { error: "Failed to delete subject" },
      { status: 500 }
    );
  }
}
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>params</code>: In Next.js 15+, <code>params</code> is a Promise. We <code>await</code> it to get <code>{ id: "abc123" }</code>.</p>
</li>
<li><p><code>findByIdAndUpdate(id, updates, { new: true })</code>: Updates the document and returns the updated version. <code>{ new: true }</code> means "return the new document, not the old one."</p>
</li>
<li><p><code>DELETE</code>: When we delete a subject, we also delete its flashcards and progress records. Otherwise we'd have orphaned data.</p>
</li>
</ul>
<h4 id="heading-flashcard-by-id-get-update-delete">Flashcard by ID – Get, Update, Delete</h4>
<p>Now, let's create the dynamic route for individual flashcards. This route will let us retrieve, update, or delete a flashcard.</p>
<p>Inside <code>src/app/api/flashcards/[id]</code>, create a file named <code>route.ts</code> and add the code below:</p>
<pre><code class="language-typescript">import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Flashcard from "@/lib/models/Flashcard";

export async function GET(
  _request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const flashcard = await Flashcard.findById(id).populate(
      "subjectId",
      "name color"
    );

    if (!flashcard) {
      return NextResponse.json(
        { error: "Flashcard not found" },
        { status: 404 }
      );
    }

    return NextResponse.json(flashcard);
  } catch (error) {
    console.error("Error fetching flashcard:", error);
    return NextResponse.json(
      { error: "Failed to fetch flashcard" },
      { status: 500 }
    );
  }
}

export async function PUT(
  request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const body = await request.json();
    const { front, back } = body;

    const flashcard = await Flashcard.findByIdAndUpdate(
      id,
      { front, back },
      { new: true }
    ).populate("subjectId", "name color");

    if (!flashcard) {
      return NextResponse.json(
        { error: "Flashcard not found" },
        { status: 404 }
      );
    }

    return NextResponse.json(flashcard);
  } catch (error) {
    console.error("Error updating flashcard:", error);
    return NextResponse.json(
      { error: "Failed to update flashcard" },
      { status: 500 }
    );
  }
}

export async function DELETE(
  _request: Request,
  { params }: { params: Promise&lt;{ id: string }&gt; }
) {
  try {
    await dbConnect();
    const { id } = await params;
    const flashcard = await Flashcard.findByIdAndDelete(id);

    if (!flashcard) {
      return NextResponse.json(
        { error: "Flashcard not found" },
        { status: 404 }
      );
    }

    return NextResponse.json({ message: "Flashcard deleted" });
  } catch (error) {
    console.error("Error deleting flashcard:", error);
    return NextResponse.json(
      { error: "Failed to delete flashcard" },
      { status: 500 }
    );
  }
}
</code></pre>
<h3 id="heading-part-5-progress-tracking-api">Part 5: Progress Tracking API</h3>
<p>When a user marks a flashcard as correct or incorrect during a study session, we need to save that result. We'll also use this data to calculate progress statistics, such as the percentage of correct answers for each subject.</p>
<p>Next, let's create the API route for tracking progress. Inside <code>src/app/api/progress</code>, create a file named <code>route.ts</code> and add the code below:</p>
<pre><code class="language-typescript">import { NextResponse } from "next/server";
import dbConnect from "@/lib/db";
import Progress from "@/lib/models/Progress";

export async function GET(request: Request) {
  try {
    await dbConnect();
    const { searchParams } = new URL(request.url);
    const subjectId = searchParams.get("subjectId");

    const query = subjectId ? { subjectId } : {};
    const progress = await Progress.find(query).sort({ reviewedAt: -1 });

    const stats = await Progress.aggregate([
      { $match: query },
      {
        $group: {
          _id: "$subjectId",
          total: { $sum: 1 },
          correct: { $sum: { $cond: ["$correct", 1, 0] } },
        },
      },
    ]);

    return NextResponse.json({ progress, stats });
  } catch (error) {
    console.error("Error fetching progress:", error);
    return NextResponse.json(
      { error: "Failed to fetch progress" },
      { status: 500 }
    );
  }
}

export async function POST(request: Request) {
  try {
    await dbConnect();
    const body = await request.json();
    const { flashcardId, subjectId, correct } = body;

    if (!flashcardId || !subjectId || typeof correct !== "boolean") {
      return NextResponse.json(
        { error: "flashcardId, subjectId, and correct are required" },
        { status: 400 }
      );
    }

    const progress = await Progress.create({
      flashcardId,
      subjectId,
      correct,
    });

    return NextResponse.json(progress);
  } catch (error) {
    console.error("Error recording progress:", error);
    return NextResponse.json(
      { error: "Failed to record progress" },
      { status: 500 }
    );
  }
}
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>aggregate</code>: MongoDB's aggregation pipeline. We group by <code>subjectId</code> and count total reviews and correct answers. <code>$cond: ["$correct", 1, 0]</code> means "if correct is true, add 1, else add 0."</p>
</li>
<li><p><code>stats</code>: Returns something like <code>[{ _id: "subjectId123", total: 20, correct: 16 }]</code>. The frontend uses this to show "80% accuracy" per subject.</p>
</li>
</ul>
<h2 id="heading-ui-implementation">UI Implementation</h2>
<p>Now we'll build the pages users see. We'll use React hooks (<code>useState</code>, <code>useEffect</code>) to manage data and <code>fetch</code> to call our API.</p>
<h3 id="heading-the-subjects-page">The Subjects Page</h3>
<p>On load, we fetch subjects from the API. We show a form to create new subjects. Each subject is a card that links to its detail page.</p>
<p>Key logic:</p>
<ol>
<li><p><code>useEffect</code> runs once on mount and calls <code>fetch("/api/subjects")</code>.</p>
</li>
<li><p>The form's <code>onSubmit</code> calls <code>fetch("/api/subjects", { method: "POST", ... })</code>.</p>
</li>
<li><p>After a successful create, we clear the form and call <code>fetchSubjects()</code> again to refresh the list.</p>
</li>
</ol>
<pre><code class="language-tsx">// Simplified structure - see full code in src/app/subjects/page.tsx
const [subjects, setSubjects] = useState&lt;Subject[]&gt;([]);
const [showForm, setShowForm] = useState(false);

useEffect(() =&gt; {
  fetch("/api/subjects")
    .then((res) =&gt; res.json())
    .then((data) =&gt; setSubjects(data));
}, []);

const handleSubmit = async (e) =&gt; {
  e.preventDefault();
  await fetch("/api/subjects", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ name, description, color }),
  });
  fetchSubjects(); 
};
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/30d83725-4d91-4642-b8ee-f3a89726843f.jpg" alt="Flashcard-study-list" style="display: block;" width="3024" height="1446" loading="lazy">

<h3 id="heading-the-subject-detail-page-creating-and-editing-flashcards">The Subject Detail Page (Creating and Editing Flashcards)</h3>
<p>This page shows one subject and its flashcards. Users can add new cards or edit/delete existing ones. The URL is <code>/subjects/[id]</code>, so we use <code>useParams()</code> to get the subject ID.</p>
<p>Key logic:</p>
<ol>
<li><p><code>useParams()</code> gives us the <code>id</code> from the URL.</p>
</li>
<li><p>We fetch the subject and its flashcards on mount.</p>
</li>
<li><p>"Add Flashcard" shows a form. On submit, we POST to <code>/api/flashcards</code> with <code>subjectId</code>, <code>front</code>, and <code>back</code>.</p>
</li>
<li><p>Each card has Edit and Delete buttons. Edit switches to an inline form, while Delete calls <code>DELETE /api/flashcards/[id]</code>.</p>
</li>
</ol>
<pre><code class="language-tsx">// Creating a flashcard
const handleCreate = async (e) =&gt; {
  e.preventDefault();
  await fetch("/api/flashcards", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ subjectId: id, front, back }),
  });
  fetchFlashcards(); // Refresh
};

// Updating a flashcard
const handleUpdate = async (e) =&gt; {
  e.preventDefault();
  await fetch(`/api/flashcards/${editingId}`, {
    method: "PUT",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ front: editFront, back: editBack }),
  });
  setEditingId(null);
  fetchFlashcards();
};
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/e60ba077-b22b-4f71-895f-5fce4f9e66d3.jpg" alt="Study-details-page" style="display: block;" width="3024" height="1360" loading="lazy">

<h3 id="heading-the-study-page-flipping-cards">The Study Page – Flipping Cards</h3>
<p>The study page has three main states:</p>
<ol>
<li><p><strong>Subject selection</strong>: User picks which subject to study.</p>
</li>
<li><p><strong>Ready to start</strong>: Shows "Start Studying" with the card count.</p>
</li>
<li><p><strong>Studying</strong>: Shows one card at a time. User clicks to flip, then marks correct or wrong. We advance to the next card and record progress.</p>
</li>
</ol>
<h4 id="heading-the-flip-animation">The Flip Animation</h4>
<p>We use CSS 3D transforms to create the flip animation. The flashcard has two faces: a front for the question and a back for the answer. When <code>flipped</code> is <code>true</code>, we rotate the card container 180 degrees. We also use <code>backface-visibility: hidden</code> so that only the appropriate face is visible during the rotation.</p>
<p>To add the styles for the flip animation, open <code>src/app/globals.css</code> and add the following code:</p>
<pre><code class="language-css">/* Flashcard flip animation */
.perspective-1000 {
  perspective: 1000px;
}

.preserve-3d {
  transform-style: preserve-3d;
}

.backface-hidden {
  backface-visibility: hidden;
}

/* Lined paper effect for the card background */
.lined-paper {
  background-image: repeating-linear-gradient(
    transparent,
    transparent 27px,
    #e5e7eb 27px,
    #e5e7eb 28px
  );
}
</code></pre>
<p>The <code>lined-paper</code> class creates horizontal grey lines (like notebook paper) using a repeating gradient. This gives the flashcard a familiar, study-friendly look.</p>
<p>The card structure:</p>
<pre><code class="language-tsx">&lt;div
  className={`preserve-3d transition-transform duration-500 ${
    flipped ? "[transform:rotateY(180deg)]" : ""
  }`}
&gt;
  {/* Front face - Question */}
  &lt;div className="backface-hidden [transform:rotateY(0deg)]"&gt;
    {currentCard.front}
  &lt;/div&gt;
  {/* Back face - Answer */}
  &lt;div className="backface-hidden [transform:rotateY(180deg)]"&gt;
    {currentCard.back}
  &lt;/div&gt;
&lt;/div&gt;
</code></pre>
<p>When the user clicks the card, we toggle <code>flipped</code>. The parent rotates, and the correct face becomes visible.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6904c2dbd42ef6b1f9e61c3e/a92a03c7-c562-4600-ba2c-ecd6e85ab00c.jpg" alt="a92a03c7-c562-4600-ba2c-ecd6e85ab00c" style="display: block;" width="3024" height="1562" loading="lazy">

<h4 id="heading-recording-progress">Recording Progress</h4>
<p>When the user clicks "Got it!" or "Didn't know", we:</p>
<ol>
<li><p>POST to <code>/api/progress</code> with <code>flashcardId</code>, <code>subjectId</code>, and <code>correct</code>.</p>
</li>
<li><p>Update local state (<code>sessionCorrect</code> or <code>sessionWrong</code>) for the live stats.</p>
</li>
<li><p>Move to the next card. If we've finished all cards, we show the "Start Studying" screen again.</p>
</li>
</ol>
<pre><code class="language-tsx">const handleKnow = () =&gt; {
  recordProgress(true);
  setFlipped(false);
  if (currentIndex &lt; flashcards.length - 1) {
    setCurrentIndex((i) =&gt; i + 1);
  } else {
    setStudyStarted(false);
    setCurrentIndex(0);
  }
};
</code></pre>
<h3 id="heading-the-progress-page">The Progress Page</h3>
<p>Here, we fetch subjects and progress stats. For each subject, we look up its stats (total reviews, correct count) and compute the percentage. We display overall stats at the top and per-subject breakdown below.</p>
<pre><code class="language-tsx">const getSubjectStats = (subjectId) =&gt; {
  const stat = stats.find((s) =&gt; s._id === subjectId);
  return stat
    ? {
        total: stat.total,
        correct: stat.correct,
        pct: Math.round((stat.correct / stat.total) * 100),
      }
    : null;
};
</code></pre>
<h3 id="heading-optional-lined-paper-and-paperclip-icon">Optional: Lined Paper and Paperclip Icon</h3>
<p>The app includes a lined-paper effect and a paperclip icon to make the flashcard feel more tactile. The paperclip is a simple SVG component:</p>
<pre><code class="language-tsx">// src/components/PaperclipIcon.tsx
export default function PaperclipIcon({ className }: { className?: string }) {
  return (
    &lt;svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"&gt;
      &lt;path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" /&gt;
    &lt;/svg&gt;
  );
}
</code></pre>
<p>Place it at the top center of the flashcard. The <code>lined-paper</code> class is applied to the card content area for the notebook effect.</p>
<h2 id="heading-best-practices">Best Practices</h2>
<h3 id="heading-code-organization">Code Organization</h3>
<ul>
<li><p><strong>Models</strong> in <code>lib/models/</code>: One file per model. Keeps schemas in one place.</p>
</li>
<li><p><strong>API routes</strong> in <code>app/api/</code>: Group by resource (subjects, flashcards, progress). Use <code>[id]</code> for dynamic routes.</p>
</li>
<li><p><strong>Reusable components</strong>: The <code>PaperclipIcon</code> is in <code>components/</code>. Use this pattern for any UI you repeat.</p>
</li>
</ul>
<h3 id="heading-error-handling">Error Handling</h3>
<ul>
<li><p><strong>API routes</strong>: Always wrap logic in <code>try/catch</code>. Return appropriate status codes (400 for bad input, 404 for not found, 500 for server errors).</p>
</li>
<li><p><strong>Frontend</strong>: Check <code>res.ok</code> before using <code>res.json()</code>. Show loading and error states to the user.</p>
</li>
</ul>
<h3 id="heading-performance-tips">Performance Tips</h3>
<ul>
<li><p><strong>Database connection</strong>: Reuse the connection (our <code>dbConnect</code> does this). Don't connect on every request.</p>
</li>
<li><p><strong>Populate sparingly</strong>: Only <code>.populate()</code> fields you need. Specify which fields: <code>.populate("subjectId", "name color")</code>.</p>
</li>
<li><p><strong>Loading states</strong>: Show a spinner while fetching. Prevents layout shift and gives feedback.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built a full-stack flashcard app with:</p>
<ul>
<li><p><strong>Next.js</strong> for the app and API routes</p>
</li>
<li><p><strong>MongoDB + Mongoose</strong> for storing subjects, flashcards, and progress</p>
</li>
<li><p><strong>React</strong> for the UI with forms, lists, and a flip animation</p>
</li>
<li><p><strong>Tailwind CSS</strong> for styling</p>
</li>
</ul>
<h3 id="heading-possible-improvements">Possible Improvements</h3>
<p>There are a few features you could build to improve this app.</p>
<p>First, you could add authentication. Add login so each user has their own subjects and cards. Consider NextAuth.js or Clerk.</p>
<p>Second, you could add a spaced repetition feature. Use the progress data to show cards at optimal intervals (for example, cards you got wrong more often).</p>
<p>Next, you could add some animations, like transitions between cards or a confetti effect when a session is complete.</p>
<p>You could also build in mobile responsiveness. The current layout works on desktop, but you could optimize the study view for phones.</p>
<p>And finally, an export/import feature could be useful: let users export their flashcards as JSON or CSV for backup.</p>
<h3 id="heading-next-steps">Next Steps</h3>
<p>To take this further, run <code>npm run dev</code> and explore the app. You can add a few subjects and flashcards, then try the study mode.</p>
<p>After that, open MongoDB Compass or Atlas to inspect your data. Experiment with the code: change colors, add fields, or tweak the flip animation.</p>
<p>Happy studying!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What to Do if Your Site Is About to Hit Vercel's Free Image Optimization Cap ]]>
                </title>
                <description>
                    <![CDATA[ I maintain my own website, and every now and then I upgrade the site to a new version. And I frequently experiment with different technologies and architectures while doing so. Recently, I faced an is ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-to-do-if-your-site-is-about-to-hit-vercels-free-image-optimization-cap/</link>
                <guid isPermaLink="false">6a848de33638e739c70ebce3</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Vercel ]]>
                    </category>
                
                    <category>
                        <![CDATA[ image optimization  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CDN ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md. Fahim Bin Amin ]]>
                </dc:creator>
                <pubDate>Tue, 18 Aug 2026 16:52:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/58b79fa2-e0a0-4172-b07f-e3446413eca8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I maintain my own website, and every now and then I upgrade the site to a new version. And I frequently experiment with different technologies and architectures while doing so.</p>
<p>Recently, I faced an issue where Vercel was giving me a warning that I was almost reaching my free tier limit on "Image Optimization - Transformation". After a few trials and errors, I managed to fix that so that it doesn't exceed my free tier unnecessarily.</p>
<p>In this article, I'll explain what the issues were, how I pinpointed the exact cause, and what options I had for resolving them. I'll also walk you through the approach I ultimately chose and how I managed to fix the problem.</p>
<h3 id="heading-so-what-was-the-problem">So What Was the Problem?</h3>
<p>My current website is running on version 5 (Year 2026), which uses a CDN from Cloudflare R2 Object Storage. It also has massive SEO optimization for both web search and AI search.</p>
<p>You can check out the website directly here: <a href="https://fahimbinamin.com">fahimbinamin.com</a>. I'm not sure which version you'll be able to see in the domain when you're visiting the website, but it'll always be the latest version.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60752ce9308cd51e1477fe19/6a478190-2df6-4138-b740-e0331833c0db.png" alt="Fahim's Website (5th version)" style="display: block;" width="2880" height="1800" loading="lazy">

<p>As version 5 of my website is a multi-page website, I tried to include everything about me under one platform. I have a lot of writing published in multiple places, and I wanted to add all of the articles under the same platform. But I also wanted to make sure that their canonical URLs worked well. This would help the AI crawler/bot understand the original published URL/source. So I incorporated this feature in the 5th version of my website.</p>
<p>This website uses Next.js and Next's image optimization protocol properly. Currently, the codebase is closed source and you won't be able to access it anywhere. But I've shared a screenshot so that you can have a glimpse of how it looks now.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60752ce9308cd51e1477fe19/5fbedbc8-a5a1-4d84-8eb2-90efc8b81aeb.png" alt="Fahim's website's (5th version) codebase on GitHub (Closed Source)" style="display: block;" width="2880" height="1800" loading="lazy">

<p>Anyway, to save money, I like to utilize free resources as effectively as possible. For the CDN, I'm using Cloudflare with R2 object storage. All the media you see on my website (images, PDFs, and so on) comes from Cloudflare R2 object storage. It's running via Vercel.</p>
<p>Everything was going smoothly until yesterday, when I noticed an email from Vercel stating that I was approaching my free tier limit for image optimization.</p>
<img src="https://cdn.hashnode.com/uploads/covers/60752ce9308cd51e1477fe19/c777113f-a8a5-41b5-a696-82f7d8ab2e37.png" alt="Warning about Image Optimization" style="display: block;" width="848" height="900" loading="lazy">

<p>I opened the usage panel expecting to see my own project images: profile photos, project screenshots, and gallery frames. Instead, almost every source image listed was hosted on <a href="http://freecodecamp.org"><code>freecodecamp.org</code></a>.</p>
<p>This was confusing for a few seconds because I don't host my images on freeCodeCamp. Then I remembered that I actually link to them!</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<p>I've organized the entire article with sections that can help you jump around if you want or need to read any specific section separately.</p>
<ul>
<li><p><a href="#heading-where-these-images-actually-come-from">Where These Images Actually Come From</a></p>
</li>
<li><p><a href="#heading-confirming-the-scope">Confirming the Scope</a></p>
</li>
<li><p><a href="#heading-two-ways-to-fix-this-issue">Two Ways to Fix This Issue</a></p>
</li>
<li><p><a href="#heading-which-solution-i-chose">Which Solution I Chose</a></p>
</li>
<li><p><a href="#heading-doing-the-migration">Doing the Migration</a></p>
</li>
<li><p><a href="#heading-why-this-is-the-better-fix-long-term">Why This Is the Better Fix Long-Term</a></p>
</li>
<li><p><a href="#heading-what-i-will-do-differently-next-time">What I Will Do Differently Next Time</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-where-these-images-actually-come-from">Where These Images Actually Come From</h2>
<p>I have cross-posted a lot of my writing to freeCodeCamp over the years. When I backfilled my articles into this site's blog archive, I kept the article bodies intact, including the <code>&lt;img&gt;</code> tags pointing at freeCodeCamp's and Hashnode's CDNs (as those were the places where the screenshots had always lived).</p>
<p>The blog renderer wraps every inline image in a <code>BlogImage</code> component:</p>
<pre><code class="language-javascript">import Image from "next/image";
 
export default function BlogImage({ src, alt, caption }) {
  return (
    &lt;figure&gt;
      &lt;Image src={src} alt={alt} fill sizes="(max-width: 768px) 100vw, 768px" /&gt;
      &lt;figcaption&gt;{caption}&lt;/figcaption&gt;
    &lt;/figure&gt;
  );
}
</code></pre>
<p><code>next/image</code> actually doesn't care whether the <code>src</code> points at your own domain or someone else's. As long as the host is listed in <code>images.remotePatterns</code> in <code>next.config.ts</code>, Next.js will run it through Vercel's Image Optimization pipeline in this step, including fetching the original, resizing it for every breakpoint, re-encoding to AVIF/WebP, and caching the result.</p>
<p>I had added <code>cdn-media-0.freecodecamp.org</code>, <code>www.freecodecamp.org</code>, and <code>cdn.hashnode.com</code> to that allowlist months ago in two separate commits. I did that specifically so those backfilled images would render instead of breaking.</p>
<p>That actually worked for me in this case. But it also meant that every one of those images (and every responsive size Next.js generated from each one) counted against my Vercel account's free-tier Image Optimization quota. I don't want my quota to be spent carelessly, since it resets monthly and I don't control how many times a crawler or a page load re-triggers a size variant.</p>
<h2 id="heading-confirming-the-scope">Confirming the Scope</h2>
<p>Before fixing anything, I wanted real numbers instead of a guess. I grepped every post in <code>content/posts/*.mdx</code> for <code>src="..."</code> attributes pointing at the three external hosts:</p>
<pre><code class="language-javascript">src="(https://(?:cdn-media-0\.freecodecamp\.org|www\.freecodecamp\.org|cdn\.hashnode\.com)[^"]*)"
</code></pre>
<p>That turned up 691 image references across 33 posts: old screenshots from tutorials going back to 2021, everything from disk-partitioning walkthroughs to CUDA setup guides. All of it was being optimized on someone else's dime (mine) instead of being cached once and served flat. That's what was causing the issue earlier.</p>
<p>When I was looking for solutions, I found two ways that could instantly help me fix the issue quickly.</p>
<h2 id="heading-two-ways-to-fix-this-issue">Two Ways to Fix This Issue</h2>
<p><strong>Option 1: Stop optimizing them.</strong> Pass <code>unoptimized</code> to <code>next/image</code> for any source that isn't my own CDN. Vercel stops touching those requests entirely.</p>
<p>In that case, the browser fetches the image straight from freeCodeCamp, and there's no resizing or AVIF conversion, and zero quota impact. This means that it will be just a small diff that can be done in minutes.</p>
<p><strong>Option 2: Own the images.</strong> Download all 691, re-host them on the Cloudflare R2 bucket I already use for every other image on the site, rewrite the <code>src</code> attributes to point at <code>cdn.fahimbinamin.com</code>, and drop the three external hosts from <code>remotePatterns</code> entirely.</p>
<h2 id="heading-which-solution-i-chose">Which Solution I Chose</h2>
<p>Option 1 was tempting for how little needed to be touched. But it meant permanently giving up responsive sizing and modern formats for a third of my blog archive, and it left the site depending on freeCodeCamp's CDN staying up and those specific URLs never changing.</p>
<p>Option 2 costs more up front but matches how every other image on the site already works. It also keeps the optimization benefit and removes the external dependency completely. So I went with Option 2.</p>
<h2 id="heading-doing-the-migration">Doing the Migration</h2>
<h3 id="heading-step-1-download-everything">Step 1: Download Everything</h3>
<p>I wrote a small Node script that walked every post, matched the same three-host regex, and pulled down each unique URL:</p>
<pre><code class="language-javascript">const HOST_RE =
  /src="(https:\/\/(?:cdn-media-0\.freecodecamp\.org|www\.freecodecamp\.org|cdn\.hashnode\.com)[^"]*)"/g;
 
// ...for each match, download to downloaded-images/&lt;slug&gt;/&lt;filename&gt;
// and record oldUrl -&gt; "images/writing/&lt;slug&gt;/&lt;filename&gt;" in url-map.json
</code></pre>
<p>Files were organized by post slug specifically to avoid collisions, since a lot of these screenshots share generic names like <code>2022-01-20_18-50.png</code> across completely different tutorials. Lastly, it was a total of 691 downloads with 0 failures, about 105 MB in total.</p>
<h3 id="heading-step-2-upload-to-r2">Step 2: Upload to R2</h3>
<p>I restructured the download folder to mirror the target key prefix (<code>images/writing/&lt;slug&gt;/...</code>) and uploaded it to the bucket using <a href="https://s3browser.com/">CS Browser</a>. R2 exposes an S3-compatible API and a GUI client handles hundreds of nested files more reliably than dragging a folder through the dashboard.</p>
<h3 id="heading-step-3-verify-before-touching-content">Step 3: Verify Before Touching content</h3>
<p>Before rewriting anything, I spot-checked a couple of the newly uploaded URLs:</p>
<pre><code class="language-shell">curl -s -o /dev/null -w "%{http_code}\n" \
  "https://cdn.fahimbinamin.com/images/writing/automount-a-storage-partition-on-startup-in-linux/HDD-Partition.png"
# 200
</code></pre>
<h3 id="heading-step-4-rewrite-the-mdx">Step 4: Rewrite the MDX</h3>
<p>A second script read <code>url-map.json</code> and replaced every matching <code>src="..."</code> in <code>content/posts/*.mdx</code> with the equivalent <code>cdn.fahimbinamin.com</code> URL:</p>
<pre><code class="language-javascript">content = content.replace(HOST_RE, (match, url) =&gt; {
  const entry = urlMap[url];
  if (!entry) return match; // left untouched, logged for review
  return `src="${CDN_BASE}/${entry.cdnPath}"`;
});
</code></pre>
<p>After the testing, I found out that a total of 33 files changed, 692 replacements (one image was referenced twice in the same post), and 0 were left unmapped.</p>
<h3 id="heading-step-5-clean-up-the-config">Step 5: Clean Up the Config</h3>
<p>With no more <code>&lt;Image&gt;</code> references to the external hosts, I removed all three from <code>images.remotePatterns</code> in <code>next.config.ts</code>:</p>
<pre><code class="language-javascript">// Removed, no longer needed
{ protocol: "https", hostname: "cdn.hashnode.com", pathname: "/**" },
{ protocol: "https", hostname: "cdn-media-0.freecodecamp.org", pathname: "/**" },
{ protocol: "https", hostname: "www.freecodecamp.org", pathname: "/**" },
</code></pre>
<p>I ran a full production build afterward. All 43+ blog posts pre-rendered clean, lint passed, and nothing was broken.</p>
<h2 id="heading-why-this-is-the-better-fix-long-term">Why This Is the Better Fix Long-Term</h2>
<p>The quota problem was really a symptom of a smaller mistake: treating a third-party CDN as if it were infrastructure that I controlled. It rendered fine for months, right up until usage crossed a threshold I wasn't watching closely enough.</p>
<p><code>unoptimized</code> would have made the warning go away without addressing the issue. But I would have kept depending on freeCodeCamp's URLs staying valid indefinitely, with no optimization to show for it either.</p>
<p>Self-hosting means that every image on this site now goes through the same pipeline, and uses the cache rules and CDN, regardless of where the content originally lived. I didn't want anything that could silently break because someone else changed a URL structure.</p>
<h2 id="heading-what-i-will-do-differently-next-time">What I Will Do Differently Next Time</h2>
<p>The two <code>remotePatterns</code> entries for freeCodeCamp and Hashnode were each added months apart, each time because a backfilled post's images were broken and adding the host was the fastest fix.</p>
<p>I never asked myself before, "should this actually be hosted here, or should it live on my own CDN from day one?" That question is cheap to ask before backfilling content and expensive to answer after 691 images and 33 posts are already live pointing outward.</p>
<p>I've written that rule down now, so future backfills download and re-host before a single <code>&lt;img&gt;</code> tag ever points off-domain.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Thank you so much for reading all the way through. I hope that this article will help you if you face the same issue in the future.</p>
<p>To get more content like this, you can follow me on <a href="https://www.linkedin.com/in/fahimfba/">LinkedIn</a> and <a href="https://x.com/Fahim_FBA">X</a>. You can also check <a href="https://www.fahimbinamin.com/">my website</a> and follow me on <a href="https://github.com/FahimFBA">GitHub</a> if you're into open source and development. If you like to watch programming and technology-related videos, then you can <a href="https://www.youtube.com/@FahimAmin">subscribe to my YouTube channel</a> as well.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Open Source SaaS Landing Page Template with shadcn/ui ]]>
                </title>
                <description>
                    <![CDATA[ Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repeti ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-landing-page-nextjs-shadcn/</link>
                <guid isPermaLink="false">6a70e0650d58f4d80d2eca59</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcn ]]>
                    </category>
                
                    <category>
                        <![CDATA[ shadcnui ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ash ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:39:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/33d9aa05-3187-4d07-8aea-bcd83fe13ac0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most SaaS landing pages share the same core sections: a hero, social proof, features, pricing, FAQ, and a footer. And most developers end up building these from scratch on every project. That's repetition, not engineering.</p>
<p>So I built and open-sourced a complete SaaS landing page template called <a href="https://www.shadcndeck.com/templates/chatdeck-saas-landing-page">ChatDeck</a>. It runs on Next.js 16, React 19, shadcn/ui with the new <code>base-nova</code> style, Tailwind CSS v4, and TypeScript. The full source is on GitHub under the MIT license. I built and open-sourced this template, and everything here comes from decisions made during that process.</p>
<p>Building it forced me to make real decisions on a stack that moved significantly in the past 12 months. This article is about those decisions: what worked, what didn't, and what I'd do differently if I started today.</p>
<p><strong>Prerequisites:</strong> This article assumes you're comfortable with React and TypeScript. Some familiarity with the Next.js App Router is helpful but not required. Each lesson is explained from first principles.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</a></p>
</li>
<li><p><a href="#heading-getting-started">Getting Started</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's Newbase-novaStyle Changes What "Accessible" Means</a></p>
</li>
<li><p><a href="#heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</a></p>
</li>
<li><p><a href="#heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</a></p>
</li>
<li><p><a href="#heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</a></p>
</li>
<li><p><a href="#heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</a></p>
</li>
<li><p><a href="#heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</a></p>
</li>
<li><p><a href="#heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</a></p>
</li>
<li><p><a href="#heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</a></p>
</li>
<li><p><a href="#heading-what-id-do-differently">What I'd Do Differently</a></p>
</li>
<li><p><a href="#heading-summary">Summary</a></p>
</li>
</ul>
<h2 id="heading-the-stack-choices-and-why-they-matter">The Stack Choices and Why They Matter</h2>
<p>Before getting into the code, here's what the template runs on. Each choice was deliberate — none of these are defaults you get from <code>create-next-app</code>.</p>
<table>
<thead>
<tr>
<th>Technology</th>
<th>Version</th>
<th>Why I chose it</th>
</tr>
</thead>
<tbody><tr>
<td>Next.js</td>
<td>^16.0.3</td>
<td>App Router gives you React Server Components out of the box. Static sections like Hero and Features render on the server — no client-side JS needed for content that never changes.</td>
</tr>
<tr>
<td>React</td>
<td>19.2.0</td>
<td>React 19 stabilises the <code>use</code> hook and concurrent features. Staying on the latest version means the template doesn't immediately feel stale.</td>
</tr>
<tr>
<td>shadcn/ui</td>
<td>^4.13.0 (CLI)</td>
<td>Components are copied into your codebase, not installed as a package. You own the code. No version lock-in, no fighting library defaults when you need to customize.</td>
</tr>
<tr>
<td>Base UI (<code>@base-ui/react</code>)</td>
<td>^1.6.0</td>
<td>shadcn/ui's new <code>base-nova</code> style uses Base UI instead of Radix as its headless primitive layer. It has a smaller peer dependency footprint and tighter ARIA integration. More on this in Lesson 1.</td>
</tr>
<tr>
<td>Tailwind CSS</td>
<td>^4</td>
<td>v4 moves theme configuration from a JavaScript config file into CSS directly. Custom animations, color tokens, and radius scales all live in <code>globals.css</code>. More on this in Lesson 2.</td>
</tr>
<tr>
<td>Motion (<code>motion/react</code>)</td>
<td>^12.23.24</td>
<td>The rebranded Framer Motion. Handles entrance animations on the Hero and scroll-triggered animations on the Features section. Chosen over CSS animations because staggered sequences are much simpler to manage.</td>
</tr>
<tr>
<td>TypeScript</td>
<td>^5</td>
<td>Full type safety throughout. Component props, icon maps, pricing plan objects — all typed. Catches errors at build time, not at runtime.</td>
</tr>
<tr>
<td>Lucide React</td>
<td>^0.553.0</td>
<td>Consistent, well-maintained icon set that works cleanly with Tailwind's <code>size-*</code> utilities. No custom SVG wrangling needed for UI icons.</td>
</tr>
</tbody></table>
<p>The most interesting decisions in this list are the ones that reflect how the ecosystem changed in the past year: Base UI replacing Radix inside shadcn/ui, and Tailwind v4's shift to CSS-first configuration. The lessons below walk through each of these in detail, starting with the choices that had the biggest impact on how the code is actually written.</p>
<h2 id="heading-getting-started">Getting Started</h2>
<p>Before diving into the lessons, here's how to get the project running locally. Having it open alongside this article makes the code examples easier to follow.</p>
<pre><code class="language-bash">git clone https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template.git
cd chatdeck-shadcn-saas-landing-page-template
pnpm install
pnpm dev
</code></pre>
<p>Open <code>http://localhost:3000</code> and you'll see the full landing page running locally.</p>
<p>All section content lives as plain TypeScript arrays inside each Block component. To change the features, edit the <code>features</code> array in <code>FeatureSection.tsx</code>. To change pricing tiers, edit the <code>plans</code> array in <code>PricingSection.tsx</code>. No CMS, no config files — just TypeScript objects.</p>
<p>To customize colors, update the OKLCH values in <code>app/globals.css</code> under the <code>:root</code> block. Change <code>--primary</code> and every button, link, and accent color updates across the entire template.</p>
<p>Deploy to Vercel with a single <code>vercel</code> command or by pushing to GitHub and connecting the repo. Next.js is detected automatically.</p>
<h2 id="heading-project-structure">Project Structure</h2>
<p>Here's the full directory layout before we go through each part of it:</p>
<pre><code class="language-plaintext">chatdeck/
├── app/
│   ├── globals.css         # Theme tokens + custom animations (Tailwind v4 @theme)
│   ├── layout.tsx          # Root layout — Navbar, Footer, fonts
│   └── page.tsx            # Section imports — 16 lines
├── components/
│   ├── Blocks/             # Page sections (Hero, Features, Pricing, etc.)
│   ├── ui/                 # shadcn/ui components — base-nova style
│   └── navbar.tsx          # Scroll-aware sticky navbar
└── lib/
    └── utils.ts            # cn() helper (clsx + tailwind-merge)
</code></pre>
<p>The key separation is <code>Blocks/</code> vs <code>ui/</code>. The <code>ui/</code> folder holds primitive components — Button, Badge, Accordion — that come from shadcn/ui and rarely change. The <code>Blocks/</code> folder holds page-level sections that are specific to this template and change often. When you're customising, you mostly work in <code>Blocks/</code>. When you upgrade <a href="https://www.shadcndeck.com/blog/shadcn-components">shadcn/ui components</a>, you touch <code>ui/</code>.</p>
<p>The lessons below go through specific files in this structure piece by piece: <code>components.json</code> and <code>ui/accordion.tsx</code> in Lesson 1, <code>app/globals.css</code> in Lessons 2 and 3, <code>app/page.tsx</code> in Lesson 4, and the individual Block components in Lessons 5 through 8.</p>
<h2 id="heading-lesson-1-shadcnuis-new-base-nova-style-changes-what-accessible-means">Lesson 1: shadcn/ui's New <code>base-nova</code> Style Changes What "Accessible" Means</h2>
<p>If you've used shadcn/ui before, you know the default setup uses <strong>Radix UI</strong> primitives, headless components that handle focus management, keyboard navigation, and ARIA attributes. Radix has been the default for years.</p>
<p>But shadcn/ui introduced a new style in 2025 called <code>base-nova</code>, which replaces <a href="https://www.shadcndeck.com/blog/radix-vs-base-ui">Radix with <strong>Base UI</strong></a>, the headless primitive library from MUI.</p>
<p>Based on shadcn's public direction and the components released through 2025, <code>base-nova</code> appears to be the intended default going forward (though shadcn hasn't yet deprecated the Radix style).</p>
<p>In the project's <code>components.json</code>:</p>
<pre><code class="language-json">{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "base-nova",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "css": "app/globals.css",
    "baseColor": "neutral",
    "cssVariables": true
  },
  "iconLibrary": "lucide"
}
</code></pre>
<p>The <code>"style": "base-nova"</code> line means every component the shadcn/ui CLI installs wraps Base UI primitives instead of Radix. To understand what this changes in practice, here's what the same Accordion trigger component looks like in the older Radix-based default style:</p>
<pre><code class="language-tsx">// Radix-based default style (the old way)
import * as AccordionPrimitive from "@radix-ui/react-accordion"

const AccordionTrigger = React.forwardRef&lt;
  React.ElementRef&lt;typeof AccordionPrimitive.Trigger&gt;,
  React.ComponentPropsWithoutRef&lt;typeof AccordionPrimitive.Trigger&gt;
&gt;(({ className, children, ...props }, ref) =&gt; {
  const [isOpen, setIsOpen] = React.useState(false)

  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        ref={ref}
        className={cn("flex flex-1 items-center justify-between ...", className)}
        onClick={() =&gt; setIsOpen(!isOpen)}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "hidden" : "block"
          )}
        /&gt;
        &lt;ChevronUpIcon
          className={cn(
            "h-4 w-4 shrink-0 transition-transform duration-200",
            isOpen ? "block" : "hidden"
          )}
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
})
</code></pre>
<p>Notice the <code>useState(false)</code> tracking whether the accordion is open, and the <code>onClick</code> handler that toggles it. This means the component has to manually keep its own <code>isOpen</code> state in sync with what Radix internally knows about the open/closed state.</p>
<p>Now here's the same component using the <code>base-nova</code> style with Base UI:</p>
<pre><code class="language-tsx">// components/ui/accordion.tsx — base-nova style (the new way)
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"

function AccordionTrigger({ className, children, ...props }: AccordionPrimitive.Trigger.Props) {
  return (
    &lt;AccordionPrimitive.Header className="flex"&gt;
      &lt;AccordionPrimitive.Trigger
        data-slot="accordion-trigger"
        className={cn(
          "group/accordion-trigger relative flex flex-1 items-start ...",
          className
        )}
        {...props}
      &gt;
        {children}
        &lt;ChevronDownIcon
          className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
        /&gt;
        &lt;ChevronUpIcon
          className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
        /&gt;
      &lt;/AccordionPrimitive.Trigger&gt;
    &lt;/AccordionPrimitive.Header&gt;
  )
}
</code></pre>
<p>No <code>useState</code>. No <code>onClick</code>. No <code>isOpen</code> variable. The chevron visibility is controlled entirely by <code>group-aria-expanded/accordion-trigger:hidden</code> — a Tailwind class that reads the <code>aria-expanded</code> attribute Base UI sets automatically on the trigger element.</p>
<p><strong>The lesson here:</strong> in the Radix version, you have two parallel systems: the component's own <code>isOpen</code> state, and the ARIA attributes that the library manages separately for screen readers. These can drift out of sync — for example, if the accordion closes via keyboard navigation, the ARIA state updates correctly but your <code>isOpen</code> state doesn't unless you wire up the right callbacks. In the Base UI version, there is only one system. ARIA state IS the state. Tailwind reads it directly. There's nothing to keep in sync and nothing that can drift.</p>
<p><strong>Lesson:</strong> use the primitive library's ARIA attributes as your source of truth for visual state. If your headless component library already sets <code>aria-expanded</code>, <code>aria-selected</code>, or <code>aria-checked</code>, Tailwind can respond to those directly with <code>aria-*</code> variant classes — no parallel JavaScript state needed.</p>
<p>So when you install shadcn/ui today, choose <code>base-nova</code> over the default Radix style. You get tighter Base UI integration, a smaller peer dependency footprint, and components that are more aligned with where the ecosystem is moving.</p>
<h2 id="heading-lesson-2-tailwind-css-v4-requires-a-mental-model-shift">Lesson 2: Tailwind CSS v4 Requires a Mental Model Shift</h2>
<p>Tailwind CSS v4 moves primary theme configuration out of the JavaScript config file and into CSS. This sounds small. In practice, it changes how you think about the entire theming system.</p>
<p>In Tailwind v3, you'd extend the theme in <code>tailwind.config.js</code>:</p>
<pre><code class="language-js">// OLD — tailwind.config.js (v3)
module.exports = {
  theme: {
    extend: {
      animation: {
        marquee: "marquee 40s linear infinite",
      },
      keyframes: {
        marquee: {
          from: { transform: "translateX(0)" },
          to: { transform: "translateX(calc(-100% - var(--gap)))" },
        },
      },
    },
  },
}
</code></pre>
<p>In Tailwind v4, that same configuration lives in your CSS file instead:</p>
<pre><code class="language-css">/* app/globals.css — Tailwind v4 */
@import "tailwindcss";

@theme inline {
  --animate-marquee: marquee var(--duration) infinite linear;
  --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite;

  @keyframes marquee {
    from { transform: translateX(0); }
    to   { transform: translateX(calc(-100% - var(--gap))); }
  }

  --radius-2xl: calc(var(--radius) * 1.8);
  --radius-3xl: calc(var(--radius) * 2.2);
  --radius-4xl: calc(var(--radius) * 2.6);
}
</code></pre>
<p>The <code>@theme inline</code> block extends Tailwind's design token system. Define <code>--animate-marquee</code> here and you can use <code>className="animate-marquee"</code> anywhere in your components. Tailwind generates the utility class automatically from the CSS variable.</p>
<p>Custom animations, radius scales, and color tokens all live in CSS now. The benefit is that CSS is where styles belong. The config file was always an indirection layer between "what I want my design system to look like" and "where that actually lives." Tailwind v4 removes the indirection.</p>
<p><strong>The friction:</strong> if you start a Tailwind v4 project with a v3 mental model, you'll spend time looking for theme config in the wrong place. Read the v4 migration guide before you start, not after you're confused.</p>
<p><strong>Lesson:</strong> move your mental model of "theme config" from JavaScript to CSS. In Tailwind v4, if you want a custom animation, a new radius scale, or a color token, define it in <code>@theme inline</code> inside <code>globals.css</code>. That's where it belongs, and that's where every developer on your team will find it.</p>
<h2 id="heading-lesson-3-oklch-colors-make-dark-mode-predictable">Lesson 3: OKLCH Colors Make Dark Mode Predictable</h2>
<p>The template uses OKLCH color values throughout, not hex or HSL:</p>
<pre><code class="language-css">:root {
  --background: oklch(1 0 0);        /* white */
  --foreground: oklch(0.145 0 0);    /* near-black */
  --primary: oklch(0.205 0 0);
  --border: oklch(0.922 0 0);
}

.dark {
  --background: oklch(0.145 0 0);    /* near-black */
  --foreground: oklch(0.985 0 0);    /* near-white */
  --primary: oklch(0.922 0 0);
  --border: oklch(1 0 0 / 10%);      /* white at 10% opacity */
}
</code></pre>
<p>OKLCH is a perceptually uniform color space. When you increase the lightness value in OKLCH, the color actually <em>looks</em> lighter to human eyes, consistently. Hex and HSL don't guarantee this. You can increase the <code>L</code> in HSL and get a color that looks the same or even darker depending on the hue.</p>
<p>For dark mode specifically, this matters because you're inverting a whole color system. With HSL, you'll often end up manually tweaking individual color values until contrast ratios look right. With OKLCH, increasing or decreasing the lightness value gives you predictable results across all your tokens.</p>
<p>The dark mode switch itself is <strong>zero JavaScript.</strong> Adding <code>class="dark"</code> to the <code>&lt;html&gt;</code> element swaps every CSS variable. Tailwind reads the updated variables and re-renders every component. There's no context provider and no <code>useTheme</code> hook needed for the CSS layer — just a class toggle on the root element.</p>
<p><strong>Lesson:</strong> swap your color tokens to OKLCH. When defining dark mode values, adjust the first OKLCH parameter (lightness) and the result will look predictably lighter or darker. With hex or HSL you're often guessing; with OKLCH you're reasoning.</p>
<h2 id="heading-lesson-4-page-architecture-flat-beats-clever">Lesson 4: Page Architecture — Flat Beats Clever</h2>
<p>The main page file is 16 lines:</p>
<pre><code class="language-tsx">// app/page.tsx
import Hero from "@/components/Blocks/Hero";
import { LogoCarousel } from "@/components/Blocks/LogoCarousel";
import { FeatureSection } from "@/components/Blocks/FeatureSection";
import { TeamSection } from "@/components/Blocks/TeamSection";
import { TestimonialSection } from "@/components/Blocks/TestimonialSection";
import { PricingSection } from "@/components/Blocks/PricingSection";
import { FaqSection } from "@/components/Blocks/FaqSection";

export default function Home() {
  return (
    &lt;main className="min-h-screen bg-white dark:bg-black"&gt;
      &lt;div className="mx-auto max-w-7xl px-6 pt-40"&gt;
        &lt;Hero /&gt;
        &lt;LogoCarousel /&gt;
        &lt;FeatureSection /&gt;
        &lt;TeamSection /&gt;
        &lt;TestimonialSection /&gt;
        &lt;PricingSection /&gt;
        &lt;FaqSection /&gt;
      &lt;/div&gt;
    &lt;/main&gt;
  );
}
</code></pre>
<p>No dynamic imports, no lazy-loading config, no context providers wrapping everything. Each section is a completely self-contained component in <code>components/Blocks/</code>. None of them import from each other.</p>
<p>This decision came from watching how developers actually use <a href="https://www.shadcndeck.com/templates">shadcn templates</a>. The first thing anyone does after cloning is delete the sections they don't need and reorder the ones they keep. With flat imports, removing the Team section is one deleted line. Reordering sections is moving one line. Adding a new section is creating a file and adding one import.</p>
<p>The alternative (a sections array, a renderer loop, a config file that controls order) sounds sophisticated. In practice, it adds indirection that makes the template harder to understand and slower to customize. Templates should be obvious, not impressive.</p>
<p><strong>Lesson:</strong> in a template context, the simplest architecture is the correct architecture. The developer cloning your template isn't impressed by abstraction. They want to understand the code fast and change it faster.</p>
<h2 id="heading-lesson-5-staggered-animations-without-managing-individual-delays">Lesson 5: Staggered Animations Without Managing Individual Delays</h2>
<p>The Hero section uses entrance animations where each element fades up sequentially: badge first, then heading, then subheading, then CTA. The naïve approach sets a different <code>delay</code> prop on each element manually. The correct approach uses <code>staggerChildren</code>:</p>
<pre><code class="language-tsx">// components/Blocks/Hero.tsx
"use client"
import { motion, type Variants } from "motion/react"

const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: {
    opacity: 1,
    transition: {
      staggerChildren: 0.15,  // each child animates 150ms after the previous
      delayChildren: 0.1,
    },
  },
}

const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: {
    opacity: 1,
    y: 0,
    transition: { duration: 0.5, ease: "easeOut" },
  },
}

const Hero = () =&gt; (
  &lt;motion.div variants={containerVariants} initial="hidden" animate="visible"&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* Badge */}
    &lt;/motion.div&gt;
    &lt;motion.h1 variants={fadeUpVariants}&gt;
      AI Chatbot for Customer Support.
    &lt;/motion.h1&gt;
    &lt;motion.p variants={fadeUpVariants}&gt;
      {/* Subheading */}
    &lt;/motion.p&gt;
    &lt;motion.div variants={fadeUpVariants}&gt;
      {/* CTA */}
    &lt;/motion.div&gt;
  &lt;/motion.div&gt;
)
</code></pre>
<p>The parent defines <code>staggerChildren: 0.15</code>. Every child with <code>variants={fadeUpVariants}</code> automatically inherits a 150ms offset from the previous child. Want to add a new element? Give it <code>variants={fadeUpVariants}</code> and the stagger chain extends automatically. No manually updated delay values.</p>
<p>The Features section uses <strong>scroll-triggered animations</strong> with a different easing:</p>
<pre><code class="language-tsx">// components/Blocks/FeatureSection.tsx
&lt;motion.div
  initial={{ opacity: 0, y: 40 }}
  whileInView={{ opacity: 1, y: 0 }}
  viewport={{ once: true, amount: 0.3 }}
  transition={{
    duration: 0.5,
    delay: index * 0.15,
    ease: [0.22, 1, 0.36, 1],
  }}
&gt;
</code></pre>
<p><code>viewport={{ once: true }}</code> fires the animation once when the element enters the viewport, not on every scroll pass. <code>amount: 0.3</code> starts the animation when 30% of the element is visible, not when the full element is on screen. The cubic bezier <code>[0.22, 1, 0.36, 1]</code> is a fast-out-slow-in curve that feels physical rather than mechanical.</p>
<p><strong>Quick note on the import:</strong> most of the core API is compatible, but <code>motion/react</code> isn't a straight drop-in rename of <code>framer-motion</code>. If you're upgrading an existing project, check the <a href="https://motion.dev/docs/react-upgrade-guide">official migration guide</a> before swapping the import. Layout animations, <code>AnimatePresence</code> behaviour, and some hooks changed.</p>
<p><strong>Lesson:</strong> define animation variants at the parent level and use <code>staggerChildren</code> to orchestrate the sequence. Never set <code>delay</code> manually on individual elements — that creates a brittle list of numbers you have to update every time you add or remove an element. Let the parent handle timing; let children just declare what they animate to.</p>
<h2 id="heading-lesson-6-css-only-infinite-scroll-no-library-needed">Lesson 6: CSS-Only Infinite Scroll — No Library Needed</h2>
<p>The testimonials use a dual-row auto-scrolling marquee. The second row scrolls in reverse. There's no third-party marquee package. It's a small component built entirely on CSS animations defined in Tailwind v4's <code>@theme</code> block.</p>
<pre><code class="language-tsx">// components/ui/marquee.tsx
export function Marquee({
  reverse = false,
  pauseOnHover = false,
  vertical = false,
  children,
  repeat = 4,
  ...props
}) {
  return (
    &lt;div className="group flex gap-(--gap) overflow-hidden [--duration:40s] [--gap:2rem]"&gt;
      {Array(repeat).fill(0).map((_, i) =&gt; (
        &lt;div
          key={i}
          className={cn("flex shrink-0 justify-around gap-(--gap)", {
            "animate-marquee flex-row": !vertical,
            "group-hover:paused": pauseOnHover,
            "[animation-direction:reverse]": reverse,
          })}
        &gt;
          {children}
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  )
}
</code></pre>
<p>The <code>repeat={4}</code> prop renders the children 4 times side by side. As the CSS animation scrolls the container left, the repetitions create a seamless loop. By the time the first set has scrolled off screen, the second set is already in position.</p>
<p><code>group-hover:paused</code> is Tailwind applying <code>animation-play-state: paused</code> when the parent has <code>group</code> class and is hovered. No <code>onMouseEnter</code>/<code>onMouseLeave</code> handlers or state, just pure CSS.</p>
<p>To customize the scroll speed without touching the component source, you override the CSS variable inline:</p>
<pre><code class="language-tsx">&lt;Marquee pauseOnHover className="[--duration:20s]"&gt;
  {items.map(item =&gt; &lt;Card key={item.id} {...item} /&gt;)}
&lt;/Marquee&gt;
</code></pre>
<p><code>[--duration:20s]</code> is a Tailwind arbitrary property. It sets <code>--duration</code> directly on the element, which the animation reads via <code>var(--duration)</code>. Speed customization without a prop, without touching the component.</p>
<p><strong>Lesson:</strong> before reaching for a third-party animation package, check whether a CSS keyframe animation and a couple of Tailwind utilities can do the same job. A marquee, a fade loop, a pulsing skeleton — all of these are achievable with native CSS. Fewer dependencies means fewer breaking changes when the ecosystem moves.</p>
<h2 id="heading-lesson-7-css-subgrid-solves-pricing-card-alignment-natively">Lesson 7: CSS Subgrid Solves Pricing Card Alignment Natively</h2>
<p>The pricing section has three cards: Free, Pro, and Business. Each card has four rows: plan name, price, CTA button, and features list. The features list height varies between plans. Without CSS subgrid, the rows don't align across cards.</p>
<p>The common workaround is <code>min-height</code> on each row, or JavaScript that measures each card and sets explicit heights. Both approaches are fragile. Subgrid solves it in CSS:</p>
<pre><code class="language-tsx">// components/Blocks/PricingSection.tsx
&lt;div className="grid lg:grid-cols-3"&gt;
  {plans.map((plan) =&gt; (
    &lt;div className="p-8 grid grid-rows-subgrid row-span-4 gap-6"&gt;
      &lt;div&gt;{/* Plan name + description */}&lt;/div&gt;
      &lt;div&gt;{/* Price */}&lt;/div&gt;
      &lt;div&gt;{/* CTA button */}&lt;/div&gt;
      &lt;div&gt;{/* Features list */}&lt;/div&gt;
    &lt;/div&gt;
  ))}
&lt;/div&gt;
</code></pre>
<p><code>grid-rows-subgrid</code> tells each card to participate in the parent grid's row tracks rather than creating its own. Each card spans 4 rows (<code>row-span-4</code>). The plan name row, price row, CTA row, and features row align across all three cards (regardless of content height) because they're all on the same row tracks.</p>
<p>Each card's <code>row-span-4</code> reserves four rows in the parent's implicit grid. Because every card spans the same four shared row tracks, their internal rows align automatically even though the parent never declares explicit row heights.</p>
<p>CSS subgrid has been in all modern browsers since late 2023. There's no reason to reach for a JavaScript layout solution when the platform handles it.</p>
<p><strong>Lesson:</strong> when you have a grid of cards where each card has multiple internal rows that need to align across columns, reach for <code>grid-rows-subgrid</code> before reaching for <code>min-height</code> or JavaScript. Define the number of rows each card spans with <code>row-span-N</code>, and the browser handles the rest.</p>
<h2 id="heading-lesson-8-inline-svgs-beat-image-libraries-for-simple-logos">Lesson 8: Inline SVGs Beat Image Libraries for Simple Logos</h2>
<p>The logo carousel renders 12 brand logos: Shopify, Stripe, GitHub, Google, and others. The first instinct is to use a package like <code>react-icons</code> or <code>simple-icons</code>. I went a different direction: inline SVG paths stored as a plain TypeScript object.</p>
<pre><code class="language-tsx">// components/Blocks/LogoCarousel.tsx
const iconMap = {
  stripe: "M13.976 9.15c-2.172-.806...",
  github: "M12 .297c-6.63 0-12...",
  google: "M12.48 10.92v3.28h7.84...",
  // ...
} as const

const SimpleIcon = ({ iconSlug, size = 24 }: { iconSlug: string; size?: number }) =&gt; {
  const iconPath = iconMap[iconSlug as keyof typeof iconMap]
  return (
    &lt;svg role="img" viewBox="0 0 24 24" className="fill-black dark:fill-white"&gt;
      &lt;path d={iconPath} /&gt;
    &lt;/svg&gt;
  )
}
</code></pre>
<p>The <code>fill-black dark:fill-white</code> class means every logo automatically inverts in dark mode: no separate dark mode logo assets, and no conditional rendering based on theme.</p>
<p>The carousel itself duplicates the logo array to create a seamless loop:</p>
<pre><code class="language-tsx">{/* First pass */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`first-${i}`} {...company} /&gt;)}
{/* Second pass — identical, creates the seamless loop */}
{techCompanies.map((company, i) =&gt; &lt;LogoCard key={`second-${i}`} {...company} /&gt;)}
</code></pre>
<p>The CSS animation (<code>animate-logo-scroll</code>) scrolls the container left. When the first pass disappears off the left edge, the second pass is already in position. The loop is seamless.</p>
<p><strong>The trade-off:</strong> maintaining SVG paths manually is fine for a fixed set of logos. If you need a large dynamic icon set, reach for <code>simple-icons</code> or a proper icon library. For 12 brand logos that rarely change, this approach ships zero extra dependencies.</p>
<p><strong>Lesson:</strong> match your tooling to your actual requirements. A logo carousel with a fixed set of brand logos doesn't need an icon library — it needs a TypeScript object and two Tailwind classes. Installing a package to solve a problem you could solve with 10 lines of code adds maintenance surface for no gain.</p>
<h2 id="heading-what-id-do-differently">What I'd Do Differently</h2>
<p>These are the three decisions I'd change if starting the template today.</p>
<h3 id="heading-1-extract-animation-variants-to-a-shared-file">1. Extract Animation Variants to a Shared File</h3>
<p><code>containerVariants</code> and <code>fadeUpVariants</code> are currently defined locally in both <code>Hero.tsx</code> and <code>FeatureSection.tsx</code>. If you want to change the global animation timing (say, reduce duration from 0.5s to 0.3s) you update two files. A shared <code>lib/animations.ts</code> exporting the standard variants would make global timing changes a one-line edit.</p>
<pre><code class="language-ts">// lib/animations.ts
export const fadeUpVariants: Variants = {
  hidden: { opacity: 0, y: 20 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.5, ease: "easeOut" } },
}

export const containerVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { staggerChildren: 0.15, delayChildren: 0.1 } },
}
</code></pre>
<h3 id="heading-2-use-subgrid-in-the-features-section-too">2. Use Subgrid in the Features Section Too</h3>
<p>The Features grid uses a border-based visual separation pattern — borders between cells create the grid appearance. It works, but the hover states have an inconsistency: the gradient hover overlay height varies slightly between cells in the same row because content heights differ. Subgrid would lock those row heights across cards the same way it does in the Pricing section.</p>
<h3 id="heading-3-use-nextfont-more-consistently">3. Use <code>next/font</code> More Consistently</h3>
<p>The layout loads both Geist and Inter font families. Inter is used via <code>--font-sans</code>. Geist is loaded but the <code>geistSans.variable</code> and <code>geistMono.variable</code> are applied to <code>&lt;body&gt;</code> as className strings while Inter drives the actual font rendering through the CSS variable. The result is that Geist is loaded but not actually displayed. Cleaning this up could shave tens of kilobytes from the font payload — worth verifying in Lighthouse or the Network tab before deploying.</p>
<h2 id="heading-summary">Summary</h2>
<p>These are the five things from this build worth taking into your next project:</p>
<ol>
<li><p><strong>shadcn/ui's</strong> <code>base-nova</code> <strong>style</strong> runs on Base UI primitives. ARIA state drives visual state — no parallel JavaScript state needed.</p>
</li>
<li><p><strong>Tailwind v4 moves theme config to CSS.</strong> All theme tokens, custom animations, and radius scales live in CSS via <code>@theme inline</code>. This is the right place for them.</p>
</li>
<li><p><strong>OKLCH gives predictable dark mode contrast.</strong> Adjusting lightness in OKLCH actually changes perceived brightness. Hex and HSL don't guarantee this.</p>
</li>
<li><p><code>staggerChildren</code> <strong>in motion/react</strong> eliminates manually managed animation delays. The parent orchestrates while the children just declare their animation variant.</p>
</li>
<li><p><strong>CSS subgrid (</strong><code>grid-rows-subgrid</code><strong>)</strong> aligns card rows across columns natively. No JavaScript measurement, no fixed heights.</p>
</li>
</ol>
<p>The full template is MIT-licensed and available at <a href="https://github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template">github.com/ShadcnDeck/chatdeck-shadcn-saas-landing-page-template</a>. If it's useful, a star helps others find it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Mastra vs LangChain: Building an AI Agent Pipeline and Analyzing the Data ]]>
                </title>
                <description>
                    <![CDATA[ A week ago, I saw this tweet: I had just shipped SupportMesh, a multi-tenant AI support platform built on Mastra, so I had opinions from production. I liked the .dowhile() loop, the typed step schem ]]>
                </description>
                <link>https://www.freecodecamp.org/news/mastra-vs-langchain-building-an-ai-agent-pipeline-and-analyzing-the-data/</link>
                <guid isPermaLink="false">6a2d04106a8db5c6ef6facf4</guid>
                
                    <category>
                        <![CDATA[ langchain ]]>
                    </category>
                
                    <category>
                        <![CDATA[ MastraAI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mastra ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Convex ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #anthropic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm evaluation ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tavily ]]>
                    </category>
                
                    <category>
                        <![CDATA[ agent-benchmarking ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shola Jegede ]]>
                </dc:creator>
                <pubDate>Sat, 13 Jun 2026 07:17:36 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0e1e81b3-6e39-4532-a12b-e99f600e372f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>A week ago, I saw this tweet:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/fae48919-95f1-4089-969a-98da75006424.png" alt="tweet image: @omaroubari_ asking &quot;has anyone tried mastra and langchain for agent orchestration? which is better?&quot;" style="display: block;" width="1190" height="536" loading="lazy">

<p>I had just shipped SupportMesh, a multi-tenant AI support platform built on Mastra, so I had opinions from production.</p>
<p>I liked the <code>.dowhile()</code> loop, the typed step schemas, and the way <code>createWorkflow</code> kept orchestration logic in one place. What I didn't like was the token overhead: every agent step initialises Mastra's tool loop manager regardless of whether tools are needed, and across a four-step pipeline that adds up to seconds of extra latency and thousands of extra tokens per run.</p>
<p>At the same time, I was looking at LangChain for a separate project I was starting. The approach is completely different from Mastra. Instead of a workflow with typed step contracts, you build a directed graph where nodes are plain async functions and state is a single shared object.</p>
<p>The promise is leaner execution and more explicit control over exactly what goes into each model call, which, given the token overhead I had been seeing with Mastra, was exactly the kind of thing I wanted to understand properly.</p>
<p>So rather than picking one based on documentation and vibes, I built the same pipeline in both and measured everything. The same five-step research and synthesis pipeline, twice, with every piece instrumented: tokens per step, latency per step, the exact prompt sent to Claude at each stage, the raw Tavily search results, and a production-grade evaluation system that actually produces varied scores rather than giving everything a 7.</p>
<p>Then I built a real-time web dashboard on Convex and Next.js so you can run any topic yourself and see every decision both frameworks make to get there.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62cab1b3e62bf98e0fb0a38f/950d7575-7048-42d9-9e00-9a59446c36dd.png" alt="Mastra vs LangChain dashboard showing both pipelines complete side by side, with Mastra scoring 9/10 in 25.2s using 9,846 tokens and LangChain scoring 8/10 in 19.8s using 7,875 tokens on the topic &quot;What is the real cost of running AI agents in production?&quot;" style="display: block;" width="3456" height="2166" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-tools-were-using">The Tools We're Using</a></p>
</li>
<li><p><a href="#heading-why-this-pipeline">Why This Pipeline</a></p>
</li>
<li><p><a href="#heading-the-project-structure">The Project Structure</a></p>
</li>
<li><p><a href="#heading-building-the-mastra-pipeline">Building the Mastra Pipeline</a></p>
<ul>
<li><p><a href="#heading-the-search-tool">The Search Tool</a></p>
</li>
<li><p><a href="#heading-the-agents">The Agents</a></p>
</li>
<li><p><a href="#heading-the-writecriticstep-why-write-and-critic-live-in-the-same-step">The writeCriticStep: Why Write and Critic Live in the Same Step</a></p>
</li>
<li><p><a href="#heading-token-capture">Token Capture</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-the-langchain-pipeline">Building the LangChain Pipeline</a></p>
<ul>
<li><p><a href="#heading-the-state-annotation">The State Annotation</a></p>
</li>
<li><p><a href="#heading-the-factory-pattern">The Factory Pattern</a></p>
</li>
<li><p><a href="#heading-the-graph-and-the-node-naming-collision">The Graph and the Node Naming Collision</a></p>
</li>
<li><p><a href="#heading-the-retry-wrapper">The Retry Wrapper</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-critic-that-gave-everything-a-7-out-of-10">The Critic That Gave Everything a 7 out of 10</a></p>
<ul>
<li><p><a href="#heading-what-production-grade-evaluation-actually-looks-like">What Production-Grade Evaluation Actually Looks Like</a></p>
</li>
<li><p><a href="#heading-extracting-json-from-chain-of-thought-output">Extracting JSON from Chain-of-Thought Output</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-the-evaluation-bias-i-almost-shipped">The Evaluation Bias I Almost Shipped</a></p>
</li>
<li><p><a href="#heading-the-real-time-dashboard">The Real-Time Dashboard</a></p>
<ul>
<li><p><a href="#heading-the-convex-schema">The Convex Schema</a></p>
</li>
<li><p><a href="#heading-the-fire-and-forget-pattern">The Fire-and-Forget Pattern</a></p>
</li>
<li><p><a href="#heading-subscribing-to-live-updates">Subscribing to Live Updates</a></p>
</li>
<li><p><a href="#heading-deduplicating-steps-after-retries">Deduplicating Steps After Retries</a></p>
</li>
<li><p><a href="#heading-the-live-log-auto-scroll">The Live Log Auto-Scroll</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-the-data-actually-shows">What the Data Actually Shows</a></p>
</li>
<li><p><a href="#heading-try-it-yourself">Try it Yourself</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow along and run this yourself, you'll need four things:</p>
<ul>
<li><p><strong>Node.js 22 or later</strong>: the pipeline packages use modern TypeScript features that require a recent Node version.</p>
</li>
<li><p><strong>An Anthropic API key</strong>: you can get one at <a href="http://console.anthropic.com">console.anthropic.com</a>. Claude Haiku 4.5 is cheap enough that running this benchmark a dozen times costs a few cents.</p>
</li>
<li><p><strong>A Tavily API key</strong>: you can get one at <a href="http://tavily.com">tavily.com</a>. The free tier gives you 1,000 searches a month, which is more than enough to run this benchmark repeatedly.</p>
</li>
<li><p><strong>A Convex account</strong>: you can sign up at <a href="http://convex.dev">convex.dev</a>. The free tier covers everything here.</p>
</li>
</ul>
<p>Once you have those, the setup section at the end of this article walks through exactly where each one goes.</p>
<h2 id="heading-the-tools-were-using">The Tools We're Using</h2>
<p>Before getting into the build, it helps to know what each tool I used is and why it's in the stack. If you're already familiar with all of these, you can skip ahead.</p>
<p><a href="https://mastra.ai">Mastra</a> is a TypeScript-first framework for building AI-powered applications and agents. The idea is that you define individual steps with typed input and output schemas, chain them into a workflow, and the framework handles the data flow between them. It has opinions about structure, which is either a feature or a constraint depending on what you're building.</p>
<p><a href="https://www.langchain.com"><strong>LangChain</strong></a> is one of the most widely used frameworks for building LLM applications. It started in Python and has a TypeScript version.</p>
<p>For agent orchestration specifically, the relevant piece is <strong>LangGraph</strong>, which is LangChain's graph-based execution layer. Instead of a workflow with typed step contracts, you build a directed graph: nodes are plain async functions, state is a single shared object that every node reads from and writes to, and the flow between nodes is controlled by edges.</p>
<p><a href="https://www.anthropic.com/claude/haiku"><strong>Claude Haiku 4.5</strong></a> is the model powering all agents. It is Anthropic's fastest and most cost-efficient model, which makes it the right choice here.</p>
<p><a href="https://www.tavily.com"><strong>Tavily</strong></a> is a web search API built specifically for AI agents. Unlike a general search API, it returns structured results with relevance scores and content snippets that are ready to pass directly into a model prompt. The free tier is generous enough to run this benchmark without paying anything.</p>
<p>I used it here because it has a clean TypeScript SDK, it works in both Mastra tools and plain LangChain nodes without any adapter layer, and the search results are consistent enough that both pipelines are working with the same quality of input.</p>
<p><a href="https://www.convex.dev"><strong>Convex</strong></a> is a real-time database with a React hook, <code>useQuery</code>, that automatically re-renders your component whenever the underlying data changes. No polling, no WebSocket setup, and no manual state syncing. When both pipelines are writing step data as they execute, the run page just updates.</p>
<p><a href="https://nextjs.org"><strong>Next.js</strong></a> is the web framework for the dashboard. App Router, API routes for the pipeline execution, and server components where they make sense.</p>
<h2 id="heading-why-this-pipeline">Why This Pipeline</h2>
<p>A simple comparison wouldn't tell me anything useful, because the difference between frameworks only shows up when you actually push them.</p>
<p>The pipeline I landed on has five steps:</p>
<pre><code class="language-plaintext">Topic
  ↓
1. RESEARCH   (Tavily web search, 5 results with relevance scores)
2. ANALYSIS   (Extract 5 key findings, 3 themes, 1 central argument)
3. WRITE      (Draft a structured ~400-word report)
4. CRITIC     (Score the draft, provide dimension-level feedback)
5. LOOP       (Revise if score below 7, output if passes or 3 iterations used)
</code></pre>
<p>I chose each step because it stresses the frameworks differently.</p>
<p>The research step requires a real tool call, which is where Mastra's Agent abstraction does its heaviest work. The analysis step needs structured JSON output, which tests how each framework enforces output shape. The write step has strict content requirements enforced purely through prompt engineering. The critic needs to do chain-of-thought reasoning and produce structured JSON at the same time, which turns out to be harder than it sounds. And the revision loop tests perhaps the most fundamental difference between the two frameworks: how each one expresses conditional iteration.</p>
<p>Taken together, this covers most of what you would actually build with an agent framework in production: tool calls, structured output, multi-step orchestration, quality evaluation, and feedback loops.</p>
<h2 id="heading-the-project-structure">The Project Structure</h2>
<p>Everything lives in a single monorepo using npm workspaces, which means all packages share a single <code>node_modules</code> at the root and can import each other directly:</p>
<pre><code class="language-plaintext">mastra-vs-langchain/
├── packages/
│   ├── mastra-pipeline/          # Mastra implementation
│   ├── langchain-pipeline/       # LangChain/LangGraph implementation
│   ├── web/                      # Next.js 16 App Router dashboard
│   └── shared/                   # Shared TypeScript types
├── convex/                       # Real-time backend
└── package.json                  # Workspace root
</code></pre>
<p>The most important piece of the shared package is the <code>PipelineCallbacks</code> interface, which both pipeline implementations must satisfy. This is the contract that lets the dashboard receive live events from either framework: step starts, step completions, token counts, and Tavily results, all without knowing anything about Mastra or LangChain specifically:</p>
<pre><code class="language-typescript">// packages/shared/src/types.ts
export interface PipelineCallbacks {
  onPipelineStart: () =&gt; Promise&lt;string&gt;;
  onPipelineComplete: (id: string, data: PipelineCompleteData) =&gt; Promise&lt;void&gt;;
  onPipelineError: (id: string, error: string) =&gt; Promise&lt;void&gt;;
  step: {
    onStepStart: (stepName: string, iteration: number, input: string) =&gt; Promise&lt;string&gt;;
    onStepComplete: (stepId: string, data: StepCompleteData) =&gt; Promise&lt;void&gt;;
    onStepError: (stepId: string, error: string) =&gt; Promise&lt;void&gt;;
  };
}
</code></pre>
<p>Every Convex write, live log entry, and token count flows through this interface. Adding a new framework to the benchmark in the future means implementing this interface and plugging it into the API route, and nothing else needs to change.</p>
<h2 id="heading-building-the-mastra-pipeline">Building the Mastra Pipeline</h2>
<p>If you haven't used Mastra before, the core mental model is this: you define individual steps with typed input and output schemas, chain them together into a workflow, and Mastra manages the data flow between them.</p>
<p>The framework is opinionated about structure but that structure gives you type safety across the entire pipeline and makes the orchestration logic easy to read.</p>
<h3 id="heading-the-search-tool">The Search Tool</h3>
<p>Mastra tools are created with <code>createTool</code>, which takes a Zod input schema and an <code>execute</code> function that receives the validated input directly:</p>
<pre><code class="language-typescript">// packages/mastra-pipeline/src/tools/search.ts
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { tavily } from "@tavily/core";

const client = tavily({ apiKey: process.env.TAVILY_API_KEY! });

export let lastTavilyCapture: { query: string; results: any[] } = {
  query: "",
  results: [],
};

export function resetTavilyCapture() {
  lastTavilyCapture = { query: "", results: [] };
}

export const searchTool = createTool({
  id: "web-search",
  description: "Search the web for information on a topic",
  inputSchema: z.object({ query: z.string() }),
  execute: async ({ query }) =&gt; {
    lastTavilyCapture = { query, results: [] };
    const results = await client.search(query, {
      maxResults: 5,
      searchDepth: "basic",
    });
    lastTavilyCapture.results = results.results;
    return { results: results.results };
  },
});
</code></pre>
<p>The <code>lastTavilyCapture</code> module-level variable is a deliberate workaround for a real constraint. Mastra's tool execution happens inside the agent's internal tool loop, which sits one layer below the workflow step.</p>
<p>I needed to capture the Tavily query and results for the dashboard so users can see the actual URLs and relevance scores for each run, but threading a callback through the agent's tool execution context would have required patching Mastra internals. Capturing at module scope and calling <code>resetTavilyCapture()</code> at the start of each research step is less elegant but completely reliable, and it prevents stale data from a previous run bleeding into the current one.</p>
<h3 id="heading-the-agents">The Agents</h3>
<p>Each step in the Mastra pipeline runs as a separate <code>Agent</code> instance. One thing worth knowing if you're just getting started with Mastra is that it requires an explicit <code>id</code> field alongside <code>name</code>. If you skip it, TypeScript throws a confusing error about missing required fields that doesn't point at the actual problem:</p>
<pre><code class="language-typescript">// packages/mastra-pipeline/src/agents/researcher.ts
export const researcherAgent = new Agent({
  name: "Researcher",
  id: "researcher",           // required in v1.41 - easy to miss
  instructions: `You are a research agent. When given a topic, use the 
  web-search tool to find 5 relevant results. Return ALL the raw search 
  results including titles, URLs, and content snippets as a formatted string.`,
  model: anthropic("claude-haiku-4-5"),
  tools: { searchTool },
});
</code></pre>
<p>The writer agent carries all its content requirements directly in the instructions rather than in a separate validation layer. This keeps the constraints in one visible place, which matters when the critic is giving feedback about which specific requirements the draft violated:</p>
<pre><code class="language-typescript">// packages/mastra-pipeline/src/agents/writer.ts
export const writerAgent = new Agent({
  name: "Writer",
  id: "writer",
  instructions: `You are a research analyst writing for a technical audience.

STRICT REQUIREMENTS:
- Opening sentence must state a specific finding from the research.
  Never open with "X is increasingly important."
- Every paragraph makes exactly one argument. State it first.
  Support it with specific evidence.
- Name specific tools, frameworks, companies, numbers, and dates.
- Conclusion must make a specific recommendation or prediction.
  It must not restate the introduction.
- Target length: 350-450 words.

FORBIDDEN PHRASES:
"it is important to note", "it is worth noting",
"organizations must consider", "in conclusion", "in summary",
"as we look to the future", "rapidly evolving landscape",
any sentence equally true if you replaced the topic`,
  model: anthropic("claude-haiku-4-5"),
});
</code></pre>
<h3 id="heading-the-writecriticstep-why-write-and-critic-live-in-the-same-step">The writeCriticStep: Why Write and Critic Live in the Same Step</h3>
<p>While implementing Mastra, I made one architectural decision here that diverges from most tutorials, and it's worth understanding why.</p>
<p>Mastra's <code>.dowhile()</code> construct loops a single step until a condition is met. That's clean when you have one thing to repeat, but the revision loop needs two things: a write step followed by a critic step. You can either combine them into one step, or build a nested workflow where the inner workflow contains both steps.</p>
<p>A nested workflow adds a layer of complexity that doesn't buy you anything in this case, so the write and critic phases live together in <code>writeCriticStep</code>. The step runs the writer first, then immediately runs the critic on the draft, and returns a combined output that includes both the draft and the score:</p>
<pre><code class="language-typescript">const writeCriticStep = createStep({
  id: "write-critic",
  inputSchema: z.object({
    topic: z.string(),
    research: z.string(),
    analysis: z.string(),
    keyFindings: z.array(z.string()),
    mainThemes: z.array(z.string()),
    centralArgument: z.string(),
    draft: z.string().optional(),       // populated after first iteration
    score: z.number().optional(),       // populated after first iteration
    feedback: z.string().optional(),    // populated after first iteration
    iterations: z.number().optional(),
  }),
  outputSchema: z.object({
    // ... all input fields plus draft, score, feedback, iterations
  }),
  execute: async ({ inputData }) =&gt; {
    const iteration = (inputData.iterations ?? 0) + 1;

    // WRITE phase
    let writerPrompt = `Topic: "\({inputData.topic}"\n\nResearch:\n\){inputData.research}\n\nAnalysis:\n${inputData.analysis}`;
    if (inputData.feedback &amp;&amp; inputData.draft) {
      // On revisions, the writer sees its previous attempt and the specific feedback
      writerPrompt += `\n\nPrevious draft:\n\({inputData.draft}\n\nFeedback:\n\){inputData.feedback}`;
    }

    const writeStepId = await callbacks.step.onStepStart("write", iteration, writerPrompt.slice(0, 500));
    const writerResult = await writerAgent.generate(writerPrompt);
    const draft = writerResult.text;
    await callbacks.step.onStepComplete(writeStepId, { output: draft, /* token data */ });

    // CRITIC phase: runs immediately after write, on the same draft
    const criticPrompt = `RESEARCH:\n\({inputData.research}\n\nANALYSIS:\n\){inputData.analysis}\n\nDRAFT:\n${draft}`;
    const criticStepId = await callbacks.step.onStepStart("critic", iteration, draft.slice(0, 500));
    const criticResult = await criticAgent.generate(criticPrompt);
    const parsed = extractJson(criticResult.text);
    const score = parsed?.score ?? 4;
    const feedback = parsed?.feedback ?? "Score parsing failed";
    await callbacks.step.onStepComplete(criticStepId, { output: criticResult.text, criticScore: score });

    return { ...inputData, draft, score, feedback, iterations: iteration };
  },
});
</code></pre>
<p>The <code>.dowhile()</code> condition then checks whether to loop again. It receives the output of the previous <code>writeCriticStep</code> as <code>inputData</code>, so it can read the score directly:</p>
<pre><code class="language-typescript">const workflow = createWorkflow({
  id: `research-pipeline-${Date.now()}`,  // timestamp prevents conflicts on concurrent runs
  inputSchema: z.object({ topic: z.string() }),
})
  .then(researchStep)
  .then(analysisStep)
  .dowhile(
    writeCriticStep,
    async ({ inputData }) =&gt; inputData.score &lt; 7 &amp;&amp; inputData.iterations &lt; 3
  )
  .commit();
</code></pre>
<p>The <code>Date.now()</code> in the workflow ID is there because Mastra workflows with a static ID conflict when two runs start concurrently. Adding the timestamp gives each run a unique workflow instance.</p>
<h3 id="heading-token-capture">Token Capture</h3>
<p>After any <code>agent.generate()</code> call, usage data lives on the result object. The shape changes between Mastra versions, so checking both possible field names is the safe approach:</p>
<pre><code class="language-typescript">const inputTokens =
  (result as any).usage?.promptTokens ??
  (result as any).usage?.inputTokens ??
  0;
const outputTokens =
  (result as any).usage?.completionTokens ??
  (result as any).usage?.outputTokens ??
  0;
</code></pre>
<h2 id="heading-building-the-langchain-pipeline">Building the LangChain Pipeline</h2>
<p>LangChain/LangGraph solves the same problem with a fundamentally different mental model.</p>
<p>Where Mastra gives you a workflow with explicitly typed step contracts, LangGraph gives you a directed graph. Nodes are plain async functions, state is a single shared mutable object that flows through the graph, and the execution order is determined by edges rather than a chain of <code>.then()</code> calls.</p>
<h3 id="heading-the-state-annotation">The State Annotation</h3>
<p>Before writing any nodes, you define the shape of the shared state using <code>Annotation.Root</code>. Every node in the graph reads from and writes to this object:</p>
<pre><code class="language-typescript">// packages/langchain-pipeline/src/graph/state.ts
export const PipelineState = Annotation.Root({
  topic: Annotation&lt;string&gt;(),
  research: Annotation&lt;string&gt;(),
  analysis: Annotation&lt;string&gt;(),
  draft: Annotation&lt;string&gt;(),
  score: Annotation&lt;number&gt;(),
  feedback: Annotation&lt;string&gt;(),
  iterations: Annotation&lt;number&gt;(),
  finalReport: Annotation&lt;string&gt;(),
  criticDimensions: Annotation&lt;object&gt;(),
});
</code></pre>
<p>Coming from Mastra, the difference in how data flows is significant. In Mastra, each step declares what it receives and returns, and the framework enforces that contract at the TypeScript level.</p>
<p>In LangGraph, any node can read or write any field in the shared state. The structure comes from the graph topology rather than the type system, which means Mastra catches data flow bugs at compile time while LangGraph makes it easier to add new fields to the pipeline without touching every step's schema.</p>
<h3 id="heading-the-factory-pattern">The Factory Pattern</h3>
<p>LangGraph nodes are plain async functions, which is exactly what makes them lean: no framework overhead, no initialization, just your code calling the model.</p>
<p>The challenge is that I needed to thread callbacks and a shared token accumulator through all four nodes, and plain functions have no built-in mechanism for that.</p>
<p>The solution is a factory function that creates all four nodes as closures over the shared state:</p>
<pre><code class="language-typescript">// packages/langchain-pipeline/src/graph/nodes.ts
export function createNodes(
  callbacks: PipelineCallbacks,
  acc: { inputTokens: number; outputTokens: number }
) {
  const tavilyClient = tavily({ apiKey: process.env.TAVILY_API_KEY! });

  async function researchNode(state: PipelineStateType): Promise&lt;Partial&lt;PipelineStateType&gt;&gt; {
    const stepId = await callbacks.step.onStepStart("research", 1, state.topic);
    const results = await tavilyClient.search(state.topic, { maxResults: 5, searchDepth: "basic" });
    const research = results.results
      .map((r, i) =&gt; `[\({i + 1}] \){r.title}\nURL: \({r.url}\nContent: \){r.content}`)
      .join("\n\n");
    await callbacks.step.onStepComplete(stepId, {
      output: research,
      promptSent: state.topic,
      timeMs: elapsed,
      inputTokens: 0,      // research step uses Tavily, not an LLM
      outputTokens: 0,
      model: "tavily-search",
      tavilyQuery: state.topic,
      tavilyResults: JSON.stringify(results.results),
    });
    return { research };
  }

  // analysisNode, writeNode, criticNode follow the same pattern

  return { researchNode, analysisNode, writeNode, criticNode };
}
</code></pre>
<p>Notice the research node returns 0 tokens because it calls Tavily directly without any LLM involvement, which is one of the key differences that shows up in the benchmark data. Each subsequent node accumulates tokens directly into the shared <code>acc</code> object:</p>
<pre><code class="language-typescript">const inputTokens = response.usage_metadata?.input_tokens ?? 0;
const outputTokens = response.usage_metadata?.output_tokens ?? 0;
acc.inputTokens += inputTokens;
acc.outputTokens += outputTokens;
</code></pre>
<p>LangChain's <code>ChatAnthropic</code> puts usage on <code>response.usage_metadata</code>, which is cleanly typed and requires no casting.</p>
<h3 id="heading-the-graph-and-the-node-naming-collision">The Graph and the Node Naming Collision</h3>
<p>One thing LangGraph enforces that's easy to miss: node names can't conflict with state annotation keys. Naming a node <code>"research"</code> throws a runtime error because <code>state.research</code> already exists as a state channel, and the error message doesn't explain why. Renaming to <code>"researcher"</code> and <code>"analyzer"</code> fixes it:</p>
<pre><code class="language-typescript">export const pipeline = new StateGraph(PipelineState)
  .addNode("researcher", researchNode)   // NOT "research": conflicts with state.research
  .addNode("analyzer", analysisNode)     // NOT "analysis": conflicts with state.analysis
  .addNode("write", writeNode)
  .addNode("critic", criticNode)
  .addEdge(START, "researcher")
  .addEdge("researcher", "analyzer")
  .addEdge("analyzer", "write")
  .addEdge("write", "critic")
  .addConditionalEdges("critic", shouldRevise, {
    revise: "write",
    end: END,
  })
  .compile();
</code></pre>
<p>The revision loop in LangGraph is expressed as a conditional edge with a routing function:</p>
<pre><code class="language-typescript">function shouldRevise(state: PipelineStateType): string {
  if (state.score &gt;= 7 || state.iterations &gt;= 3) return "end";
  return "revise";
}
</code></pre>
<p>After every critic execution, <code>shouldRevise</code> runs and returns either <code>"revise"</code> to loop back to the write node or <code>"end"</code> to exit the graph. That's the state machine equivalent of Mastra's <code>.dowhile()</code>: the same conditional logic expressed as graph routing rather than as a named loop construct.</p>
<h3 id="heading-the-retry-wrapper">The Retry Wrapper</h3>
<p>Both frameworks hit intermittent TLS session reuse errors when making concurrent HTTPS requests. The error look like this: <code>SSL routines:tls_get_more_records:decryption failed or bad record mac</code>. A retry wrapper with linear backoff handles it:</p>
<pre><code class="language-typescript">async function retryOnFetch&lt;T&gt;(fn: () =&gt; Promise&lt;T&gt;, retries = 3): Promise&lt;T&gt; {
  for (let i = 0; i &lt;= retries; i++) {
    try {
      return await fn();
    } catch (e: any) {
      const shouldRetry =
        e?.message?.includes("fetch") ||
        e?.message === "fetch failed" ||
        e?.message?.includes("SSL") ||
        e?.message?.includes("ECONNRESET") ||
        e?.message?.includes("other side closed") ||
        e?.cause?.code === "ECONNRESET";
      if (i &lt; retries &amp;&amp; shouldRetry) {
        await new Promise((r) =&gt; setTimeout(r, 1000 * (i + 1)));
        continue;
      }
      throw e;
    }
  }
  throw new Error("unreachable");
}
</code></pre>
<p>Every <code>llm.invoke()</code> call in the LangChain nodes is wrapped in this. In the web app's API route, there's an equivalent <code>retryMutation</code> wrapper around every Convex call for the same reason.</p>
<h2 id="heading-the-critic-that-gave-everything-a-7-out-of-10">The Critic That Gave Everything a 7 out of 10</h2>
<p>With both pipelines running, I tested a few topics. Every score came back 7 out of 10, regardless of topic, framework, or iteration.</p>
<p>This is actually a well-documented failure mode called LLM-as-judge bias. When you ask a language model to assign a score from 1 to 10 without giving it structured criteria and explicit anchors for each score level, it gravitates toward 7. It's the socially safe answer: high enough to signal quality, low enough to seem fair, and it requires no real justification. The model has no incentive to discriminate because nothing in the prompt forces it to.</p>
<p>My original critic was this:</p>
<pre><code class="language-plaintext">You are a critical editor. Score the draft 1-10 on accuracy,
clarity, and depth. Return { score, feedback }.
</code></pre>
<p>That single sentence was the entire prompt, so obviously it gave everything a 7.</p>
<h3 id="heading-what-production-grade-evaluation-actually-looks-like">What Production-Grade Evaluation Actually Looks Like</h3>
<p>The solution I used comes from the <a href="https://arxiv.org/abs/2303.16634">G-Eval paper</a>, which is also the approach behind tools like DeepEval and RAGAS. The key insight is that you need three things working together: the judge must reason step-by-step before assigning any score, the dimensions being scored must be independent of each other, and each score level must have an explicit description of what it means, not just "1 is bad, 10 is perfect."</p>
<p>So, I rebuilt the critic around six mandatory steps that must all complete before a number is produced:</p>
<ol>
<li><p><strong>Claim audit</strong>: every factual claim in the report gets classified as GROUNDED (supported by a specific search result), INFERRED (reasonable extension of the research), UNSUPPORTED (no basis in the results), or HALLUCINATED (contradicts the results).</p>
</li>
<li><p><strong>Specificity audit</strong>: every generic sentence and every forbidden phrase gets flagged explicitly.</p>
</li>
<li><p><strong>Insight audit</strong>: checks whether the conclusion actually adds something beyond restating the introduction.</p>
</li>
<li><p><strong>Counterfactual check</strong>: the judge must name at least one specific belief a reader would hold after reading this that they wouldn't hold from just the topic title alone. If it can't identify one, the insight score can't exceed 6.</p>
</li>
<li><p><strong>Dimension scoring</strong>: three independent scores with explicit anchors for each level.</p>
</li>
<li><p><strong>Floor rule</strong>: if any single dimension scores 4 or below, the final score can't exceed 6 regardless of the other dimensions.</p>
</li>
</ol>
<p>The floor rule deserves a specific explanation because it addresses a real failure mode: without it, a report that hallucinates facts could score 2 on source fidelity but still end up with a passing score on the weighted average if specificity and insight are high enough. A critical failure in one dimension should disqualify the report, not get diluted.</p>
<p>This is the full critic prompt, which is shared between Mastra and LangChain via a constant in <code>nodes.ts</code>:</p>
<pre><code class="language-typescript">const CRITIC_INSTRUCTIONS = `You are a senior research editor.
Catch the specific ways AI-generated reports fail.

STEP 1: CLAIM AUDIT
Classify every claim: [GROUNDED] [INFERRED] [UNSUPPORTED] [HALLUCINATED]

STEP 2: SPECIFICITY AUDIT
List sentences that are generic, use forbidden phrases, or make no
falsifiable claims. Forbidden phrases: "it is important to note",
"organizations must consider", "rapidly evolving", "as we look to the future"

STEP 3: INSIGHT AUDIT
Does the conclusion add anything not already in the introduction?

STEP 3.5: COUNTERFACTUAL CHECK
Name one specific belief a reader holds after reading this that they
would not hold from just the topic title. If you cannot identify one,
insight cannot exceed 6.

STEP 4: SCORE EACH DIMENSION

SOURCE FIDELITY (40% weight):
5-6: Claims accurate but traced to general topic knowledge, not these specific results
7:   Most claims traceable, at least one source cited by name
8:   All major claims grounded, two or more named sources with specific details
9-10: Every claim traces to a named source, at least one statistic used

SPECIFICITY (30% weight):
5-6: Some specific claims but generic analysis between paragraphs
7:   Mostly specific, minor filler remains
8:   Every paragraph falsifiable, named entities throughout
9-10: Zero sentences survive if you swap the topic

INSIGHT (30% weight):
5-6: Some synthesis but conclusion could have been written before reading
7:   Conclusion makes a recommendation that follows from the evidence
8:   Identifies a tradeoff the reader has not considered
9-10: A senior engineer would reconsider an architectural decision after reading this

STEP 5: FLOOR RULE
If any dimension scores 4 or below, the final score cannot exceed 6.

STEP 6: CALCULATE
finalScore = round((fidelity * 0.40) + (specificity * 0.30) + (insight * 0.30))

Respond ONLY with this JSON:
{
  "fidelity": &lt;1-10&gt;,
  "fidelityReasoning": "&lt;one sentence&gt;",
  "specificity": &lt;1-10&gt;,
  "specificityReasoning": "&lt;one sentence&gt;",
  "insight": &lt;1-10&gt;,
  "insightReasoning": "&lt;one sentence&gt;",
  "score": &lt;weighted final&gt;,
  "feedback": "&lt;surgical: quote the specific sentence that caused the
  lowest-scoring dimension to fail, then state exactly what needs to change&gt;"
}`;
</code></pre>
<h3 id="heading-extracting-json-from-chain-of-thought-output">Extracting JSON from Chain-of-Thought Output</h3>
<p>Because the critic now writes several paragraphs of reasoning before producing the JSON, <code>JSON.parse(result.text)</code> throws because the response isn't pure JSON anymore. Before I caught this and fixed it, the fallback value of <code>4</code> was returned silently on every parse failure, which meant every loop ran the full three iterations on every topic.</p>
<p>The fix scans the text for the last valid JSON object, working backwards through any matches because the JSON block always appears at the end after the reasoning:</p>
<pre><code class="language-typescript">function extractJson(text: string): any {
  try { return JSON.parse(text.trim()); } catch {}

  const matches = text.match(/\{[\s\S]*\}/g);
  if (matches) {
    for (let i = matches.length - 1; i &gt;= 0; i--) {
      try { return JSON.parse(matches[i]); } catch {}
    }
  }

  const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
  if (fenced) {
    try { return JSON.parse(fenced[1].trim()); } catch {}
  }

  return null;
}
</code></pre>
<h2 id="heading-the-evaluation-bias-i-almost-shipped">The Evaluation Bias I Almost Shipped</h2>
<p>After the critic rebuild, things were working properly: first drafts scoring 4-6, the revision loop triggering, revisions actually improving on the previous attempt.</p>
<p>But a clear pattern emerged across technology topics: Mastra consistently scoring 8-9, and LangChain consistently scoring 6-7, on every single topic.</p>
<p>Looking at what the critic was actually rewarding revealed the problem. Source Fidelity carries 40% of the final score, and it rewards reports that cite specific named sources from the Tavily results. Mastra's reports were full of phrases like "according to Kore.ai's analysis" and "the ArXiv paper on orchestrated multi-agent systems identifies." LangChain's reports made the same points but without attributing them to specific sources.</p>
<p>The cause was how context flowed through each pipeline. Mastra's Agent class carries the full Tavily content (titles, URLs, content snippets) in its conversation history through the tool loop. By the time the writer agent runs, all of that source material is available in context.</p>
<p>The LangChain write node, on the other hand, only received <code>state.analysis</code>, which is the structured JSON extracted from the research: key findings, themes, and a central argument. By the time that JSON was produced, the specific source details had already been abstracted away. The writer had the conclusions but not the citations.</p>
<p>Both pipelines were correctly implemented according to each framework's idioms, but I had given them unequal inputs without realising it. The evaluation system was rewarding one framework for having more context rather than for producing a better report, and the consistent score gap across every technology topic was the signal: a genuine quality difference would vary by topic and draft, but a structural gap shows up the same way every time.</p>
<p>The fix was one change in the LangChain write node: pass <code>state.research</code> (the raw Tavily results) alongside <code>state.analysis</code>:</p>
<pre><code class="language-typescript">async function writeNode(state: PipelineStateType): Promise&lt;Partial&lt;PipelineStateType&gt;&gt; {
  const prompt = `You are a research analyst writing for a technical audience.

RESEARCH (raw search results -- cite specific sources by name):
${state.research}

ANALYSIS:
${state.analysis}
\({state.feedback ? `\nCRITIC FEEDBACK FROM PREVIOUS DRAFT:\n\){state.feedback}` : ""}

${WRITER_INSTRUCTIONS}

Return ONLY the report text.`;

  const response = await retryOnFetch(() =&gt; llm.invoke(prompt));
  return { draft: response.content as string, iterations: (state.iterations ?? 0) + 1 };
}
</code></pre>
<p>With both writers receiving identical source material, quality scores now reflect actual writing quality. If your evaluation system consistently favours one option across many runs, the first thing to check is whether both options have equal inputs. A structural gap produces consistent results, while a genuine quality difference varies by topic and draft quality.</p>
<h2 id="heading-the-real-time-dashboard">The Real-Time Dashboard</h2>
<p>Running pipelines in the terminal works for your own comparisons, but it doesn't scale to a benchmark that other people can use. The dashboard needed both pipelines running in parallel, every step visible as it executes, the full prompt and response expandable per step, Tavily results with relevance score bars, token counts, a live scrolling log, and everything saved and browsable by category.</p>
<h3 id="heading-the-convex-schema">The Convex Schema</h3>
<p>Convex was chosen specifically for real-time capabilities: its <code>useQuery</code> hook in React subscribes to database queries and automatically re-renders when the underlying data changes, without any polling or websocket management on your end.</p>
<p>The schema stores every run at three levels of granularity:</p>
<pre><code class="language-typescript">steps: defineTable({
  runId: v.id("runs"),
  pipelineResultId: v.id("pipelineResults"),
  framework: v.union(v.literal("mastra"), v.literal("langchain")),
  stepName: v.union(
    v.literal("research"), v.literal("analysis"),
    v.literal("write"), v.literal("critic")
  ),
  iterationNumber: v.number(),
  status: v.union(v.literal("running"), v.literal("complete"), v.literal("error")),
  promptSent: v.optional(v.string()),
  output: v.optional(v.string()),
  timeMs: v.optional(v.number()),
  inputTokens: v.optional(v.number()),
  outputTokens: v.optional(v.number()),
  model: v.optional(v.string()),
  tavilyQuery: v.optional(v.string()),
  tavilyResults: v.optional(v.string()),
  criticScore: v.optional(v.number()),
  criticFeedback: v.optional(v.string()),
  criticDimensions: v.optional(v.object({
    fidelity: v.number(),
    specificity: v.number(),
    insight: v.number(),
    fidelityReasoning: v.string(),
    specificityReasoning: v.string(),
    insightReasoning: v.string(),
  })),
}).index("by_pipeline_result", ["pipelineResultId"]),
</code></pre>
<p>The <code>criticDimensions</code> field stores the full G-Eval breakdown so the dashboard can render individual dimension scores with colored bars and the per-dimension reasoning text.</p>
<h3 id="heading-the-fire-and-forget-pattern">The Fire-and-Forget Pattern</h3>
<p>The most important decision in the Next.js API route is returning the <code>runId</code> before either pipeline finishes. If you await both pipelines first, the browser sits waiting 30-60 seconds before it can even navigate to the run page, and the whole point of real-time updates is gone.</p>
<pre><code class="language-typescript">const activeTasks = new Map&lt;string, Promise&lt;void&gt;&gt;();

export async function POST(req: NextRequest) {
  const { topic, category } = await req.json();

  // Create the Convex records synchronously (these are fast)
  const runId = await retryMutation(() =&gt;
    fetchMutation(api.runs.createRun, { topic, category, status: "running" })
  );
  const mastraResultId = await retryMutation(() =&gt;
    fetchMutation(api.pipelineResults.createPipelineResult, {
      runId, framework: "mastra", status: "running", iterations: 0,
    })
  );
  const langchainResultId = await retryMutation(() =&gt;
    fetchMutation(api.pipelineResults.createPipelineResult, {
      runId, framework: "langchain", status: "running", iterations: 0,
    })
  );

  // Start both pipelines without awaiting them
  const task = Promise.allSettled([
    withRetry(() =&gt; runMastraPipeline(topic, buildCallbacks(runId, mastraResultId, "mastra"))),
    withRetry(() =&gt; runLangChainPipeline(topic, buildCallbacks(runId, langchainResultId, "langchain"))),
  ]).then(async () =&gt; {
    await retryMutation(() =&gt;
      fetchMutation(api.runs.updateRunStatus, { runId, status: "complete" })
    );
    activeTasks.delete(runId as string);
  });

  // Hold a reference in the Map so Node.js doesn't garbage-collect the promise
  activeTasks.set(runId as string, task);
  return NextResponse.json({ runId });   // returns immediately
}
</code></pre>
<p>On Vercel, this pattern still fails because serverless functions terminate when the route handler returns, killing any background promises. The fix is using <code>waitUntil</code> from <code>@vercel/functions</code>, which tells Vercel to keep the execution context alive until the promise resolves:</p>
<pre><code class="language-typescript">import { waitUntil } from "@vercel/functions";

waitUntil(task);
return NextResponse.json({ runId });
</code></pre>
<h3 id="heading-subscribing-to-live-updates">Subscribing to Live Updates</h3>
<p>On the run page, three Convex queries run simultaneously: the run itself, the pipeline results, and the steps for each pipeline result.</p>
<p>The <code>"skip"</code> sentinel is important here: it tells Convex to hold the subscription open without executing the query until a real argument is available. This prevents a race condition where the steps query fires before the pipeline result records have been created:</p>
<pre><code class="language-typescript">const mastraSteps = useQuery(
  api.steps.getStepsForPipelineResult,
  mastraResult ? { pipelineResultId: mastraResult._id } : "skip"
);
</code></pre>
<h3 id="heading-deduplicating-steps-after-retries">Deduplicating Steps After Retries</h3>
<p>When a pipeline fails due to a TLS error and retries from the beginning, the failed attempt's step records stay in Convex alongside the successful attempt's records. The UI would render both, creating a visible gap between the research card and the rest of the steps.</p>
<p>The fix groups steps by <code>stepName + iterationNumber</code> and keeps the best version of each:</p>
<pre><code class="language-typescript">const stepMap = new Map&lt;string, Step&gt;();
[...steps]
  .sort((a, b) =&gt; (a._creationTime ?? 0) - (b._creationTime ?? 0))
  .forEach((s) =&gt; {
    const key = `\({s.stepName}-\){s.iterationNumber}`;
    const existing = stepMap.get(key);
    if (!existing) { stepMap.set(key, s); return; }
    if (s.status === "complete") { stepMap.set(key, s); return; }
    if (existing.status !== "complete") { stepMap.set(key, s); }
  });
</code></pre>
<h3 id="heading-the-live-log-auto-scroll">The Live Log Auto-Scroll</h3>
<p>Log entries are appended to the pipeline result document in Convex as an array, and the panel auto-scrolls as new entries arrive using a ref attached to an empty div at the bottom:</p>
<pre><code class="language-typescript">function LiveLogPanel({ logs }: { logs?: LogEntry[] }) {
  const endRef = useRef&lt;HTMLDivElement&gt;(null);

  useEffect(() =&gt; {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [logs?.length]);

  return (
    &lt;div className="max-h-52 overflow-y-auto font-mono text-xs"&gt;
      {logs?.map((entry, i) =&gt; (
        &lt;div key={i} className="flex gap-2"&gt;
          &lt;span className="text-[#484f58]"&gt;[{fmtTs(entry.timestamp)}]&lt;/span&gt;
          &lt;span className={`font-bold w-14 ${tagColor(entry.tag)}`}&gt;{entry.tag}&lt;/span&gt;
          &lt;span className="text-[#c9d1d9]"&gt;{entry.message}&lt;/span&gt;
        &lt;/div&gt;
      ))}
      &lt;div ref={endRef} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The effect dependency is <code>logs?.length</code>, so the scroll triggers every time a new log entry arrives from Convex.</p>
<h2 id="heading-what-the-data-actually-shows">What the Data Actually Shows</h2>
<p><strong>Speed:</strong> LangChain is 25-45% faster in every run. On shorter topics the gap narrows to 7-8 seconds, but it never reverses.</p>
<p>I think the reason for this is structural. Mastra's Agent class initialises its tool loop manager on every step, even when no tools are called. That means internal conversation history, tool schemas, and retry infrastructure are all set up as overhead before the actual model call happens.</p>
<p>Across a four-step pipeline, those 2-5 seconds per step accumulate. LangGraph nodes are plain async functions, so your code runs directly, with no framework initialisation between you and the model.</p>
<p><strong>Tokens:</strong> Mastra uses 1.5-2.5x more tokens. The research step alone accounted for most of that gap because LangChain's research node calls Tavily directly without invoking an LLM at all.</p>
<p>On more typical topics, Mastra runs around 6,200 tokens and LangChain around 3,900. The gap scales with how much content Tavily returns, because that content flows into Mastra's agent conversation history on every subsequent step.</p>
<p><strong>Quality:</strong> After fixing the evaluation bias, scores vary meaningfully by topic rather than by framework. Both produce high-scoring reports when the Tavily results are specific and rich. Both struggle on vague or biographical topics where the search results are generic.</p>
<p>A first draft scoring 7 or 8 means the research was strong and the writer made specific grounded claims. A 4 or 5 means the research returned thin results and the writer defaulted to generic observations, and the revision loop runs until either the draft improves or the iteration limit is hit.</p>
<p><strong>The tradeoff:</strong> Mastra handles orchestration complexity in the framework so you don't have to. You write <code>.dowhile()</code> instead of a conditional edge, typed step schemas instead of a shared mutable state object, and the framework manages conversation history and tool execution. The cost is a consistent token and latency overhead on every step.</p>
<p>LangChain gives you the graph execution engine and leaves everything else to you: more explicit wiring to write, but leaner execution and precise control over every token that enters each model call.</p>
<h2 id="heading-try-it-yourself">Try it Yourself</h2>
<p>The live demo is at <a href="https://mastra-vs-langchain.vercel.app">mastra-vs-langchain.vercel.app</a> and the complete source code for this comparison is at <a href="https://github.com/sholajegede/mastra-vs-langchain">github.com/sholajegede/mastra-vs-langchain</a>. If it helped you, consider giving it a star.</p>
<pre><code class="language-bash">git clone https://github.com/sholajegede/mastra-vs-langchain.git
cd mastra-vs-langchain
npm install
cp .env.example .env
# Add ANTHROPIC_API_KEY and TAVILY_API_KEY
npx convex dev   # Terminal 1
npm run web      # Terminal 2
</code></pre>
<p>Open <code>localhost:3000</code>, enter a topic, pick a category, and run both. Every step is visible as it happens, every token is counted, and the history page stores all previous runs by category.</p>
<p>If you want to take this comparison further by adding CrewAI, CopilotKit, or any other framework to the benchmark, the <code>PipelineCallbacks</code> interface in <code>packages/shared</code> is the only contract you need to implement.</p>
<p>If this tutorial was useful, feel free to share it with others who might benefit. I’d really appreciate your thoughts. You can mention me on X at <a href="https://x.com/wani_shola">@wani_shola</a> or <a href="https://linkedin.com/in/sholajegede">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Deploy a Full-Stack Next.js App on Cloudflare Workers with GitHub Actions CI/CD ]]>
                </title>
                <description>
                    <![CDATA[ I typically build my projects using Next.js 14 (App Router) and Supabase for authentication along with Postgres. The default deployment choice for a Next.js app is usually Vercel, and for good reason: ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-deploy-a-full-stack-next-js-app-on-cloudflare-workers-with-github-actions-ci-cd/</link>
                <guid isPermaLink="false">69f2145e6e0124c05e1a5b6e</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 14:23:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cbb9e559-baa7-452c-992a-3416041712ad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I typically build my projects using Next.js 14 (App Router) and Supabase for authentication along with Postgres. The default deployment choice for a Next.js app is usually Vercel, and for good reason: it provides an excellent developer experience.</p>
<p>But after running the same project on both platforms for about a week, I started exploring Cloudflare Workers as an alternative. I noticed improvements in latency (lower TTFB) and found the free tier to be more flexible for my use case.</p>
<p>Deploying Next.js apps on Cloudflare used to be challenging. Earlier solutions like Cloudflare Pages had limitations with full Next.js features, and tools like <code>next-on-pages</code> often lagged behind the latest releases.</p>
<p>That changed with the introduction of <a href="https://opennext.js.org/cloudflare"><code>@opennextjs/cloudflare</code></a>. It allows you to compile a standard Next.js application into a Cloudflare Worker, supporting features like SSR, ISR, middleware, and the Image component – all without requiring major code changes.</p>
<p>In this guide, I’ll walk you through the exact steps I used to deploy my full-stack Next.js + Supabase application to Cloudflare Workers.</p>
<p>This article is the runbook I wish I had when I started.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-choose-cloudflare-workers-over-vercel">Why Choose Cloudflare Workers Over Vercel?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-stack">The Stack</a></p>
</li>
<li><p><a href="#heading-step-1-install-the-cloudflare-adapter">Step 1 — Install the Cloudflare Adapter</a></p>
</li>
<li><p><a href="#heading-step-2-wire-opennext-into-next-dev">Step 2 — Wire OpenNext into next dev</a></p>
</li>
<li><p><a href="#heading-step-3-local-environment-setup-with-devvars">Step 3— Local Environment Setup with .dev.vars</a></p>
</li>
<li><p><a href="#heading-step-4-deploy-your-app-from-your-local-machine">Step 4 — Deploy Your App from Your Local Machine</a></p>
</li>
<li><p><a href="#heading-step-5-push-your-secrets-to-the-worker">Step 5 — Push your secrets to the Worker</a></p>
</li>
<li><p><a href="#heading-step-6-set-up-continuous-deployment-with-github-actions">Step 6 — Set Up Continuous Deployment with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-step-7-updating-the-project-the-daily-workflow">Step 7 — Updating the project (the daily workflow)</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-choose-cloudflare-workers-over-vercel">Why Choose Cloudflare Workers Over Vercel?</h2>
<p>When deploying a Next.js application, Vercel is often the default choice. It offers a smooth developer experience and tight integration with Next.js.</p>
<p>But Cloudflare Workers provides a compelling alternative, especially when you care about global performance and cost efficiency.</p>
<p>Here’s a high-level comparison (at the time of writing):</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Vercel (Hobby)</th>
<th>Cloudflare Workers (Free Tier)</th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td>Fair usage limits</td>
<td>Millions of requests per day</td>
</tr>
<tr>
<td>Cold starts</td>
<td>~100–300 ms (region-based)</td>
<td>Near-zero (V8 isolates)</td>
</tr>
<tr>
<td>Edge locations</td>
<td>Limited regions for SSR</td>
<td>300+ global edge locations</td>
</tr>
<tr>
<td>Bandwidth</td>
<td>~100 GB/month (soft cap)</td>
<td>Generous / no strict cap on free tier</td>
</tr>
<tr>
<td>Custom domains</td>
<td>Supported</td>
<td>Supported</td>
</tr>
<tr>
<td>Image optimization</td>
<td>Counts toward usage</td>
<td>Available via <code>IMAGES</code> binding</td>
</tr>
<tr>
<td>Pricing beyond free</td>
<td>Starts at ~$20/month</td>
<td>Low-cost, usage-based pricing</td>
</tr>
</tbody></table>
<h3 id="heading-key-takeaways">Key Takeaways</h3>
<ul>
<li><p><strong>Lower latency globally</strong>: Cloudflare runs your app across hundreds of edge locations, reducing response time for users worldwide.</p>
</li>
<li><p><strong>Minimal cold starts</strong>: Thanks to V8 isolates, functions start almost instantly.</p>
</li>
<li><p><strong>Cost efficiency</strong>: The free tier is generous enough for portfolios, blogs, and many small-to-medium apps.</p>
</li>
</ul>
<h3 id="heading-trade-offs-to-consider">Trade-offs to Consider</h3>
<p>Cloudflare Workers use a V8 isolate runtime, not a full Node.js environment. That means:</p>
<ul>
<li><p>Some Node.js APIs like <code>fs</code> or <code>child_process</code> aren't available</p>
</li>
<li><p>Native binaries or certain libraries may not work</p>
</li>
</ul>
<p>That said, for most modern stacks –&nbsp;like Next.js + Supabase + Stripe + Resend – this limitation is rarely an issue.</p>
<p>In short, choose <strong>Vercel</strong> if you want the simplest, plug-and-play Next.js deployment. Choose <strong>Cloudflare Workers</strong> if you want better edge performance and more flexible scaling.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before getting started, make sure you have the following set up. Most of these take only a few minutes:</p>
<ul>
<li><p><strong>Node.js 18+</strong> and <strong>pnpm 9+</strong> (you can also use npm or yarn, but this guide uses pnpm.)</p>
</li>
<li><p>A <strong>Cloudflare account</strong> 👉 <a href="https://dash.cloudflare.com/sign-up">https://dash.cloudflare.com/sign-up</a></p>
</li>
<li><p>A <strong>Supabase account</strong> (if your app uses a database) 👉 <a href="https://supabase.com">https://supabase.com</a></p>
</li>
<li><p>A <strong>GitHub repository</strong> for your project (required later for CI/CD setup)</p>
</li>
<li><p>A <strong>domain name</strong> (optional) – You’ll get a free <code>*.workers.dev</code> URL by default.</p>
</li>
</ul>
<h3 id="heading-install-wrangler-cloudflare-cli">Install Wrangler (Cloudflare CLI)</h3>
<p>We’ll use Wrangler to build and deploy the application:</p>
<pre><code class="language-bash">pnpm add -D wrangler
</code></pre>
<h2 id="heading-the-stack">The Stack</h2>
<p>Here’s the tech stack used in this project:</p>
<ul>
<li><p><strong>Next.js (v14.2.x):</strong> Using the App Router with Edge runtime for both public and dashboard routes</p>
</li>
<li><p><strong>Supabase:</strong> Handles authentication, Postgres database, and Row-Level Security (RLS)</p>
</li>
<li><p><strong>Tailwind CSS</strong> + UI utilities: For styling, along with lightweight animation using Framer Motion</p>
</li>
<li><p><strong>Cloudflare Workers:</strong> Deployment powered by <code>@opennextjs/cloudflare</code> and <code>wrangler</code></p>
</li>
<li><p><strong>GitHub Actions:</strong> Used to automate CI/CD and deployments</p>
</li>
</ul>
<p><strong>Note:</strong> If you're using Next.js <strong>15 or later</strong>, you can remove the<br><code>--dangerouslyUseUnsupportedNextVersion</code> flag from the build script, as it's only required for certain Next.js 14 setups.</p>
<h2 id="heading-step-1-install-the-cloudflare-adapter">Step 1 — Install the Cloudflare Adapter</h2>
<p>From inside your existing Next.js project, install the OpenNext adapter along with Wrangler (Cloudflare’s CLI tool):</p>
<pre><code class="language-bash">pnpm add @opennextjs/cloudflare
pnpm add -D wrangler
</code></pre>
<p>Then add the deploy scripts to <code>package.json</code>:</p>
<pre><code class="language-jsonc">{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",

    "cloudflare-build": "opennextjs-cloudflare build --dangerouslyUseUnsupportedNextVersion",
    "preview":          "pnpm cloudflare-build &amp;&amp; opennextjs-cloudflare preview",
    "deploy":           "pnpm cloudflare-build &amp;&amp; wrangler deploy",
    "upload":           "pnpm cloudflare-build &amp;&amp; opennextjs-cloudflare upload",
    "cf-typegen":       "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
  }
}
</code></pre>
<p>What each script does:</p>
<table>
<thead>
<tr>
<th>Script</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>pnpm cloudflare-build</code></td>
<td>Compiles your Next app into <code>.open-next/</code> (the Worker bundle). No upload.</td>
</tr>
<tr>
<td><code>pnpm preview</code></td>
<td>Builds and runs the Worker locally with <code>wrangler dev</code>. Closest thing to prod.</td>
</tr>
<tr>
<td><code>pnpm deploy</code></td>
<td>Builds and uploads to Cloudflare. <strong>This ships to production.</strong></td>
</tr>
<tr>
<td><code>pnpm upload</code></td>
<td>Builds and uploads a <em>new version</em> without promoting it (for staged rollouts).</td>
</tr>
<tr>
<td><code>pnpm cf-typegen</code></td>
<td>Regenerates <code>cloudflare-env.d.ts</code> types after editing <code>wrangler.jsonc</code>.</td>
</tr>
</tbody></table>
<p><strong>Heads up:</strong> the Pages-based <code>@cloudflare/next-on-pages</code> is a different tool. We are <strong>not</strong> using Pages — we're deploying as a real Worker. Don't mix the two.</p>
<h2 id="heading-step-2-wire-opennext-into-next-dev">Step 2 — Wire OpenNext into <code>next dev</code></h2>
<p>So that <code>pnpm dev</code> can read your Cloudflare bindings (env vars, R2, KV, D1, …) the same way production will, edit <code>next.config.mjs</code>:</p>
<pre><code class="language-js">/** @type {import('next').NextConfig} */
const nextConfig = {};

if (process.env.NODE_ENV !== "production") {
  const { initOpenNextCloudflareForDev } = await import(
    "@opennextjs/cloudflare"
  );
  initOpenNextCloudflareForDev();
}

export default nextConfig;
</code></pre>
<p>We only call it in development so <code>next build</code> stays fast and CI doesn't spin up a Miniflare instance for nothing.</p>
<h2 id="heading-step-3-local-environment-setup-with-devvars">Step 3 — Local Environment Setup with <code>.dev.vars</code></h2>
<p>When working with Cloudflare Workers locally, Wrangler uses a file called <code>.dev.vars</code> to store environment variables (instead of <code>.env.local</code> used by Next.js).</p>
<p>A simple and reliable approach is to keep an example file in your repo and ignore the real one.</p>
<h3 id="heading-example-devvarsexample-committed">Example: <code>.dev.vars.example</code> (committed)</h3>
<pre><code class="language-bash">NEXT_PUBLIC_SUPABASE_URL="https://YOUR-PROJECT-ref.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR-ANON-KEY"
NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL="admin@example.com"
</code></pre>
<h3 id="heading-set-up-your-local-environment">Set Up Your Local Environment</h3>
<p>Run the following commands:</p>
<pre><code class="language-plaintext">cp .dev.vars.example .dev.vars
cp .dev.vars .env.local
</code></pre>
<ul>
<li><p><code>.dev.vars</code> is used by Wrangler (<code>wrangler dev</code>)</p>
</li>
<li><p><code>.env.local</code> is used by Next.js (<code>next dev</code>)</p>
</li>
</ul>
<h3 id="heading-why-use-both-files">Why Use Both Files?</h3>
<ul>
<li><p><code>next dev</code> reads from <code>.env.local</code></p>
</li>
<li><p><code>wrangler dev</code> (used in <code>pnpm preview</code>) reads from <code>.dev.vars</code></p>
</li>
</ul>
<p>Keeping both files in sync ensures your app behaves consistently in development and when running in the Cloudflare runtime.</p>
<h3 id="heading-update-gitignore">Update <code>.gitignore</code></h3>
<p>Make sure these files are ignored:</p>
<pre><code class="language-plaintext">.dev.vars
.env*.local
.open-next
.wrangler
</code></pre>
<h2 id="heading-step-4-deploy-your-app-from-your-local-machine">Step 4 — Deploy Your App from Your Local Machine</h2>
<p>Once <code>pnpm preview</code> is working correctly, you're ready to deploy your application:</p>
<pre><code class="language-bash">pnpm deploy
</code></pre>
<p>Under the hood that runs:</p>
<pre><code class="language-bash">pnpm cloudflare-build &amp;&amp; wrangler deploy
</code></pre>
<p>The first time, Wrangler will:</p>
<ol>
<li><p>Compile your app to <code>.open-next/worker.js</code>.</p>
</li>
<li><p>Upload the script + assets to Cloudflare.</p>
</li>
<li><p>Print your live URL, e.g. <code>https://porfolio.&lt;your-account&gt;.workers.dev</code>.</p>
</li>
</ol>
<p>Open it in a browser. Congratulations — you're on Cloudflare's edge in 330+ cities. The page should be served in <strong>&lt;100 ms</strong> TTFB from anywhere.  </p>
<p><a href="https://portfolio.tarikuldev.workers.dev/">Here's the live version of my own portfolio deployed this way</a></p>
<h2 id="heading-step-5-push-your-secrets-to-the-worker">Step 5 — Push Your Secrets to the Worker</h2>
<p>Local <code>.dev.vars</code> is <strong>not</strong> uploaded by <code>wrangler deploy</code>. You have to push secrets explicitly:</p>
<pre><code class="language-bash">wrangler secret put NEXT_PUBLIC_SUPABASE_URL
wrangler secret put NEXT_PUBLIC_SUPABASE_ANON_KEY
wrangler secret put NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL
</code></pre>
<p>Each command prompts you for the value and stores it encrypted on Cloudflare. Or do it visually:</p>
<blockquote>
<p>Cloudflare Dashboard → <strong>Workers &amp; Pages</strong> → your worker → <strong>Settings</strong> → <strong>Variables and Secrets</strong> → <strong>Add</strong>.</p>
</blockquote>
<p>Important: <code>NEXT_PUBLIC_*</code> vars are inlined into the client bundle at build time, so they also need to be available when pnpm cloudflare-build runs (locally, that's your .env.local; in CI, see Step 10).</p>
<h2 id="heading-step-6-set-up-continuous-deployment-with-github-actions">Step 6 — Set Up Continuous Deployment with GitHub Actions</h2>
<p>Once your local deployment is working, the next step is automating deployments so every push to the <code>main</code> branch updates production automatically.</p>
<p>With this workflow:</p>
<ul>
<li><p>Pull requests will run validation checks</p>
</li>
<li><p>Production deploys only happen after successful builds</p>
</li>
<li><p>Broken code never reaches your live site</p>
</li>
</ul>
<p>Create the following file inside your project:</p>
<p><code>.github/workflows/deploy.yml</code></p>
<pre><code class="language-yaml">name: CI / Deploy to Cloudflare Workers

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

concurrency:
  group: cloudflare-deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  verify:
    name: Lint and Build
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

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

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

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm build
        env:
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL }}

  deploy:
    name: Deploy to Cloudflare Workers
    needs: verify
    if: github.event_name == 'push' &amp;&amp; github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@v4

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

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

      - run: pnpm install --frozen-lockfile

      - name: Build and Deploy
        run: pnpm run deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL }}
</code></pre>
<h3 id="heading-required-github-repo-secrets">Required GitHub repo secrets</h3>
<p>Go to GitHub repo → Settings → Secrets and variables → Actions → New repository secret and add:</p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Where to get it</th>
</tr>
</thead>
<tbody><tr>
<td><code>CLOUDFLARE_API_TOKEN</code></td>
<td><a href="https://dash.cloudflare.com/profile/api-tokens">https://dash.cloudflare.com/profile/api-tokens</a> → "Edit Cloudflare Workers" template</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_ID</code></td>
<td>Cloudflare dashboard → right sidebar, "Account ID"</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_SUBDOMAIN</code></td>
<td>Your <code>*.workers.dev</code> subdomain (used only for the deployment URL link)</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_URL</code></td>
<td>Supabase project settings</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_ANON_KEY</code></td>
<td>Supabase project settings</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL</code></td>
<td>Email pre-filled on <code>/dashboard/login</code></td>
</tr>
</tbody></table>
<p>That's it. Push it to <code>main</code> and it'll go live in about 90 seconds. PRs run lint and build only, so broken code never reaches production.</p>
<h2 id="heading-step-7-updating-the-project-the-daily-workflow">Step 7 — Updating the Project (the Daily Workflow)</h2>
<p>After the initial setup, the loop is boringly simple — which is the whole point. Here's what I actually do day-to-day:</p>
<h3 id="heading-code-change">Code Change</h3>
<pre><code class="language-bash">git checkout -b feat/new-section
# ...edit files...
pnpm dev                # iterate locally
pnpm preview            # final smoke test on the Worker runtime
git commit -am "feat: add new section"
git push origin feat/new-section
</code></pre>
<p>Open a PR and the <strong>verify</strong> that the job runs. Then review, merge, and the deploy it. The job ships to Cloudflare automatically.</p>
<h3 id="heading-updating-env-vars-secrets">Updating env Vars / Secrets</h3>
<pre><code class="language-bash"># Local
nano .dev.vars

# Production
wrangler secret put NEXT_PUBLIC_SUPABASE_URL
# ...etc.
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>When I started this migration, I was nervous about leaving Vercel — the Next.js DX there is genuinely excellent. But the moment you push beyond a hobby site, Cloudflare's economics and edge performance are not close.</p>
<p>With <code>@opennextjs/cloudflare</code>, the developer experience has also caught up: my <code>pnpm dev</code> loop is identical, my <code>pnpm preview</code> mimics production, and <code>git push</code> deploys globally in ~90 seconds.</p>
<p>If you've been holding off because the old Cloudflare Pages + Next.js story was rough, that era is over. Try this runbook on a side project this weekend and see for yourself.</p>
<p>If you found this useful, the full repo is <a href="./">here</a> — feel free to clone it as a starter.</p>
<p>Happy shipping.</p>
<p>— <em>Tarikul</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Multi-Tenant SaaS Platform with Next.js, Express, and Prisma ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wondered how platforms like Webflow, Notion, or Hashnode serve thousands of users from a single codebase — each with their own unique URL? The answer is multi-tenancy: an architecture wh ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-multi-tenant-saas-platform-with-next-js-express-and-prisma/</link>
                <guid isPermaLink="false">69f213e46e0124c05e19e1af</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SaaS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ prisma ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Express ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Michael Okolo ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 14:21:24 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ef0c87aa-4455-4230-9669-bf2c13db9947.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wondered how platforms like Webflow, Notion, or Hashnode serve thousands of users from a single codebase — each with their own unique URL?</p>
<p>The answer is multi-tenancy: an architecture where one application dynamically serves isolated experiences to many different users, often through subdomains.</p>
<p>In this tutorial, you'll build a multi-tenant portfolio SaaS platform from scratch using Next.js, Express, and Prisma. Each user who signs up gets their own portfolio site, served on their own subdomain — generated instantly, powered by a single backend, and stored in a single database.</p>
<p>Here's what you'll build:</p>
<ul>
<li><p>A landing page where users fill out a form to create their portfolio</p>
</li>
<li><p>An Express + Prisma backend that stores each user as a "tenant"</p>
</li>
<li><p>A Next.js middleware layer that detects subdomains and routes requests dynamically</p>
</li>
<li><p>A JSON-driven template system that controls which sections appear on each portfolio</p>
</li>
<li><p>A production-ready portfolio page served at <code>name.localhost:3000</code> in development and <code>name.yourdomain.com</code> in production</p>
</li>
</ul>
<p>You can find the complete source code in the GitHub repositories linked at the end of this tutorial.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-multi-tenancy">What is Multi-Tenancy?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-backend">How to Set Up the Backend</a></p>
<ul>
<li><p><a href="#heading-how-to-install-dependencies">How to Install Dependencies</a></p>
</li>
<li><p><a href="#heading-how-to-configure-typescript-for-esm">How to Configure TypeScript for ESM</a></p>
</li>
<li><p><a href="#heading-how-to-initialize-prisma">How to Initialize Prisma</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-define-the-prisma-schema">How to Define the Prisma Schema</a></p>
</li>
<li><p><a href="#heading-how-to-run-your-first-migration">How to Run Your First Migration</a></p>
</li>
<li><p><a href="#heading-how-to-generate-and-instantiate-the-prisma-client">How to Generate and Instantiate the Prisma Client</a></p>
<ul>
<li><p><a href="#heading-how-to-generate-the-client">How to Generate the Client</a></p>
</li>
<li><p><a href="#heading-how-to-instantiate-the-client">How to Instantiate the Client</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-seed-a-template">How to Seed a Template</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-express-api">How to Build the Express API</a></p>
<ul>
<li><p><a href="#heading-how-to-install-express">How to Install Express</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-server-entry-point">How to Create the Server Entry Point</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-express-app">How to Create the Express App</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-tenant-controller">How to Create the Tenant Controller</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-tenant-routes">How to Create the Tenant Routes</a></p>
</li>
<li><p><a href="#heading-how-to-start-the-server">How to Start the Server</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-create-the-nextjs-frontend">How to Create the Next.js Frontend</a></p>
</li>
<li><p><a href="#heading-how-to-add-subdomain-routing-with-middleware">How to Add Subdomain Routing with Middleware</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-landing-page">How to Build the Landing Page</a></p>
<ul>
<li><p><a href="#heading-how-to-update-the-layout">How to Update the Layout</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-home-page">How to Create the Home Page</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-build-the-tenant-portfolio-page">How to Build the Tenant Portfolio Page</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-full-flow">How to Test the Full Flow</a></p>
</li>
<li><p><a href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-source-code">Source Code</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you begin, make sure you have the following:</p>
<ul>
<li><p>Node.js (version 18 or higher) installed on your machine</p>
</li>
<li><p>A basic understanding of React, TypeScript, and REST APIs</p>
</li>
<li><p>Familiarity with Prisma ORM (you don't need to be an expert)</p>
</li>
<li><p>A code editor like VS Code</p>
</li>
</ul>
<p>You'll use Prisma Postgres as your database, so you won't need to set up a separate database server locally. Prisma handles the connection string and adapter configuration for you.</p>
<h2 id="heading-what-is-multi-tenancy">What is Multi-Tenancy?</h2>
<p>Multi-tenancy is an architectural pattern where a single application serves multiple users — called tenants — each with isolated data and often their own URL.</p>
<p>Here's how the flow works in this tutorial:</p>
<ol>
<li><p>A user visits your landing page and fills out a form with their name, bio, and skills.</p>
</li>
<li><p>Your Express backend creates a new tenant record in the database and generates a slug from their name.</p>
</li>
<li><p>The browser redirects the user to <code>their-name.localhost:3000</code>.</p>
</li>
<li><p>Your Next.js middleware detects the subdomain, extracts the slug, and rewrites the request to <code>/tenant/their-name</code> internally.</p>
</li>
<li><p>The tenant page fetches that user's data from the API and renders their portfolio.</p>
</li>
</ol>
<p>The key insight is that the URL in the browser never changes — the rewrite is invisible to the user. One Next.js app serves every tenant dynamically.</p>
<h2 id="heading-how-to-set-up-the-backend">How to Set Up the Backend</h2>
<p>Start by creating a project folder with separate directories for the backend and frontend:</p>
<pre><code class="language-bash">mkdir portfolio-saas &amp;&amp; cd portfolio-saas
mkdir portfolio-api portfolio-client
</code></pre>
<p>Navigate into the backend directory and initialize a new Node.js project:</p>
<pre><code class="language-bash">cd portfolio-api
npm init -y
</code></pre>
<h3 id="heading-how-to-install-dependencies">How to Install Dependencies</h3>
<p>Install TypeScript, Prisma, and the supporting packages:</p>
<pre><code class="language-bash">npm install typescript tsx @types/node --save-dev
npx tsc --init
npm install prisma @types/node @types/pg --save-dev
npm install @prisma/client @prisma/adapter-pg pg dotenv
</code></pre>
<h3 id="heading-how-to-configure-typescript-for-esm">How to Configure TypeScript for ESM</h3>
<p>Open <code>tsconfig.json</code> and replace its contents with:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2023",
    "strict": true,
    "esModuleInterop": true,
    "ignoreDeprecations": "6.0",
    "types": ["node"]
  }
}
</code></pre>
<p>Then open <code>package.json</code> and add <code>"type": "module"</code> to enable ESM:</p>
<pre><code class="language-json">{
  "type": "module"
}
</code></pre>
<h3 id="heading-how-to-initialize-prisma">How to Initialize Prisma</h3>
<p>Run the following command to initialize Prisma and generate your schema setup:</p>
<pre><code class="language-bash">npx prisma init --db --output ../generated/prisma
</code></pre>
<p>This command creates a <code>prisma/schema.prisma</code> file, a <code>.env</code> file with your database connection string, and a generated Prisma configuration folder.</p>
<h2 id="heading-how-to-define-the-prisma-schema">How to Define the Prisma Schema</h2>
<p>Open <code>prisma/schema.prisma</code> and replace its contents with the following:</p>
<pre><code class="language-prisma">generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
}

model Tenant {
  id         String   @id @default(uuid())
  slug       String   @unique
  name       String
  bio        String
  skills     String[]
  templateId String
  createdAt  DateTime @default(now())
}

model Template {
  id     String @id @default(uuid())
  name   String
  config Json
}

model Post {
  id         String   @id @default(uuid())
  title      String
  content    String
  tenantSlug String
  createdAt  DateTime @default(now())
}
</code></pre>
<p>Let's look at what each model does.</p>
<p>The <code>Tenant</code> model represents a user who has signed up and created a portfolio. The <code>slug</code> field is generated from their name (for example, "John Doe" becomes <code>john-doe</code>) and is used as their subdomain. The <code>templateId</code> links each tenant to a template that controls their portfolio's layout.</p>
<p>The <code>Template</code> model stores layout configuration as JSON. Instead of hardcoding sections like "hero" or "skills" into your components, you store them in the database. This means you can add or remove sections for different templates without touching any component code.</p>
<p>The <code>Post</code> model is included for future extensibility — you can use it to let tenants publish blog posts on their portfolio.</p>
<h2 id="heading-how-to-run-your-first-migration">How to Run Your First Migration</h2>
<p>Run the following command to create your database tables based on the schema:</p>
<pre><code class="language-bash">npx prisma migrate dev --name init
</code></pre>
<p>This command creates the database tables, generates a migration file, and applies the migration to your database. After it runs, your Postgres database structure matches your Prisma schema exactly.</p>
<h2 id="heading-how-to-generate-and-instantiate-the-prisma-client">How to Generate and Instantiate the Prisma Client</h2>
<h3 id="heading-how-to-generate-the-client">How to Generate the Client</h3>
<p>Run this command to generate a fully type-safe Prisma Client based on your schema:</p>
<pre><code class="language-bash">npx prisma generate
</code></pre>
<p>You only need to run this once after each schema change. The generated client lives in the <code>../generated/prisma</code> folder you configured earlier.</p>
<h3 id="heading-how-to-instantiate-the-client">How to Instantiate the Client</h3>
<p>Create a new file at <code>lib/prisma.ts</code> and add the following:</p>
<pre><code class="language-typescript">import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client';

const connectionString = process.env.DATABASE_URL as string;

const adapter = new PrismaPg({ connectionString });
const prisma = new PrismaClient({ adapter });

export default prisma;
</code></pre>
<p>This file creates a single shared Prisma Client instance that your entire backend will import. The <code>PrismaPg</code> adapter connects Prisma to your Postgres database using the connection string from your <code>.env</code> file.</p>
<h2 id="heading-how-to-seed-a-template">How to Seed a Template</h2>
<p>Your platform needs at least one template in the database before any tenant can sign up. Instead of hardcoding layout decisions into your components, you'll store the template configuration as JSON and read it at runtime.</p>
<p>Create a new file at <code>prisma/seed.ts</code> and add the following:</p>
<pre><code class="language-typescript">import prisma from '../lib/prisma';

async function main() {
  await prisma.template.create({
    data: {
      name: 'minimal',
      config: {
        theme: {
          primaryColor: '#6366f1',
          background: 'dark',
        },
        sections: {
          hero: true,
          about: true,
          skills: true,
          projects: true,
          blog: true,
          contact: true,
        },
      },
    },
  });

  console.log('Template seeded successfully.');
}

main()
  .catch((e) =&gt; {
    console.error(e);
    process.exit(1);
  })
  .finally(async () =&gt; {
    await prisma.$disconnect();
  });
</code></pre>
<p>The <code>config</code> field is stored as JSON in Postgres. When a tenant's portfolio loads, your frontend reads this JSON to decide which sections to show. Setting <code>hero: false</code> on a template would hide the hero section for every tenant using that template — no code changes needed.</p>
<p>Now add a seed script to your <code>package.json</code>:</p>
<pre><code class="language-json">{
  "scripts": {
    "seed": "tsx prisma/seed.ts"
  }
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">npm run seed
</code></pre>
<p>Your database now has a default template ready to attach to new tenants.</p>
<h2 id="heading-how-to-build-the-express-api">How to Build the Express API</h2>
<p>Now you'll build the backend API that creates tenants and retrieves their data.</p>
<h3 id="heading-how-to-install-express">How to Install Express</h3>
<pre><code class="language-bash">npm install express cors
npm install -D @types/express @types/cors
</code></pre>
<h3 id="heading-how-to-create-the-server-entry-point">How to Create the Server Entry Point</h3>
<p>Create <code>src/index.ts</code>:</p>
<pre><code class="language-typescript">import app from './app';

const PORT = 8080;

app.listen(PORT, () =&gt; {
  console.log('Server is running on port 8080');
});
</code></pre>
<h3 id="heading-how-to-create-the-express-app">How to Create the Express App</h3>
<p>Create <code>src/app.ts</code>:</p>
<pre><code class="language-typescript">import express from 'express';
import cors from 'cors';
import tenantRoutes from './routes/tenant.routes';

const app = express();

app.use(cors());
app.use(express.json());
app.use('/api', tenantRoutes);

export default app;
</code></pre>
<p>This file sets up CORS so your Next.js frontend can communicate with the API, parses JSON request bodies, and mounts all tenant routes under the <code>/api</code> prefix.</p>
<h3 id="heading-how-to-create-the-tenant-controller">How to Create the Tenant Controller</h3>
<p>Create <code>src/controllers/tenant.controller.ts</code>:</p>
<pre><code class="language-typescript">import { Request, Response } from 'express';
import prisma from '../../lib/prisma';

export async function createTenant(req: Request, res: Response) {
  const { name, bio, skills } = req.body;

  if (!name || !bio || !skills) {
    return res.status(400).json({ error: 'Missing required fields' });
  }

  const slug = name.toLowerCase().replace(/\s+/g, '-');

  const template = await prisma.template.findFirst();
  if (!template) {
    return res.status(500).json({ error: 'No template found' });
  }

  const tenant = await prisma.tenant.create({
    data: {
      slug,
      name,
      bio,
      skills,
      templateId: template.id,
    },
  });

  res.json({ slug: tenant.slug });
}

export async function getTenant(req: Request, res: Response) {
  const slug = req.params.slug;

  if (!slug || typeof slug !== 'string') {
    return res.status(400).json({ error: 'Invalid slug parameter' });
  }

  const tenant = await prisma.tenant.findUnique({
    where: { slug },
  });

  if (!tenant) {
    return res.status(404).json({ error: 'Tenant not found' });
  }

  const template = await prisma.template.findUnique({
    where: { id: tenant.templateId },
  });

  res.json({ tenant, template });
}
</code></pre>
<p>Let's break down what this controller does.</p>
<p><code>createTenant</code> takes the user's name, bio, and skills from the request body. It generates a slug by lowercasing the name and replacing spaces with hyphens — so "Jane Smith" becomes <code>jane-smith</code>. It then finds the first available template and creates the tenant record in the database, linking the template to the new tenant via <code>templateId</code>.</p>
<p><code>getTenant</code> looks up a tenant by their slug and also fetches the template attached to them. Both pieces of data are returned together so the frontend can render the portfolio and apply the correct layout configuration in a single API call.</p>
<h3 id="heading-how-to-create-the-tenant-routes">How to Create the Tenant Routes</h3>
<p>Create <code>src/routes/tenant.routes.ts</code>:</p>
<pre><code class="language-typescript">import { Router } from 'express';
import { createTenant, getTenant } from '../controllers/tenant.controller';

const router = Router();

router.post('/tenants', createTenant);
router.get('/tenants/:slug', getTenant);

export default router;
</code></pre>
<p>Your API now exposes two endpoints:</p>
<pre><code class="language-plaintext">POST   /api/tenants        — creates a new tenant
GET    /api/tenants/:slug  — retrieves a tenant and their template
</code></pre>
<h3 id="heading-how-to-start-the-server">How to Start the Server</h3>
<p>Add the dev script to your <code>package.json</code>:</p>
<pre><code class="language-json">{
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "seed": "tsx prisma/seed.ts"
  }
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">npm run dev
</code></pre>
<p>You should see <code>Server is running on port 8080</code> in your terminal. Your backend is ready.</p>
<h2 id="heading-how-to-create-the-nextjs-frontend">How to Create the Next.js Frontend</h2>
<p>Navigate to the <code>portfolio-client</code> directory and create a new Next.js project. Make sure to select <strong>Yes</strong> when the installer asks if you want to use Tailwind CSS:</p>
<pre><code class="language-bash">cd ../portfolio-client
npx create-next-app@latest . --typescript --app --tailwind --eslint
npm install
</code></pre>
<p>Since <code>create-next-app</code> sets up Tailwind for you automatically, no extra configuration is needed. The <code>tailwind.config.ts</code> and the <code>@tailwind</code> directives in <code>globals.css</code> are already in place.</p>
<h2 id="heading-how-to-add-subdomain-routing-with-middleware">How to Add Subdomain Routing with Middleware</h2>
<p>This is the heart of the multi-tenant architecture. You need a piece of code that runs before every request, reads the subdomain from the URL, and rewrites the request to the correct internal route — all without the user ever seeing the URL change.</p>
<p>Create a file called <code>proxy.ts</code> in the root directory:</p>
<pre><code class="language-typescript">import { NextRequest, NextResponse } from 'next/server';

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const host = request.headers.get('host') ?? '';

  const hostname = host.split(':')[0];
  const parts = hostname.split('.');

  // Local development: john.localhost:3000
  if (hostname.endsWith('localhost')) {
    const subdomain = parts[0];

    // Root localhost — load the landing page normally
    if (subdomain === 'localhost') {
      return NextResponse.next();
    }

    // Already rewritten — don't rewrite again
    if (pathname.startsWith('/tenant')) {
      return NextResponse.next();
    }

    return NextResponse.rewrite(new URL(`/tenant/${subdomain}`, request.url));
  }

  // Production: john.yourdomain.com
  if (parts.length &gt; 2) {
    const subdomain = parts[0];

    if (subdomain !== 'www') {
      if (pathname.startsWith('/tenant')) {
        return NextResponse.next();
      }

      return NextResponse.rewrite(new URL(`/tenant/${subdomain}`, request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)',
  ],
};
</code></pre>
<p>Here's exactly what this proxy does, step by step.</p>
<p>It reads the <code>host</code> header from every incoming request and splits it on <code>.</code> to extract the subdomain. For a request to <code>john.localhost:3000</code>, the subdomain is <code>john</code>. For a request directly to <code>localhost:3000</code>, the subdomain is <code>localhost</code> itself — in which case the proxy lets the request through unchanged so the landing page loads normally.</p>
<p>When a subdomain is detected, the proxy rewrites the request URL from <code>/</code> to <code>/tenant/john</code> internally. This rewrite is invisible to the browser — the user still sees <code>john.localhost:3000</code> in their address bar, but Next.js routes the request to your <code>/tenant/[slug]</code> page.</p>
<p>The <code>if (pathname.startsWith('/tenant'))</code> guard prevents infinite rewrite loops. Without it, the already-rewritten request would be rewritten again on the next pass through the middleware.</p>
<h2 id="heading-how-to-build-the-landing-page">How to Build the Landing Page</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6792df3bde63bedd84d043e5/5b879d6d-6307-42ce-9a6e-d1f47a789380.png" alt="The PortfolioSaaS landing page showing an empty form with fields for name, bio, and skills on a dark background" style="display: block;" width="1606" height="874" loading="lazy">

<h3 id="heading-how-to-update-the-layout">How to Update the Layout</h3>
<p>Open <code>app/layout.tsx</code> and update it:</p>
<pre><code class="language-typescript">import type { Metadata } from 'next';
import { Geist, Geist_Mono } from 'next/font/google';
import './globals.css';

const geistSans = Geist({
  variable: '--font-geist-sans',
  subsets: ['latin'],
});

const geistMono = Geist_Mono({
  variable: '--font-geist-mono',
  subsets: ['latin'],
});

export const metadata: Metadata = {
  title: 'Portfolio SaaS App',
  description: 'Create and host your portfolio with subdomains',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &lt;html lang="en"&gt;
      &lt;body className={`\({geistSans.variable} \){geistMono.variable} antialiased`}&gt;
        {children}
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<h3 id="heading-how-to-create-the-home-page">How to Create the Home Page</h3>
<p>Create <code>app/page.tsx</code>:</p>
<pre><code class="language-typescript">'use client';

import { useState } from 'react';

export default function Home() {
  const [name, setName] = useState('');
  const [bio, setBio] = useState('');
  const [skills, setSkills] = useState('');

  const handleSubmit = async () =&gt; {
    const res = await fetch('http://localhost:8080/api/tenants', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name,
        bio,
        skills: skills.split(',').map((s) =&gt; s.trim()),
      }),
    });

    const data = await res.json();
    window.location.href = `http://${data.slug}.localhost:3000`;
  };

  return (
    &lt;div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 text-white flex flex-col"&gt;
      {/* Header */}
      &lt;header className="flex items-center justify-between px-10 py-5"&gt;
        &lt;span className="text-xl font-semibold tracking-wide"&gt;PortfolioSaaS&lt;/span&gt;
        &lt;nav className="flex gap-6 text-sm text-slate-400"&gt;
          &lt;a href="#features" className="hover:text-white transition-colors"&gt;Features&lt;/a&gt;
          &lt;a href="#pricing" className="hover:text-white transition-colors"&gt;Pricing&lt;/a&gt;
          &lt;a href="#docs" className="hover:text-white transition-colors"&gt;Docs&lt;/a&gt;
        &lt;/nav&gt;
      &lt;/header&gt;

      {/* Main */}
      &lt;main className="flex flex-1 items-center justify-center px-6 py-10"&gt;
        &lt;div className="w-full max-w-md bg-slate-800 rounded-2xl p-10 shadow-2xl"&gt;
          &lt;h1 className="text-3xl font-bold mb-3"&gt;Create Your Portfolio&lt;/h1&gt;
          &lt;p className="text-slate-400 text-sm mb-8"&gt;
            Launch your personal portfolio instantly with your own subdomain.
          &lt;/p&gt;

          &lt;input
            type="text"
            placeholder="Your Name"
            onChange={(e) =&gt; setName(e.target.value)}
            className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 mb-4"
          /&gt;

          &lt;textarea
            placeholder="Short Bio"
            rows={4}
            onChange={(e) =&gt; setBio(e.target.value)}
            className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 mb-4 resize-none"
          /&gt;

          &lt;input
            type="text"
            placeholder="Skills (comma separated)"
            onChange={(e) =&gt; setSkills(e.target.value)}
            className="w-full bg-slate-900 border border-slate-700 rounded-lg px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500 mb-6"
          /&gt;

          &lt;button
            onClick={handleSubmit}
            className="w-full bg-indigo-600 hover:bg-indigo-500 text-white font-semibold py-3 rounded-lg transition-colors"
          &gt;
            Create Portfolio
          &lt;/button&gt;
        &lt;/div&gt;
      &lt;/main&gt;

      {/* Footer */}
      &lt;footer className="text-center text-xs text-slate-500 py-5 border-t border-slate-800"&gt;
        © {new Date().getFullYear()} PortfolioSaaS. All rights reserved.
      &lt;/footer&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>When a user submits the form, three things happen in sequence. First, a <code>POST</code> request creates a new tenant in your database. Second, the API returns the generated slug. Third, the browser redirects the user to their subdomain — <code>their-name.localhost:3000</code> — where the middleware takes over and renders their portfolio.</p>
<h2 id="heading-how-to-build-the-tenant-portfolio-page">How to Build the Tenant Portfolio Page</h2>
<p>Create <code>app/tenant/[slug]/page.tsx</code>:</p>
<pre><code class="language-typescript">import type { Metadata } from 'next';

async function getTenant(slug: string) {
  const res = await fetch(`http://localhost:8080/api/tenants/${slug}`, {
    cache: 'no-store',
  });
  return res.json();
}

export async function generateMetadata({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}): Promise&lt;Metadata&gt; {
  const { slug } = await params;
  const { tenant } = await getTenant(slug);

  if (!tenant) {
    return {
      title: 'Portfolio Not Found',
      description: 'This portfolio does not exist.',
      robots: { index: false, follow: false },
    };
  }

  return {
    title: tenant.name,
    description:
      tenant.bio?.slice(0, 160) ||
      `Explore ${tenant.name}'s professional portfolio.`,
    openGraph: {
      title: tenant.name,
      description: tenant.bio,
      type: 'website',
    },
  };
}

function initials(name: string) {
  return name
    .split(' ')
    .filter(Boolean)
    .slice(0, 2)
    .map((n) =&gt; n[0]?.toUpperCase())
    .join('');
}

export default async function TenantPage({
  params,
}: {
  params: Promise&lt;{ slug: string }&gt;;
}) {
  const { slug } = await params;
  const data = await getTenant(slug);

  const tenant = data?.tenant;
  const template = data?.template;

  if (!tenant) {
    return (
      &lt;div className="min-h-screen bg-slate-900 text-white flex items-center justify-center"&gt;
        &lt;h1 className="text-2xl font-bold text-slate-400"&gt;Portfolio not found&lt;/h1&gt;
      &lt;/div&gt;
    );
  }

  const primary = template?.config?.theme?.primaryColor || '#6366f1';

  // Template-driven section toggles with safe defaults
  const sections = {
    hero: true,
    about: true,
    skills: true,
    projects: true,
    blog: true,
    contact: true,
    ...(template?.config?.sections ?? {}),
  };

  const avatarUrl = tenant.avatarUrl as string | undefined;

  return (
    &lt;div className="min-h-screen bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-slate-100"&gt;

      {/* Header */}
      &lt;header className="sticky top-0 z-20 backdrop-blur-md bg-slate-900/60 border-b border-white/5"&gt;
        &lt;div className="max-w-5xl mx-auto px-5 py-4 flex items-center justify-between gap-4"&gt;
          &lt;div className="flex items-center gap-3 min-w-0"&gt;
            &lt;span
              className="w-2.5 h-2.5 rounded-full shrink-0"
              style={{ backgroundColor: primary }}
            /&gt;
            &lt;span className="font-semibold truncate"&gt;{tenant.name}&lt;/span&gt;
          &lt;/div&gt;

          &lt;nav className="hidden md:flex items-center gap-5 text-sm text-slate-400"&gt;
            {(sections.hero || sections.about) &amp;&amp; (
              &lt;a href="#about" className="hover:text-white transition-colors"&gt;About&lt;/a&gt;
            )}
            {sections.skills &amp;&amp; (
              &lt;a href="#skills" className="hover:text-white transition-colors"&gt;Skills&lt;/a&gt;
            )}
            {sections.projects &amp;&amp; (
              &lt;a href="#projects" className="hover:text-white transition-colors"&gt;Projects&lt;/a&gt;
            )}
            {sections.blog &amp;&amp; (
              &lt;a href="#blog" className="hover:text-white transition-colors"&gt;Blog&lt;/a&gt;
            )}
            {sections.contact &amp;&amp; (
              &lt;a href="#contact" className="hover:text-white transition-colors"&gt;Contact&lt;/a&gt;
            )}
          &lt;/nav&gt;

          {sections.contact &amp;&amp; (
            &lt;a
              href="#contact"
              className="text-sm font-semibold px-4 py-2 rounded-full transition-transform hover:-translate-y-px"
              style={{ backgroundColor: primary, color: '#0b1020' }}
            &gt;
              Hire me
            &lt;/a&gt;
          )}
        &lt;/div&gt;
      &lt;/header&gt;

      {/* Hero / About */}
      {(sections.hero || sections.about) &amp;&amp; (
        &lt;section className="px-5 pt-20 pb-14" id="about"&gt;
          &lt;div className="max-w-5xl mx-auto bg-white/[0.04] border border-white/[0.08] rounded-2xl p-7 shadow-2xl grid grid-cols-[110px_1fr] gap-6 items-center"&gt;

            {/* Avatar */}
            &lt;div className="w-[110px] h-[110px] rounded-full overflow-hidden border border-white/10 bg-white/5 flex items-center justify-center shrink-0"&gt;
              {avatarUrl ? (
                // eslint-disable-next-line @next/next/no-img-element
                &lt;img src={avatarUrl} alt={`${tenant.name} avatar`} className="w-full h-full object-cover" /&gt;
              ) : (
                &lt;span className="text-2xl font-extrabold text-slate-200 tracking-tight"&gt;
                  {initials(tenant.name)}
                &lt;/span&gt;
              )}
            &lt;/div&gt;

            {/* Text */}
            &lt;div className="min-w-0"&gt;
              &lt;h1
                className="text-5xl font-extrabold tracking-tight leading-tight mb-3"
                style={{ color: primary }}
              &gt;
                {tenant.name}
              &lt;/h1&gt;
              &lt;p className="text-slate-400 text-base leading-relaxed max-w-2xl"&gt;
                {tenant.bio}
              &lt;/p&gt;
              &lt;div className="flex flex-wrap gap-3 mt-5"&gt;
                {sections.contact &amp;&amp; (
                  &lt;a
                    href="#contact"
                    className="inline-flex items-center px-5 py-2.5 rounded-full text-sm font-semibold transition-transform hover:-translate-y-px"
                    style={{ backgroundColor: primary, color: '#0b1020' }}
                  &gt;
                    Let&amp;apos;s connect
                  &lt;/a&gt;
                )}
                {sections.skills &amp;&amp; (
                  &lt;a
                    href="#skills"
                    className="inline-flex items-center px-5 py-2.5 rounded-full text-sm font-semibold text-slate-200 border border-white/10 bg-white/5 hover:border-white/20 transition-all hover:-translate-y-px"
                  &gt;
                    View skills
                  &lt;/a&gt;
                )}
              &lt;/div&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/section&gt;
      )}

      {/* Skills */}
      {sections.skills &amp;&amp; (
        &lt;section className="px-5 py-14 max-w-5xl mx-auto" id="skills"&gt;
          &lt;h2 className="text-2xl font-bold text-center tracking-tight mb-7"&gt;Skills&lt;/h2&gt;
          &lt;div className="flex flex-wrap gap-3 justify-center"&gt;
            {tenant.skills.map((skill: string) =&gt; (
              &lt;span
                key={skill}
                className="px-4 py-2 rounded-full text-sm font-semibold bg-white/5 backdrop-blur-sm hover:-translate-y-0.5 hover:shadow-lg transition-all"
                style={{ border: `1px solid ${primary}` }}
              &gt;
                {skill}
              &lt;/span&gt;
            ))}
          &lt;/div&gt;
        &lt;/section&gt;
      )}

      {/* Projects */}
      {sections.projects &amp;&amp; (
        &lt;section className="px-5 py-14 max-w-5xl mx-auto" id="projects"&gt;
          &lt;h2 className="text-2xl font-bold text-center tracking-tight mb-7"&gt;Projects&lt;/h2&gt;
          &lt;div className="flex flex-wrap gap-3 justify-center"&gt;
            {['Portfolio SaaS', 'Multi-tenant Routing', 'Template Builder'].map((p) =&gt; (
              &lt;span
                key={p}
                className="px-4 py-2 rounded-full text-sm font-semibold bg-white/5 backdrop-blur-sm hover:-translate-y-0.5 hover:shadow-lg transition-all"
                style={{ border: `1px solid ${primary}` }}
              &gt;
                {p}
              &lt;/span&gt;
            ))}
          &lt;/div&gt;
        &lt;/section&gt;
      )}

      {/* Blog */}
      {sections.blog &amp;&amp; (
        &lt;section className="px-5 py-14 max-w-5xl mx-auto" id="blog"&gt;
          &lt;h2 className="text-2xl font-bold text-center tracking-tight mb-7"&gt;Blog&lt;/h2&gt;
          &lt;div className="flex flex-wrap gap-3 justify-center"&gt;
            {[
              'How I built this portfolio',
              'Next.js Middleware Tips',
              'Designing Templates',
            ].map((post) =&gt; (
              &lt;span
                key={post}
                className="px-4 py-2 rounded-full text-sm font-semibold bg-white/5 backdrop-blur-sm hover:-translate-y-0.5 hover:shadow-lg transition-all"
                style={{ border: `1px solid ${primary}` }}
              &gt;
                {post}
              &lt;/span&gt;
            ))}
          &lt;/div&gt;
        &lt;/section&gt;
      )}

      {/* Contact */}
      {sections.contact &amp;&amp; (
        &lt;section className="px-5 pt-2 pb-16" id="contact"&gt;
          &lt;div className="max-w-3xl mx-auto bg-white/[0.04] border border-white/[0.08] rounded-2xl p-7"&gt;
            &lt;h2 className="text-xl font-bold mb-2"&gt;Contact&lt;/h2&gt;
            &lt;p className="text-slate-400 leading-relaxed mb-5"&gt;
              Want to work together? Send a message and I&amp;apos;ll reply quickly.
            &lt;/p&gt;
            &lt;div className="flex flex-wrap gap-3"&gt;
              &lt;a
                href={`mailto:hello@${tenant.slug}.com`}
                className="inline-flex items-center px-5 py-2.5 rounded-full text-sm font-semibold transition-transform hover:-translate-y-px"
                style={{ backgroundColor: primary, color: '#0b1020' }}
              &gt;
                Email me
              &lt;/a&gt;
              &lt;a
                href="#about"
                className="inline-flex items-center px-5 py-2.5 rounded-full text-sm font-semibold text-slate-200 border border-white/10 bg-white/5 hover:border-white/20 transition-all hover:-translate-y-px"
              &gt;
                Back to top
              &lt;/a&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/section&gt;
      )}

      {/* Footer */}
      &lt;footer className="text-center text-xs text-slate-500 py-5 border-t border-white/5"&gt;
        © {new Date().getFullYear()} {tenant.name}
      &lt;/footer&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>There are a few important details in this page worth calling out.</p>
<p>The <code>generateMetadata</code> function runs server-side before the page renders and sets the page title, description, and Open Graph tags for each tenant individually. This means every portfolio gets its own SEO metadata — important for a real SaaS product.</p>
<p>The <code>sections</code> object merges safe defaults (<code>hero: true</code>, <code>skills: true</code>, and so on) with whatever the tenant's template specifies. This means even if the template JSON is missing a key, the page won't break — the section will simply fall back to being shown.</p>
<p>The <code>initials</code> helper generates a two-letter avatar placeholder from the tenant's name when no profile image is available. "Jane Smith" produces "JS" — a small detail that makes the portfolio look polished even before a user adds a photo.</p>
<p>Notice that the primary color from the template JSON (<code>theme.primaryColor</code>) is applied using the <code>style</code> prop rather than a Tailwind class. This is intentional. Tailwind generates class names at build time and cannot know the dynamic color value stored in your database. Inline styles are the correct approach whenever a CSS value is truly dynamic.</p>
<h2 id="heading-how-to-test-the-full-flow">How to Test the Full Flow</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6792df3bde63bedd84d043e5/ed282eea-60a0-456c-a6b5-ba4463bae338.png" alt="The PortfolioSaaS form filled in with Alex Morgan's name, bio, and a comma-separated list of skills ready to submit" style="display: block;" width="1612" height="871" loading="lazy">

<p>Start both servers in separate terminal windows:</p>
<pre><code class="language-bash"># Terminal 1 — Backend
cd portfolio-api
npm run dev

# Terminal 2 — Frontend
cd portfolio-client
npm run dev
</code></pre>
<p>Now test the complete flow:</p>
<ol>
<li><p>Visit <code>http://localhost:3000</code> and fill out the form with your name, a short bio, and a comma-separated list of skills.</p>
</li>
<li><p>Click <strong>Create Portfolio</strong>. The form submits to your Express API, which creates the tenant record and returns the slug.</p>
</li>
<li><p>Your browser redirects to <code>http://your-name.localhost:3000</code>.</p>
</li>
<li><p>The Next.js middleware detects the subdomain, rewrites the request to <code>/tenant/your-name</code>, and your portfolio page fetches and renders your data.</p>
</li>
</ol>
<p>You should see a fully rendered portfolio page with your name, bio, skills, and the placeholder projects and blog sections — all styled with Tailwind utility classes.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6792df3bde63bedd84d043e5/6bb311aa-e55b-4444-9b5e-2b778daccc5e.png" alt="Alex Morgan's generated portfolio page showing the hero section with initials avatar, bio, skills badges, and projects section" style="display: block;" width="1600" height="903" loading="lazy">

<h2 id="heading-next-steps">Next Steps</h2>
<p>You now have a working multi-tenant SaaS foundation. Here are some extensions worth considering for a production build:</p>
<p>You could add authentication with NextAuth.js so tenants can log in and update their portfolio without losing their data between sessions.</p>
<p>You could also add custom domain support so tenants can point their own domain (for example, <code>janedoe.com</code>) to their portfolio by adding a CNAME record. You would need to handle wildcard SSL certificates on your hosting provider.</p>
<p>You could add image uploads for avatars using Cloudinary or AWS S3, then store the URL in the tenant record and replace the initials fallback with a real photo.</p>
<p>You could add real blog post management using the <code>Post</code> model already defined in your schema. Tenants could write and publish posts that appear on their portfolio.</p>
<p>And you could add Stripe subscriptions so tenants pay a monthly fee to keep their portfolio live. The architecture from the Stripe Connect tutorial maps directly onto this.</p>
<p>Finally, you could deploy the backend to Railway or Render and the frontend to Vercel. Just make sure to update your API URLs from <code>localhost:8080</code> to your production URL before deploying.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>In this tutorial, you built a complete multi-tenant SaaS platform where users can sign up, get their own subdomain, and have a portfolio site generated instantly — all from a single codebase.</p>
<p>You learned how to use Next.js middleware to detect subdomains and rewrite requests dynamically, model multi-tenant data in Prisma with a slug-based routing system, build a JSON-driven template system that controls page layout without code changes, and style a production-ready Next.js frontend entirely with Tailwind CSS utility classes.</p>
<p>The core insight is that multi-tenancy isn't magic. It's subdomain detection plus dynamic routing plus isolated data. Once you understand those three moving parts, you can apply this pattern to any SaaS product you build.</p>
<p>If you found this tutorial helpful, share it with someone who is learning to build full-stack applications. Happy coding!</p>
<h2 id="heading-source-code">Source Code</h2>
<p>You can find the complete source code for both parts of this project on GitHub:</p>
<ul>
<li><strong>Frontend (Next.js multi-tenant app) + Backend (Express + Prisma API):</strong> <a href="https://github.com/michaelokolo/portfolio-saas-v1">https://github.com/michaelokolo/portfolio-saas-v1</a></li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ A Developer’s Guide to Lazy Loading in React and Next.js ]]>
                </title>
                <description>
                    <![CDATA[ Large JavaScript bundles can slow down your application. When too much code loads at once, users wait longer for the first paint and pages feel less responsive. Search engines may also rank slower sit ]]>
                </description>
                <link>https://www.freecodecamp.org/news/a-developers-guide-to-lazy-loading-in-react-and-nextjs/</link>
                <guid isPermaLink="false">69dea43f91716f3cfb762c99</guid>
                
                    <category>
                        <![CDATA[ React ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Tue, 14 Apr 2026 20:31:59 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9e6d8733-23e7-4dab-8da2-98fbbc1c44e9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Large JavaScript bundles can slow down your application. When too much code loads at once, users wait longer for the first paint and pages feel less responsive. Search engines may also rank slower sites lower in results.</p>
<p>Lazy loading helps solve this problem by splitting your code into smaller chunks and loading them only when they are needed</p>
<p>This guide walks you through lazy loading in React and Next.js. By the end, you'll know when to use <code>React.lazy</code>, <code>next/dynamic</code>, and <code>Suspense</code>, and you'll have working examples you can copy and adapt to your own projects.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-lazy-loading">What is Lazy Loading?</a></p>
</li>
<li><p><a href="#heading-prequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-use-reactlazy-for-code-splitting">How to Use React.lazy for Code Splitting</a></p>
</li>
<li><p><a href="#heading-how-to-use-suspense-with-reactlazy">How to Use Suspense with React.lazy</a></p>
</li>
<li><p><a href="#heading-how-to-handle-errors-with-error-boundaries">How to Handle Errors with Error Boundaries</a></p>
</li>
<li><p><a href="#heading-how-to-use-nextdynamic-in-nextjs">How to Use next/dynamic in Next.js</a></p>
</li>
<li><p><a href="#heading-reactlazy-vs-nextdynamic-when-to-use-each">React.lazy vs next/dynamic: When to Use Each</a></p>
</li>
<li><p><a href="#heading-real-world-examples">Real-World Examples</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-lazy-loading">What is Lazy Loading?</h2>
<p>Lazy loading is a performance technique that defers loading code until it's needed. Instead of loading your entire app at once, you split it into smaller chunks. The browser only downloads a chunk when the user navigates to that route or interacts with that feature.</p>
<p>Benefits include:</p>
<ul>
<li><p><strong>Faster initial load</strong>: Smaller first bundle means quicker time to interactive</p>
</li>
<li><p><strong>Better Core Web Vitals</strong>: Improves Largest Contentful Paint and Total Blocking Time</p>
</li>
<li><p><strong>Lower bandwidth</strong>: Users only download what they use</p>
</li>
</ul>
<p>In React, you achieve this with dynamic imports and <code>React.lazy()</code> or Next.js’s <code>next/dynamic</code>.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you follow along, you should have:</p>
<ul>
<li><p>Basic familiarity with React (components, hooks, state)</p>
</li>
<li><p>Node.js installed (version 18 or later recommended)</p>
</li>
<li><p>A React app (Create React App or Vite) or a Next.js app (for the Next.js examples)</p>
</li>
</ul>
<p>For the React examples, you can use Create React App or Vite. For the Next.js examples, use the App Router (Next.js 13 or later).</p>
<h2 id="heading-how-to-use-reactlazy-for-code-splitting">How to Use <code>React.lazy</code> for Code Splitting</h2>
<p><code>React.lazy()</code> lets you define a component as a dynamic import. React will load that component only when it's first rendered.</p>
<p><code>React.lazy()</code> expects a function that returns a dynamic <code>import()</code>. The imported module must use a default export.</p>
<p>Here's a basic example:</p>
<pre><code class="language-jsx">import { lazy } from 'react';

const HeavyChart = lazy(() =&gt; import('./HeavyChart'));
const AdminDashboard = lazy(() =&gt; import('./AdminDashboard'));

function App() {
  return (
    &lt;div&gt;
      &lt;h1&gt;My App&lt;/h1&gt;
      &lt;HeavyChart /&gt;
      &lt;AdminDashboard /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>If you use named exports, you can map them to a default export:</p>
<pre><code class="language-jsx">const ComponentWithNamedExport = lazy(() =&gt;
  import('./MyComponent').then((module) =&gt; ({
    default: module.NamedComponent,
  }))
);
</code></pre>
<p>You can also name chunks for easier debugging in the browser:</p>
<pre><code class="language-jsx">const HeavyChart = lazy(() =&gt;
  import(/* webpackChunkName: "heavy-chart" */ './HeavyChart')
);
</code></pre>
<p><code>React.lazy()</code> alone isn't enough. You must wrap lazy components in <code>Suspense</code> so React knows what to show while they load.</p>
<h2 id="heading-how-to-use-suspense-with-reactlazy">How to Use <code>Suspense</code> with <code>React.lazy</code></h2>
<p><code>Suspense</code> is a React component that shows a fallback UI while its children are loading. It works with <code>React.lazy()</code> to handle the loading state of dynamically imported components.</p>
<p>Wrap your lazy components in <code>Suspense</code> and provide a <code>fallback</code> prop:</p>
<pre><code class="language-jsx">import { lazy, Suspense } from 'react';

const HeavyChart = lazy(() =&gt; import('./HeavyChart'));
const AdminDashboard = lazy(() =&gt; import('./AdminDashboard'));

function App() {
  return (
    &lt;div&gt;
      &lt;h1&gt;My App&lt;/h1&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading chart...&lt;/div&gt;}&gt;
        &lt;HeavyChart /&gt;
      &lt;/Suspense&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading dashboard...&lt;/div&gt;}&gt;
        &lt;AdminDashboard /&gt;
      &lt;/Suspense&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>You can use a single <code>Suspense</code> boundary for multiple lazy components:</p>
<pre><code class="language-jsx">&lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
  &lt;HeavyChart /&gt;
  &lt;AdminDashboard /&gt;
&lt;/Suspense&gt;
</code></pre>
<p>A more polished fallback improves perceived performance:</p>
<pre><code class="language-jsx">function LoadingSpinner() {
  return (
    &lt;div className="loading-container"&gt;
      &lt;div className="spinner" /&gt;
      &lt;p&gt;Loading...&lt;/p&gt;
    &lt;/div&gt;
  );
}

&lt;Suspense fallback={&lt;LoadingSpinner /&gt;}&gt;
  &lt;HeavyChart /&gt;
&lt;/Suspense&gt;
</code></pre>
<h2 id="heading-how-to-handle-errors-with-error-boundaries">How to Handle Errors with Error Boundaries</h2>
<p><code>React.lazy()</code> and <code>Suspense</code> don't handle loading errors (for example, network failures or missing chunks). For that, you need an Error Boundary.</p>
<p>Error Boundaries are class components that use <code>componentDidCatch</code> or <code>static getDerivedStateFromError</code> to catch errors in their child tree and render a fallback UI.</p>
<p>Here is a simple Error Boundary:</p>
<pre><code class="language-jsx">import { Component } from 'react';

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || &lt;div&gt;Something went wrong.&lt;/div&gt;;
    }
    return this.props.children;
  }
}
</code></pre>
<p>Wrap your <code>Suspense</code> boundary with an Error Boundary:</p>
<pre><code class="language-jsx">import { lazy, Suspense } from 'react';
import ErrorBoundary from './ErrorBoundary';

const HeavyChart = lazy(() =&gt; import('./HeavyChart'));

function App() {
  return (
    &lt;ErrorBoundary fallback={&lt;div&gt;Failed to load chart. Please try again.&lt;/div&gt;}&gt;
      &lt;Suspense fallback={&lt;div&gt;Loading chart...&lt;/div&gt;}&gt;
        &lt;HeavyChart /&gt;
      &lt;/Suspense&gt;
    &lt;/ErrorBoundary&gt;
  );
}
</code></pre>
<p>If the chunk fails to load, the Error Boundary catches it and shows your fallback instead of a blank screen or unhandled error.</p>
<h2 id="heading-how-to-use-nextdynamic-in-nextjs">How to Use <code>next/dynamic</code> in Next.js</h2>
<p>Next.js provides <code>next/dynamic</code>, which wraps <code>React.lazy()</code> and <code>Suspense</code> and adds options tailored for Next.js (including Server-Side Rendering).</p>
<p>Basic usage:</p>
<pre><code class="language-jsx">'use client';

import dynamic from 'next/dynamic';

const ComponentA = dynamic(() =&gt; import('../components/A'));
const ComponentB = dynamic(() =&gt; import('../components/B'));

export default function Page() {
  return (
    &lt;div&gt;
      &lt;ComponentA /&gt;
      &lt;ComponentB /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-custom-loading-ui">Custom Loading UI</h3>
<p>Use the <code>loading</code> option to show a placeholder while the component loads:</p>
<pre><code class="language-jsx">const HeavyChart = dynamic(() =&gt; import('../components/HeavyChart'), {
  loading: () =&gt; &lt;p&gt;Loading chart...&lt;/p&gt;,
});
</code></pre>
<h3 id="heading-disable-server-side-rendering">Disable Server-Side Rendering</h3>
<p>For components that must run only on the client (for example, those using <code>window</code> or browser-only APIs), set <code>ssr: false</code>:</p>
<pre><code class="language-jsx">const ClientOnlyMap = dynamic(() =&gt; import('../components/Map'), {
  ssr: false,
  loading: () =&gt; &lt;p&gt;Loading map...&lt;/p&gt;,
});
</code></pre>
<p>Note: <code>ssr: false</code> works only for Client Components. Use it inside a <code>'use client'</code> file.</p>
<h3 id="heading-load-on-demand">Load on Demand</h3>
<p>You can load a component only when a condition is met:</p>
<pre><code class="language-jsx">'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() =&gt; import('../components/Modal'), {
  loading: () =&gt; &lt;p&gt;Opening modal...&lt;/p&gt;,
});

export default function Page() {
  const [showModal, setShowModal] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setShowModal(true)}&gt;Open Modal&lt;/button&gt;
      {showModal &amp;&amp; &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-named-exports">Named Exports</h3>
<p>For named exports, return the component from the dynamic import:</p>
<pre><code class="language-jsx">const Hello = dynamic(() =&gt;
  import('../components/hello').then((mod) =&gt; mod.Hello)
);
</code></pre>
<h3 id="heading-using-suspense-with-nextdynamic">Using Suspense with next/dynamic</h3>
<p>In React 18+, you can use <code>suspense: true</code> to rely on a parent <code>Suspense</code> boundary instead of the <code>loading</code> option:</p>
<pre><code class="language-jsx">const HeavyChart = dynamic(() =&gt; import('../components/HeavyChart'), {
  suspense: true,
});

// In your component:
&lt;Suspense fallback={&lt;div&gt;Loading...&lt;/div&gt;}&gt;
  &lt;HeavyChart /&gt;
&lt;/Suspense&gt;
</code></pre>
<p>Important: When using <code>suspense: true</code>, you can't use <code>ssr: false</code> or the <code>loading</code> option. Use the <code>Suspense</code> fallback instead.</p>
<h2 id="heading-reactlazy-vs-nextdynamic-when-to-use-each"><code>React.lazy</code> vs <code>next/dynamic</code>: When to Use Each</h2>
<table>
<thead>
<tr>
<th>Feature</th>
<th>React.lazy + Suspense</th>
<th>next/dynamic</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Framework</strong></td>
<td>Any React app (Create React App, Vite, etc.)</td>
<td>Next.js only</td>
</tr>
<tr>
<td><strong>Server-Side Rendering</strong></td>
<td>Not supported</td>
<td>Supported by default</td>
</tr>
<tr>
<td><strong>Disable SSR</strong></td>
<td>N/A</td>
<td><code>ssr: false</code> option</td>
</tr>
<tr>
<td><strong>Loading UI</strong></td>
<td><code>Suspense</code> fallback prop</td>
<td>Built-in <code>loading</code> option</td>
</tr>
<tr>
<td><strong>Error handling</strong></td>
<td>Requires Error Boundary</td>
<td>Requires Error Boundary</td>
</tr>
<tr>
<td><strong>Named exports</strong></td>
<td>Manual <code>.then()</code> mapping</td>
<td>Same <code>.then()</code> pattern</td>
</tr>
<tr>
<td><strong>Suspense mode</strong></td>
<td>Always uses Suspense</td>
<td>Optional via <code>suspense: true</code></td>
</tr>
</tbody></table>
<h3 id="heading-when-to-use-reactlazy">When to Use <code>React.lazy</code></h3>
<ul>
<li><p>You're building a <strong>pure React app</strong> (no Next.js)</p>
</li>
<li><p>You use Create React App, Vite, or a custom Webpack setup</p>
</li>
<li><p>You don't need Server-Side Rendering</p>
</li>
<li><p>You want a simple, framework-agnostic approach</p>
</li>
</ul>
<h3 id="heading-when-to-use-nextdynamic">When to <code>Use next/dynamic</code></h3>
<ul>
<li><p>You're building a <strong>Next.js app</strong></p>
</li>
<li><p>You need SSR for some components and want to disable it for others</p>
</li>
<li><p>You want built-in loading placeholders without manually adding <code>Suspense</code></p>
</li>
<li><p>You want Next.js-specific optimizations and defaults</p>
</li>
</ul>
<h2 id="heading-real-world-examples">Real-World Examples</h2>
<h3 id="heading-example-1-route-based-code-splitting-in-react">Example 1: Route-Based Code Splitting in React</h3>
<p>Split your app by route so each page loads only when the user navigates to it:</p>
<pre><code class="language-jsx">// App.jsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import ErrorBoundary from './ErrorBoundary';

const Home = lazy(() =&gt; import('./pages/Home'));
const Dashboard = lazy(() =&gt; import('./pages/Dashboard'));
const Settings = lazy(() =&gt; import('./pages/Settings'));

function App() {
  return (
    &lt;BrowserRouter&gt;
      &lt;ErrorBoundary fallback={&lt;div&gt;Failed to load page.&lt;/div&gt;}&gt;
        &lt;Suspense fallback={&lt;div&gt;Loading page...&lt;/div&gt;}&gt;
          &lt;Routes&gt;
            &lt;Route path="/" element={&lt;Home /&gt;} /&gt;
            &lt;Route path="/dashboard" element={&lt;Dashboard /&gt;} /&gt;
            &lt;Route path="/settings" element={&lt;Settings /&gt;} /&gt;
          &lt;/Routes&gt;
        &lt;/Suspense&gt;
      &lt;/ErrorBoundary&gt;
    &lt;/BrowserRouter&gt;
  );
}
</code></pre>
<h3 id="heading-example-2-lazy-loading-a-heavy-chart-library-in-nextjs">Example 2: Lazy Loading a Heavy Chart Library in Next.js</h3>
<p>Defer loading a chart library until the user opens the analytics section:</p>
<pre><code class="language-jsx">// app/analytics/page.jsx
'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const Chart = dynamic(() =&gt; import('../components/Chart'), {
  ssr: false,
  loading: () =&gt; (
    &lt;div className="chart-skeleton"&gt;
      &lt;div className="skeleton-bar" /&gt;
      &lt;div className="skeleton-bar" /&gt;
      &lt;div className="skeleton-bar" /&gt;
    &lt;/div&gt;
  ),
});

export default function AnalyticsPage() {
  const [showChart, setShowChart] = useState(false);

  return (
    &lt;div&gt;
      &lt;h1&gt;Analytics&lt;/h1&gt;
      &lt;button onClick={() =&gt; setShowChart(true)}&gt;Load Chart&lt;/button&gt;
      {showChart &amp;&amp; &lt;Chart /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-example-3-lazy-loading-a-modal">Example 3: Lazy Loading a Modal</h3>
<p>Load a modal component only when the user clicks to open it:</p>
<pre><code class="language-jsx">// React (with React.lazy)
import { lazy, Suspense, useState } from 'react';

const Modal = lazy(() =&gt; import('./Modal'));

function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setShowModal(true)}&gt;Add to Cart&lt;/button&gt;
      {showModal &amp;&amp; (
        &lt;Suspense fallback={null}&gt;
          &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;
        &lt;/Suspense&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// Next.js (with next/dynamic)
'use client';

import { useState } from 'react';
import dynamic from 'next/dynamic';

const Modal = dynamic(() =&gt; import('./Modal'), {
  loading: () =&gt; null,
});

export default function ProductPage() {
  const [showModal, setShowModal] = useState(false);

  return (
    &lt;div&gt;
      &lt;button onClick={() =&gt; setShowModal(true)}&gt;Add to Cart&lt;/button&gt;
      {showModal &amp;&amp; &lt;Modal onClose={() =&gt; setShowModal(false)} /&gt;}
    &lt;/div&gt;
  );
}
</code></pre>
<h3 id="heading-example-4-lazy-loading-external-libraries">Example 4: Lazy Loading External Libraries</h3>
<p>Load a library only when the user needs it (for example, when they start typing in a search box):</p>
<pre><code class="language-jsx">'use client';

import { useState } from 'react';

const names = ['Alice', 'Bob', 'Charlie', 'Diana'];

export default function SearchPage() {
  const [results, setResults] = useState([]);
  const [query, setQuery] = useState('');

  const handleSearch = async (value) =&gt; {
    setQuery(value);
    if (!value) {
      setResults([]);
      return;
    }
    // Load fuse.js only when user searches
    const Fuse = (await import('fuse.js')).default;
    const fuse = new Fuse(names);
    setResults(fuse.search(value));
  };

  return (
    &lt;div&gt;
      &lt;input
        type="text"
        placeholder="Search..."
        value={query}
        onChange={(e) =&gt; handleSearch(e.target.value)}
      /&gt;
      &lt;ul&gt;
        {results.map((result) =&gt; (
          &lt;li key={result.refIndex}&gt;{result.item}&lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Lazy loading improves performance by splitting your bundle and loading code only when needed. Here's what you learned:</p>
<ul>
<li><p><strong>React.lazy()</strong> – Use in plain React apps for code splitting. It requires a default export and works with dynamic <code>import()</code>.</p>
</li>
<li><p><strong>Suspense</strong> – Wrap lazy components in <code>Suspense</code> and provide a <code>fallback</code> for the loading state.</p>
</li>
<li><p><strong>Error Boundaries</strong> – Use them to catch chunk load failures and show a friendly error UI.</p>
</li>
<li><p><strong>next/dynamic</strong> – Use in Next.js for the same benefits plus SSR control and built-in loading options.</p>
</li>
</ul>
<p>Choose <code>React.lazy</code> for React-only projects and <code>next/dynamic</code> for Next.js. Combine them with <code>Suspense</code> and Error Boundaries for a solid lazy-loading setup.</p>
<p>Start by identifying your heaviest components (charts, modals, admin panels) and lazy load them. Measure your bundle size and Core Web Vitals before and after to see the impact.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Online Marketplace with Next.js, Express, and Stripe Connect ]]>
                </title>
                <description>
                    <![CDATA[ Have you ever wondered how platforms like Etsy, Uber, or Teachable handle payments for thousands of sellers? The answer is a multi-vendor marketplace: an application where merchants can sign up, list  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-online-marketplace-with-next-js-express-stripe-connect/</link>
                <guid isPermaLink="false">69d7ca9dfa7251682ec4b098</guid>
                
                    <category>
                        <![CDATA[ stripe ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Michael Okolo ]]>
                </dc:creator>
                <pubDate>Thu, 09 Apr 2026 15:49:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1181805a-87ae-440d-9673-64efeb073aad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Have you ever wondered how platforms like Etsy, Uber, or Teachable handle payments for thousands of sellers? The answer is a <strong>multi-vendor marketplace</strong>: an application where merchants can sign up, list products or services, and receive payments directly from customers.</p>
<p>In this handbook, you'll build a complete marketplace from scratch using TypeScript. You won't need a traditional database. Instead, you'll use Stripe as your product catalog and payment engine.</p>
<p>This is how many real-world marketplaces work: Stripe stores the products, prices, and customer data, while your application handles the user experience.</p>
<p>Here's what you'll build:</p>
<ol>
<li><p>A merchant onboarding flow where sellers create accounts and connect with Stripe</p>
</li>
<li><p>A product management system where merchants can add and list products directly through Stripe</p>
</li>
<li><p>A checkout flow that supports both one-time payments and recurring subscriptions</p>
</li>
<li><p>Webhooks that listen for payment events in real time</p>
</li>
<li><p>A billing portal where customers can manage their subscriptions</p>
</li>
<li><p>A complete storefront where customers can browse and buy products</p>
</li>
</ol>
<p>You can also grab the complete source code from the GitHub repository linked at the end.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-stripe-connect">What is Stripe Connect?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-the-backend">How to Set Up the Backend</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-express-backend">How to Build the Express Backend</a></p>
</li>
<li><p><a href="#heading-how-to-handle-merchant-onboarding">How to Handle Merchant Onboarding</a></p>
<ul>
<li><p><a href="#heading-how-to-create-a-connected-account">How to Create a Connected Account</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-onboarding-link">How to Create the Onboarding Link</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-check-account-status">How to Check Account Status</a></p>
</li>
<li><p><a href="#heading-how-to-create-products-through-stripe">How to Create Products Through Stripe</a></p>
</li>
<li><p><a href="#heading-how-to-fetch-products">How to Fetch Products</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-checkout-flow">How to Build the Checkout Flow</a></p>
</li>
<li><p><a href="#heading-how-to-handle-webhooks">How to Handle Webhooks</a></p>
</li>
<li><p><a href="#heading-how-to-configure-webhooks-in-the-stripe-dashboard">How to Configure Webhooks in the Stripe Dashboard</a></p>
</li>
<li><p><a href="#heading-how-to-test-webhooks-locally">How to Test Webhooks Locally</a></p>
</li>
<li><p><a href="#heading-how-to-add-the-billing-portal">How to Add the Billing Portal</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-nextjs-frontend">How to Build the Next.js Frontend</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-account-context">How to Create the Account Context</a></p>
</li>
<li><p><a href="#heading-how-to-create-the-account-status-hook">How to Create the Account Status Hook</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-merchant-onboarding-component">How to Build the Merchant Onboarding Component</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-product-create-product-list-and-checkout">How to Build the Product Create, Product List and Checkout</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-product-form">How to Build the Product Form</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-main-page">How to Build the Main Page</a></p>
</li>
<li><p><a href="#heading-how-to-test-the-full-flow">How to Test the Full Flow</a></p>
</li>
<li><p><a href="#heading-how-the-payment-split-works">How the Payment Split Works</a></p>
</li>
<li><p><a href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a href="#heading-acknowledgements">Acknowledgements</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you begin, make sure you have the following:</p>
<ol>
<li><p>Node.js (version 18 or higher) installed on your machine</p>
</li>
<li><p>A basic understanding of React, TypeScript, and REST APIs</p>
</li>
<li><p>A Stripe account (sign up for free at <a href="http://stripe.com">stripe.com</a>)</p>
</li>
<li><p>A code editor like VS Code</p>
</li>
</ol>
<p>You do <strong>not</strong> need a database for this project. Stripe will store your products, prices, and customer information. This keeps the architecture simple and mirrors how many production marketplaces actually work.</p>
<h2 id="heading-what-is-stripe-connect"><strong>What is Stripe Connect?</strong></h2>
<p>Stripe Connect is a set of APIs designed for platforms and marketplaces. It lets you create accounts for your merchants (Stripe calls them "connected accounts"), route payments to them, and take a platform fee on every transaction.</p>
<p>In this tutorial, you will use Stripe’s <strong>V2 Accounts API</strong>, which is the newer and recommended way to create connected accounts. With the V2 API, you configure what each account can do (accept card payments, receive payouts) through a configuration object, and Stripe handles all compliance and identity verification through a hosted onboarding flow.</p>
<p>Here's how the payment flow works:</p>
<ol>
<li><p>A customer selects a product and clicks checkout on your marketplace.</p>
</li>
<li><p>Your server creates a Stripe Checkout Session linked to the merchant’s connected account.</p>
</li>
<li><p>The customer pays on Stripe’s hosted checkout page.</p>
</li>
<li><p>Stripe automatically splits the payment: the merchant gets their share, and your platform keeps an application fee.</p>
</li>
<li><p>Stripe sends a webhook event to your server confirming the payment.</p>
</li>
<li><p>The merchant can view their earnings and withdraw funds from their Stripe dashboard.</p>
</li>
</ol>
<h2 id="heading-how-to-set-up-the-project"><strong>How to Set Up the Project</strong></h2>
<p>Create a project folder with separate directories for your backend and frontend:</p>
<pre><code class="language-shell">mkdir marketplace &amp;&amp; cd marketplace
mkdir server client
</code></pre>
<h2 id="heading-how-to-set-up-the-backend"><strong>How to Set Up the Backend</strong></h2>
<p>Navigate into the server directory and initialize a TypeScript project:</p>
<pre><code class="language-shell">cd server
npm init -y
npm install express cors dotenv stripe
npm install -D typescript ts-node @types/express @types/cors @types/node
npx tsc --init
mkdir src
</code></pre>
<p>Open tsconfig.json and update it with these settings:</p>
<pre><code class="language-json">{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"]
}
</code></pre>
<p>Then create a .env file in the server root:</p>
<pre><code class="language-plaintext">STRIPE_SECRET_KEY=sk_test_your_key_here
DOMAIN=http://localhost:3000
</code></pre>
<p>You can find your Stripe test secret key in the Stripe Dashboard under Developers &gt; API Keys. The DOMAIN variable tells your server where to redirect customers after checkout.</p>
<p>Add these scripts to your package.json:</p>
<pre><code class="language-json">{
&nbsp; "scripts": {
    "dev": "ts-node src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}
</code></pre>
<h2 id="heading-how-to-build-the-express-backend"><strong>How to Build the Express Backend</strong></h2>
<p>Create the file src/index.ts. This will be your entire backend. Let’s start with the setup and imports:</p>
<pre><code class="language-typescript">import express, { Request, Response, Router } from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import Stripe from 'stripe';

dotenv.config();

const app = express();
const router = Router();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);

app.use(cors({ origin: process.env.DOMAIN }));
app.use(express.static('public'));
</code></pre>
<p>Notice that we don't import any database client. Stripe is our data layer. Every product, price, customer, and transaction lives in Stripe. Your Express server is a thin orchestration layer that talks to the Stripe API on behalf of your frontend.</p>
<p>We also mount <code>express.static("public")</code> so you can serve static files later if needed. The webhook endpoint needs the raw request body, so we'll register it before the JSON parser. Let’s add that now.</p>
<h2 id="heading-how-to-handle-merchant-onboarding"><strong>How to Handle Merchant Onboarding</strong></h2>
<p>The first thing a merchant needs to do is create an account on your platform and connect it to Stripe. This involves two steps: creating a connected account, and then redirecting the merchant to Stripe’s hosted onboarding form.</p>
<h3 id="heading-how-to-create-a-connected-account">How to Create a Connected Account</h3>
<p>Add the following route to your src/index.ts:</p>
<pre><code class="language-typescript">// Type definitions for request bodies
interface CreateAccountBody {
  email: string;
}
interface AccountIdBody {
  accountId: string;
}

// Create a Connected Account using Stripe V2 API
router.post(
  '/create-connect-account',
  async (req: Request&lt;{}, {}, CreateAccountBody&gt;, res: Response) =&gt; {
    try {
      const account = await stripe.v2.core.accounts.create({
        display_name: req.body.email,
        contact_email: req.body.email,
        dashboard: 'full',
        defaults: {
          responsibilities: {
            fees_collector: 'stripe',
            losses_collector: 'stripe',
          },
        },
        identity: {
          country: 'GB',
          entity_type: 'company',
        },
        configuration: {
          customer: {},
          merchant: {
            capabilities: {
              card_payments: { requested: true },
            },
          },
        },
      });
      res.json({ accountId: account.id });
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>Let’s break down what this code does. The <code>stripe.v2.core.accounts.create()</code> method creates a new connected account using Stripe’s V2 API. Here are the key configuration options:</p>
<ol>
<li><p><code>dashboard: "full"</code> gives the merchant access to their own Stripe dashboard where they can view payments, manage payouts, and handle disputes.</p>
</li>
<li><p><code>responsibilities</code> tells Stripe who collects fees and who is liable for losses. Setting both to "stripe" means Stripe handles this, which is the simplest configuration.</p>
</li>
<li><p><code>identity</code> sets the country and entity type. Change "GB" to your merchants’ country code (for example, "US" for the United States).</p>
</li>
<li><p><code>configuration.merchant.capabilities</code> requests the <code>card_payments</code> capability, which lets the merchant accept credit card payments.</p>
</li>
</ol>
<h3 id="heading-how-to-create-the-onboarding-link">How to Create the Onboarding Link</h3>
<p>After creating the account, you need to redirect the merchant to Stripe’s hosted onboarding form. Add this route:</p>
<pre><code class="language-typescript">// Create Account Link for onboarding
router.post('/create-account-link', async (req: Request&lt;{}, {}, AccountIdBody&gt;, res: Response) =&gt; {
  const { accountId } = req.body;
  try {
    const accountLink = await stripe.v2.core.accountLinks.create({
      account: accountId,
      use_case: {
        type: 'account_onboarding',
        account_onboarding: {
          configurations: ['merchant', 'customer'],
          refresh_url: `${process.env.DOMAIN}`,
          return_url: `\({process.env.DOMAIN}?accountId=\){accountId}`,
        },
      },
    });
    res.json({ url: accountLink.url });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>The <code>accountLinks.create()</code> method generates a temporary URL that takes the merchant to Stripe’s onboarding form. On that form, Stripe collects the merchant’s identity documents, bank account details, and tax information. You don't need to build any of this yourself.</p>
<p>The <code>return_url</code> is where Stripe redirects the merchant after they complete onboarding. Notice that you append the <code>accountId</code> as a query parameter so your frontend can pick it up and store it.</p>
<h2 id="heading-how-to-check-account-status"><strong>How to Check Account Status</strong></h2>
<p>You need a way to check whether a merchant has finished onboarding and is ready to accept payments. Add this route:</p>
<pre><code class="language-typescript">// Get Connected Account Status
router.get(
  '/account-status/:accountId',
  async (req: Request&lt;{ accountId: string }&gt;, res: Response) =&gt; {
    try {
      const account = await stripe.v2.core.accounts.retrieve(req.params.accountId, {
        include: ['requirements', 'configuration.merchant'],
      });
      const payoutsEnabled =
        account.configuration?.merchant?.capabilities?.stripe_balance?.payouts?.status === 'active';
      const chargesEnabled =
        account.configuration?.merchant?.capabilities?.card_payments?.status === 'active';
      const summaryStatus = account.requirements?.summary?.minimum_deadline?.status;
      const detailsSubmitted = !summaryStatus || summaryStatus === 'eventually_due';
      res.json({
        id: account.id,
        payoutsEnabled,
        chargesEnabled,
        detailsSubmitted,
        requirements: account.requirements?.entries,
      });
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>This route retrieves the connected account and checks three important statuses:</p>
<ul>
<li><p><code>chargesEnabled</code> tells you if the merchant can accept payments.</p>
</li>
<li><p><code>payoutsEnabled</code> tells you if they can receive payouts to their bank account.</p>
</li>
<li><p><code>detailsSubmitted</code> tells you if they have completed the onboarding form.</p>
</li>
</ul>
<p>Your frontend will use these flags to show or hide features.</p>
<h2 id="heading-how-to-create-products-through-stripe"><strong>How to Create Products Through Stripe</strong></h2>
<p>Instead of storing products in a database, you'll create them directly in Stripe. Each product is created on the merchant’s connected account using the <code>stripeAccount</code> header. This means each merchant has their own isolated product catalog inside Stripe.</p>
<pre><code class="language-typescript">// Type definition for product creation
interface CreateProductBody {
  productName: string;
  productDescription: string;
  productPrice: number;
  accountId: string;
}
// Create a product on the connected account
router.post('/create-product', async (req: Request&lt;{}, {}, CreateProductBody&gt;, res: Response) =&gt; {
  const { productName, productDescription, productPrice, accountId } = req.body;
  try {
    // Create the product on the connected account
    const product = await stripe.products.create(
      {
        name: productName,
        description: productDescription,
      },
      { stripeAccount: accountId },
    ); // Create a price for the product
    const price = await stripe.prices.create(
      {
        product: product.id,
        unit_amount: productPrice,
        currency: 'usd',
      },
      { stripeAccount: accountId },
    );
    res.json({
      productName,
      productDescription,
      productPrice,
      priceId: price.id,
    });
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>There are two Stripe API calls happening here. First, <code>stripe.products.create()</code> creates the product (name and description). Then <code>stripe.prices.create()</code> creates a price for that product (amount and currency).</p>
<p>Stripe separates products from prices because a single product can have multiple prices — for example, a monthly plan and an annual plan.</p>
<p>The <code>{ stripeAccount: accountId }</code> option on both calls tells Stripe to create these resources on the merchant’s connected account, not on your platform account. This is a critical detail: without it, the products would be created on your platform’s account and the merchant would never see them.</p>
<h2 id="heading-how-to-fetch-products"><strong>How to Fetch Products</strong></h2>
<p>Add a route to list all products for a given merchant:</p>
<pre><code class="language-typescript">// Fetch products for a specific account
router.get('/products/:accountId', async (req: Request&lt;{ accountId: string }&gt;, res: Response) =&gt; {
  const { accountId } = req.params;
  try {
    const options: Stripe.RequestOptions = {};
    if (accountId !== 'platform') {
      options.stripeAccount = accountId;
    }
    const prices = await stripe.prices.list(
      {
        expand: ['data.product'],
        active: true,
        limit: 100,
      },
      options,
    );
    const products = prices.data.map((price) =&gt; {
      const product = price.product as Stripe.Product;
      return {
        id: product.id,
        name: product.name,
        description: product.description,
        price: price.unit_amount,
        priceId: price.id,
        period: price.recurring ? price.recurring.interval : null,
      };
    });
    res.json(products);
  } catch (error) {
    const message = error instanceof Error ? error.message : 'Unknown error';
    res.status(500).json({ error: message });
  }
});
</code></pre>
<p>This route fetches all active prices from a merchant’s Stripe account and expands the product data (using <code>expand: ["data.product"]</code>) so you get the product name and description in the same API call. The period field will be null for one-time products and "month" or "year" for subscriptions.</p>
<h2 id="heading-how-to-build-the-checkout-flow"><strong>How to Build the Checkout Flow</strong></h2>
<p>Your checkout flow needs to handle two scenarios: one-time payments for individual products, and recurring subscriptions. Stripe’s Checkout Sessions handle both — you just need to set the mode based on the price type.</p>
<pre><code class="language-typescript">// Type definition for checkout
interface CheckoutBody {
  priceId: string;
  accountId: string;
}
// Create checkout session
router.post(
  '/create-checkout-session',
  async (req: Request&lt;{}, {}, CheckoutBody&gt;, res: Response) =&gt; {
    const { priceId, accountId } = req.body;
    try {
      // Retrieve the price to determine if it is
      // one-time or recurring
      const price = await stripe.prices.retrieve(priceId, { stripeAccount: accountId });
      const isSubscription = price.type === 'recurring';
      const mode = isSubscription ? 'subscription' : 'payment';
      const session = await stripe.checkout.sessions.create(
        {
          line_items: [
            {
              price: priceId,
              quantity: 1,
            },
          ],
          mode,
          success_url: `${process.env.DOMAIN}/done?session_id={CHECKOUT_SESSION_ID}`,
          cancel_url: `${process.env.DOMAIN}`,
          ...(isSubscription
            ? {
                subscription_data: {
                  application_fee_percent: 10,
                },
              }
            : {
                payment_intent_data: {
                  application_fee_amount: 123,
                },
              }),
        },
        { stripeAccount: accountId },
      );
      res.redirect(303, session.url as string);
    } catch (error) {
      const message = error instanceof Error ? error.message : 'Unknown error';
      res.status(500).json({ error: message });
    }
  },
);
</code></pre>
<p>Here's what this route does step by step. First, it retrieves the price from the merchant’s connected account to check whether it is a one-time price or a recurring subscription. Then it creates a Checkout Session with the appropriate mode — either "payment" or "subscription".</p>
<p>The <code>application_fee_amount</code> is your platform’s cut of the transaction, specified in the smallest currency unit (cents for USD). In this example, you take $1.23 or 10% per transaction. For a real marketplace, you would likely calculate this as a percentage of the product price.</p>
<p>Notice that <code>application_fee_amount</code> goes inside <code>subscription_data</code> for subscriptions but inside <code>payment_intent_data</code> for one-time payments. This is a Stripe requirement — the two modes use different configuration objects.</p>
<p>Finally, the route uses <code>res.redirect(303, session.url)</code> to send the customer directly to Stripe’s hosted checkout page.</p>
<h2 id="heading-how-to-handle-webhooks"><strong>How to Handle Webhooks</strong></h2>
<p>Webhooks are how Stripe tells your server about events that happen asynchronously — like a successful payment, a failed charge, or a subscription cancellation.</p>
<p>In a production marketplace, you should <strong>never</strong> rely solely on redirect URLs to confirm payments. A customer might close their browser before the redirect completes. Webhooks are your source of truth.</p>
<p>Add the webhook endpoint <strong>before</strong> the JSON body parser. Stripe sends webhook payloads as raw bytes, and you need the raw body to verify the signature:</p>
<pre><code class="language-typescript">// IMPORTANT: Register this BEFORE app.use(express.json())
app.post(
  '/api/webhook',
  express.raw({ type: 'application/json' }),
  (req: Request, res: Response) =&gt; {
    let event: Stripe.Event = JSON.parse(req.body.toString()); // If you have an endpoint secret, verify the
    // signature for security
    const endpointSecret = process.env.WEBHOOK_SECRET;
    if (endpointSecret) {
      const signature = req.headers['stripe-signature'] as string;
      try {
        event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret) as Stripe.Event;
      } catch (err) {
        const message = err instanceof Error ? err.message : 'Unknown error';
        console.log('Webhook signature verification failed:', message);
        res.sendStatus(400);
        return;
      }
    } // Handle the event
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Payment successful for session:', session.id); // Fulfill the order: send email, grant access,
        // update your records, and so on
        break;
      }
      case 'checkout.session.expired': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Session expired:', session.id); // Optionally notify the customer or clean up
        // any pending records
        break;
      }
      case 'checkout.session.async_payment_succeeded': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Delayed payment succeeded for session:', session.id); // Fulfill the order now that payment cleared
        break;
      }
      case 'checkout.session.async_payment_failed': {
        const session = event.data.object as Stripe.Checkout.Session;
        console.log('Payment failed for session:', session.id); // Notify the customer that payment failed
        break;
      }
      case 'customer.subscription.deleted': {
        const subscription = event.data.object as Stripe.Subscription;
        console.log('Subscription cancelled:', subscription.id); // Revoke access for the customer
        break;
      }
      default:
        console.log('Unhandled event type:', event.type);
    }
    res.send();
  },
);
</code></pre>
<p>The webhook handler checks for five key events.</p>
<ul>
<li><p><code>checkout.session.completed</code> fires when a payment succeeds — this is where you would fulfill an order, send a confirmation email, or grant access.</p>
</li>
<li><p><code>checkout.session.expired</code> fires when a session expires before the customer completes payment.</p>
</li>
<li><p><code>checkout.session.async_payment_succeeded</code> fires when a delayed payment method (like a bank transfer) finally goes through.</p>
</li>
<li><p><code>checkout.session.async_payment_failed</code> fires when a delayed payment method fails.</p>
</li>
<li><p>And <code>customer.subscription.deleted</code> fires when a subscription is cancelled.</p>
</li>
</ul>
<h2 id="heading-how-to-configure-webhooks-in-the-stripe-dashboard"><strong>How to Configure Webhooks in the Stripe Dashboard</strong></h2>
<p>Before you can receive webhook events, you need to tell Stripe where to send them and which events you care about. Follow these steps:</p>
<ol>
<li><p>Go to the Stripe Dashboard and navigate to Developers &gt; Webhooks.</p>
</li>
<li><p>Click "Add destination."</p>
</li>
<li><p>Under the account type, select "Connected and V2 accounts" since your payments go through connected merchant accounts.</p>
</li>
<li><p>Under "Events to listen for," click "All events" and select the following five events:</p>
<ul>
<li><p><code>checkout.session.async_payment_succeeded</code> — Occurs when a payment intent using a delayed payment method finally succeeds.</p>
</li>
<li><p><code>checkout.session.completed</code> — Occurs when a Checkout Session has been successfully completed.</p>
</li>
<li><p><code>checkout.session.expired</code> — Occurs when a Checkout Session expires before completion.</p>
</li>
<li><p><code>checkout.session.async_payment_failed</code> — Occurs when a payment intent using a delayed payment method fails.</p>
</li>
<li><p><code>customer.subscription.deleted</code> — Occurs whenever a customer’s subscription ends.</p>
</li>
</ul>
</li>
<li><p>Enter your webhook endpoint URL. For production, this would be something like <a href="https://yourdomain.com/api/webhook">https://yourdomain.com/api/webhook</a>. For local development, you will use the Stripe CLI instead (covered next).</p>
</li>
<li><p>Click "Add destination" to save.</p>
</li>
</ol>
<h2 id="heading-how-to-test-webhooks-locally"><strong>How to Test Webhooks Locally</strong></h2>
<p>For local development, you don't need to expose your server to the internet. Install the Stripe CLI and run:</p>
<pre><code class="language-shell">brew install stripe/stripe-cli/stripe
stripe login
stripe listen --forward-to localhost:4242/webhook
</code></pre>
<p>The CLI will print a webhook signing secret that starts with <code>whsec_</code>. Add this to your .env file as <code>WEBHOOK_SECRET</code>. The CLI intercepts all webhook events from Stripe and forwards them to your local server, so you can test the full payment flow without deploying anything.</p>
<h2 id="heading-how-to-add-the-billing-portal"><strong>How to Add the Billing Portal</strong></h2>
<p>The billing portal lets customers manage their subscriptions without you building any UI for it. Stripe hosts the entire experience — customers can update their payment method, change plans, or cancel their subscription.</p>
<pre><code class="language-typescript">// Create a billing portal session
router.post(
&nbsp; "/create-portal-session",
&nbsp; async (req: Request, res: Response) =&gt; {
&nbsp;&nbsp;&nbsp; const { session_id } = req.body as {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; session_id: string;
&nbsp;&nbsp;&nbsp; };
&nbsp;
&nbsp;&nbsp;&nbsp; try {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const session =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await stripe.checkout.sessions.retrieve(
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; session_id
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; );
&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const portalSession =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; await stripe.billingPortal.sessions.create({
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; customer_account: session.customer_account as string,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; return_url: `\({process.env.DOMAIN}?session_id=\){session_id}`,
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; });
&nbsp;
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; res.redirect(303, portalSession.url);
&nbsp;&nbsp;&nbsp; } catch (error) {
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; const message =
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; error instanceof Error
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; ? error.message
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; : "Unknown error";
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; res.status(500).json({ error: message });
&nbsp;&nbsp;&nbsp; }
&nbsp; }
);
</code></pre>
<p>This route takes a <code>session_id</code> from a previous checkout, retrieves the associated customer, and creates a billing portal session. The <code>customer_account</code> field links the portal to the correct connected account so the customer sees only their subscriptions with that specific merchant.</p>
<p>Now add the JSON parser and mount the router. This must come <strong>after</strong> the webhook route:</p>
<pre><code class="language-typescript">// JSON and URL-encoded parsers (AFTER webhook route)
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

// Mount all routes under /api
app.use('/api', router);
const PORT: number = parseInt(process.env.PORT || '4242', 10);
app.listen(PORT, () =&gt; {
  console.log(`Server running on port ${PORT}`);
});
</code></pre>
<h2 id="heading-how-to-build-the-nextjs-frontend"><strong>How to Build the Next.js Frontend</strong></h2>
<p>Navigate to the client directory and create a new Next.js project with TypeScript:</p>
<pre><code class="language-shell">cd ../client
npx create-next-app@latest . --typescript --app --tailwind --eslint
npm install axios
</code></pre>
<h2 id="heading-how-to-create-the-account-context"><strong>How to Create the Account Context</strong></h2>
<p>You need a way to share the merchant’s account ID across all components. Create a context provider at <code>contexts/AccountContext.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
import { useSearchParams } from 'next/navigation';

interface AccountContextType {
  accountId: string | null;
  setAccountId: (id: string | null) =&gt; void;
}

const AccountContext = createContext&lt;AccountContextType | undefined&gt;(undefined);

export function useAccount(): AccountContextType {
  const context = useContext(AccountContext);
  if (!context) {
    throw new Error('useAccount must be used within AccountProvider');
  }
  return context;
}

export function AccountProvider({ children }: { children: ReactNode }) {
  const searchParams = useSearchParams();
  const [accountId, setAccountId] = useState&lt;string | null&gt;(searchParams.get('accountId'));

  return (
    &lt;AccountContext.Provider value={{ accountId, setAccountId }}&gt;
      {children}
    &lt;/AccountContext.Provider&gt;
  );
}
</code></pre>
<p>This context stores the current merchant’s account ID and makes it available throughout the app. On initial load, it checks the URL for an accountId query parameter — this is how Stripe’s onboarding redirect passes the account ID back to your app.</p>
<h2 id="heading-how-to-create-the-account-status-hook"><strong>How to Create the Account Status Hook</strong></h2>
<p>Create a custom hook at <code>hooks/useAccountStatus.ts</code> that polls the account status:</p>
<pre><code class="language-typescript">'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
interface AccountStatus {
  id: string;
  payoutsEnabled: boolean;
  chargesEnabled: boolean;
  detailsSubmitted: boolean;
}
export default function useAccountStatus() {
  const [accountStatus, setAccountStatus] = useState&lt;AccountStatus | null&gt;(null);
  const { accountId, setAccountId } = useAccount();
  useEffect(() =&gt; {
    if (!accountId) return;
    const fetchStatus = async () =&gt; {
      try {
        const res = await fetch(`http://localhost:4242/api/account-status/${accountId}`);
        if (!res.ok) throw new Error('Failed to fetch');
        const data: AccountStatus = await res.json();
        setAccountStatus(data);
      } catch {
        setAccountId(null);
      }
    };
    fetchStatus();
    const interval = setInterval(fetchStatus, 5000);
    return () =&gt; clearInterval(interval);
  }, [accountId, setAccountId]);
  return {
    accountStatus,
    needsOnboarding: !accountStatus?.chargesEnabled &amp;&amp; !accountStatus?.detailsSubmitted,
  };
}
</code></pre>
<p>This hook polls the account status every 5 seconds. This is important because Stripe’s onboarding is asynchronous — a merchant might complete the form, but it can take a moment for Stripe to verify their details and activate their account. The <code>needsOnboarding</code> flag tells your UI whether to show the onboarding button or the merchant dashboard.</p>
<h2 id="heading-how-to-build-the-merchant-onboarding-component"><strong>How to Build the Merchant Onboarding Component</strong></h2>
<p>Create <code>components/ConnectOnboarding.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
export default function ConnectOnboarding() {
  const [email, setEmail] = useState&lt;string&gt;('');
  const { accountId, setAccountId } = useAccount();
  const { accountStatus, needsOnboarding } = useAccountStatus();
  const handleCreateAccount = async () =&gt; {
    const res = await fetch(`${API_URL}/create-connect-account`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email }),
    });
    const data = await res.json();
    setAccountId(data.accountId);
  };
  const handleStartOnboarding = async () =&gt; {
    const res = await fetch(`${API_URL}/create-account-link`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ accountId }),
    });
    const data = await res.json();
    window.location.href = data.url;
  };
  if (!accountId) {
    return (
      &lt;div className="max-w-md mx-auto p-6"&gt;
        &lt;h2 className="text-xl font-bold mb-4"&gt;Create Your Seller Account&lt;/h2&gt;
        &lt;input
          type="email"
          placeholder="Your email"
          value={email}
          onChange={(e) =&gt; setEmail(e.target.value)}
          className="w-full border p-2 rounded mb-4"
        /&gt;
        &lt;button
          onClick={handleCreateAccount}
          className="w-full bg-green-600 text-white p-2 rounded hover:bg-green-700"
        &gt;
          Create Connect Account
        &lt;/button&gt;
      &lt;/div&gt;
    );
  }
  return (
    &lt;div className="max-w-md mx-auto p-6"&gt;
      &lt;h3 className="font-semibold mb-2"&gt;Account: {accountId} &lt;/h3&gt;
      &lt;p className="mb-2"&gt;Charges: {accountStatus?.chargesEnabled ? 'Active' : 'Pending'} &lt;/p&gt;
      &lt;p className="mb-4"&gt;Payouts: {accountStatus?.payoutsEnabled ? 'Active' : 'Pending'} &lt;/p&gt;
      {needsOnboarding &amp;&amp; (
        &lt;button
          onClick={handleStartOnboarding}
          className="bg-purple-600 text-white px-6 py-2 rounded hover:bg-purple-700"
        &gt;
          Complete Onboarding
        &lt;/button&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component handles both states of the merchant experience. If no account exists, it shows a simple email form. After account creation, it shows the account status and an onboarding button if needed.</p>
<h2 id="heading-how-to-build-the-product-create-product-list-and-checkout"><strong>How to Build the Product Create, Product List and Checkout</strong></h2>
<p>Create <code>components/Products.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState, useEffect } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface Product {
  id: string;
  name: string;
  description: string | null;
  price: number | null;
  priceId: string;
  period: string | null;
}
export default function Products() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [products, setProducts] = useState&lt;Product[]&gt;([]);
  useEffect(() =&gt; {
    if (!accountId || needsOnboarding) return;
    const fetchProducts = async () =&gt; {
      const res = await fetch(`\({API_URL}/products/\){accountId}`);
      const data: Product[] = await res.json();
      setProducts(data);
    };
    fetchProducts();
    const interval = setInterval(fetchProducts, 5000);
    return () =&gt; clearInterval(interval);
  }, [accountId, needsOnboarding]);
  return (
    &lt;div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6"&gt;
      {' '}
      {products.map((product) =&gt; (
        &lt;div key={product.priceId} className="border rounded-lg p-4 shadow-sm"&gt;
          &lt;h3 className="text-lg font-semibold"&gt;&nbsp; {product.name}&lt;/h3&gt;

          &lt;p className="text-gray-600 mt-1"&gt;&nbsp; {product.description}&lt;/p&gt;

          &lt;p className="text-xl font-bold mt-3"&gt;
            ${((product.price ?? 0) / 100).toFixed(2)}
            {product.period ? ` / ${product.period}` : ''}
          &lt;/p&gt;

          &lt;form action={`${API_URL}/create-checkout-session`} method="POST"&gt;
            &lt;input type="hidden" name="priceId" value={product.priceId} /&gt;
            &lt;input type="hidden" name="accountId" value={accountId ?? ''} /&gt;
            &lt;button
              type="submit"
              className="mt-4 w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700"
            &gt;
              {product.period ? 'Subscribe' : 'Buy Now'}
            &lt;/button&gt;
          &lt;/form&gt;
        &lt;/div&gt;
      ))}
    &lt;/div&gt;
  );
}

</code></pre>
<p>The Products component fetches all products from the merchant’s Stripe account and displays them in a responsive grid. The checkout button submits a form directly to your backend, which redirects the customer to Stripe’s hosted checkout page. Notice how the button text changes based on whether the product is a one-time purchase or a subscription.</p>
<h2 id="heading-how-to-build-the-product-form"><strong>How to Build the Product Form</strong></h2>
<p>Merchants need a way to add products from the frontend. Create <code>components/ProductForm.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { useState } from 'react';
import { useAccount } from '@/contexts/AccountContext';
import useAccountStatus from '@/hooks/useAccountStatus';
const API_URL = 'http://localhost:4242/api';
interface ProductFormData {
  productName: string;
  productDescription: string;
  productPrice: number;
}
export default function ProductForm() {
  const { accountId } = useAccount();
  const { needsOnboarding } = useAccountStatus();
  const [showForm, setShowForm] = useState&lt;boolean&gt;(false);
  const [formData, setFormData] = useState&lt;ProductFormData&gt;({
    productName: '',
    productDescription: '',
    productPrice: 1000,
  });
  const handleSubmit = async (e: React.FormEvent): Promise&lt;void&gt; =&gt; {
    e.preventDefault();
    if (!accountId || needsOnboarding) return;
    await fetch(`${API_URL}/create-product`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        ...formData,
        accountId,
      }),
    }); // Reset form and hide it
    setFormData({
      productName: '',
      productDescription: '',
      productPrice: 1000,
    });
    setShowForm(false);
  }; // Only show the form if the merchant has completed
  // onboarding and can accept charges
  if (!accountId || needsOnboarding) return null;
  return (
    &lt;div className="my-6"&gt;
      &lt;button
        onClick={() =&gt; setShowForm(!showForm)}
        className="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700"
      &gt;
        {showForm ? 'Cancel' : 'Add New Product'}
      &lt;/button&gt;

      {showForm &amp;&amp; (
        &lt;form onSubmit={handleSubmit} className="mt-4 max-w-md space-y-4"&gt;
          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Product Name&lt;/label&gt;

            &lt;input
              type="text"
              value={formData.productName}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productName: e.target.value,
                })
              }
              className="w-full border p-2 rounded"
              required
            /&gt;
          &lt;/div&gt;

          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Description&lt;/label&gt;
            &lt;input
              type="text"
              value={formData.productDescription}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productDescription: e.target.value,
                })
              }
              className="w-full border p-2 rounded"
            /&gt;
          &lt;/div&gt;
          &lt;div&gt;
            &lt;label className="block text-sm font-medium mb-1"&gt;Price (in cents)&lt;/label&gt;

            &lt;input
              type="number"
              value={formData.productPrice}
              onChange={(e) =&gt;
                setFormData({
                  ...formData,
                  productPrice: parseInt(e.target.value),
                })
              }
              className="w-full border p-2 rounded"
              required
            /&gt;
          &lt;/div&gt;
          &lt;button
            type="submit"
            className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
          &gt;
            Create Product
          &lt;/button&gt;
        &lt;/form&gt;
      )}
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component only renders after the merchant has completed onboarding (the <code>if (!accountId || needsOnboarding) return null</code> check at the top). It toggles a form where the merchant enters a product name, description, and price in cents. When submitted, it calls your <code>/api/create-product</code> endpoint, which creates both the product and its price on the merchant’s connected Stripe account.</p>
<p>The price field uses cents because that is what Stripe expects. So if a merchant wants to sell a product for \(25.00, they enter 2500. In a production app, you would add a friendlier input that lets merchants type \)25.00 and converts it to cents automatically.</p>
<h2 id="heading-how-to-build-the-main-page"><strong>How to Build the Main Page</strong></h2>
<p>Finally, put it all together in <code>app/page.tsx</code>:</p>
<pre><code class="language-typescript">'use client';
import { AccountProvider } from '@/contexts/AccountContext';
import ConnectOnboarding from '@/components/ConnectOnboarding';
import Products from '@/components/Products';
import ProductForm from '@/components/ProductForm';
export default function Home() {
  return (
    &lt;AccountProvider&gt;
      {' '}
      &lt;main className="max-w-6xl mx-auto p-8"&gt;
        &lt;h1 className="text-3xl font-bold mb-8"&gt; Marketplace Dashboard &lt;/h1&gt;
        &lt;ConnectOnboarding /&gt;
        &lt;ProductForm /&gt;
        &lt;Products /&gt;
      &lt;/main&gt;
    &lt;/AccountProvider&gt;
  );
}
</code></pre>
<h2 id="heading-how-to-test-the-full-flow"><strong>How to Test the Full Flow</strong></h2>
<p>Start both servers:</p>
<pre><code class="language-shell"># Terminal 1 - Backend
cd server
npm run dev
&nbsp;
# Terminal 2 - Frontend
cd client
npm run dev
&nbsp;
# Terminal 3 - Stripe webhook listener
stripe listen --forward-to localhost:4242/api/webhook
</code></pre>
<p>Now test the complete flow:</p>
<ol>
<li><p>Go to <a href="http://localhost:3000">http://localhost:3000</a> and enter an email to create a merchant account.</p>
</li>
<li><p>Click "Complete Onboarding" and fill out Stripe’s test onboarding form. Use test data like 000-000-0000 for the phone number and 0000 for the last four digits of SSN.</p>
</li>
<li><p>Wait a few seconds for the account status to update. Once charges are active, you can add products.</p>
</li>
<li><p>Create a product using the product form (set the price in cents — for example, 2500 for $25.00).</p>
</li>
<li><p>Click "Buy Now" on a product to start the checkout flow.</p>
</li>
<li><p>On Stripe’s checkout page, use the test card number 4242 4242 4242 4242 with any future expiry date and any CVC.</p>
</li>
<li><p>Check your terminal — you should see the webhook event confirming the payment.</p>
</li>
<li><p>Check the Stripe Dashboard to see the payment, the application fee, and the transfer to the connected account.</p>
</li>
</ol>
<h2 id="heading-how-the-payment-split-works"><strong>How the Payment Split Works</strong></h2>
<p>Here is exactly what happens when a customer pays $25.00 for a product:</p>
<ol>
<li><p>The customer pays $25.00 on Stripe’s checkout page.</p>
</li>
<li><p>Stripe deducts its processing fee (approximately 2.9% + $0.30 for US cards).</p>
</li>
<li><p>Your platform takes the application fee you set ($1.23 in our example).</p>
</li>
<li><p>The remaining amount is transferred to the merchant’s connected Stripe account.</p>
</li>
<li><p>The merchant can withdraw their funds to their bank account from the Stripe Dashboard.</p>
</li>
</ol>
<p>You control the application fee in the checkout route. In a production marketplace, you would calculate this as a percentage of the transaction. For example, to take a 10% fee:</p>
<pre><code class="language-plaintext">onst applicationFee = Math.round(
&nbsp; (price.unit_amount ?? 0) * 0.1
);
</code></pre>
<h2 id="heading-next-steps"><strong>Next Steps</strong></h2>
<p>You now have a working marketplace. Here are improvements to consider for production:</p>
<ul>
<li><p>Add authentication with NextAuth.js so merchants can securely log in and manage their accounts across sessions.</p>
</li>
<li><p>Add runtime validation with Zod to validate all request bodies before they reach Stripe.</p>
</li>
<li><p>Add image uploads for products using Cloudinary or AWS S3, then pass the image URL to Stripe’s product metadata.</p>
</li>
<li><p>Build separate merchant and customer views. Right now the app combines both experiences on one page.</p>
</li>
<li><p>Deploy your backend to Railway or Render and your frontend to Vercel. Update the webhook URL in your Stripe Dashboard to point to your production server.</p>
</li>
</ul>
<p>You can find the complete source code for this tutorial on GitHub: <a href="https://github.com/michaelokolo/marketplace">https://github.com/michaelokolo/marketplace</a></p>
<h2 id="heading-acknowledgements"><strong>Acknowledgements</strong></h2>
<p>Some API usage patterns in this tutorial are inspired by examples from the <a href="https://docs.stripe.com">official Stripe documentation</a>. These examples were adapted to demonstrate how to build a complete multi-vendor marketplace architecture.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this handbook, you built a complete online marketplace where merchants can onboard through Stripe Connect, create products stored directly in Stripe, and receive payments from customers — all without a traditional database.</p>
<p>You learned how to use Stripe’s V2 Accounts API for merchant onboarding, create products and prices on connected accounts, build a checkout flow that handles both one-time payments and subscriptions, listen for payment events with webhooks, and give customers a billing portal to manage their subscriptions.</p>
<p>The key insight is that Stripe Connect handles the hardest parts of running a marketplace — payment splitting, tax compliance, identity verification, and fund transfers. Your job is to build a great user experience on top of it.</p>
<p>If you found this tutorial helpful, share it with someone who is learning to build full-stack applications. Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Reusable Architecture for Large Next.js Applications ]]>
                </title>
                <description>
                    <![CDATA[ Every Next.js project starts the same way: you run npx create-next-app, write a few pages, maybe add an API route or two, and things feel clean. Then the project grows. Features multiply. A second app ]]>
                </description>
                <link>https://www.freecodecamp.org/news/reusable-architecture-for-large-nextjs-applications/</link>
                <guid isPermaLink="false">69d00029e466e2b762517489</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monorepo ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Abisoye Alli-Balogun ]]>
                </dc:creator>
                <pubDate>Fri, 03 Apr 2026 18:00:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0a713a19-a418-4954-bfca-9b8bc1e77a03.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every Next.js project starts the same way: you run <code>npx create-next-app</code>, write a few pages, maybe add an API route or two, and things feel clean.</p>
<p>Then the project grows. Features multiply. A second app appears, maybe a separate admin dashboard, a marketing site, or a mobile-facing API. Suddenly, you're copying components between repos, duplicating business logic, arguing over where auth utilities belong, and asking yourself: <em>where did it all go wrong?</em></p>
<p>The answer is almost always architecture, or rather, the absence of one. Not the kind that lives in a Notion doc but the kind baked into your folder structure, your module boundaries, and the tools you reach for at the start of a project (not after it's already broken).</p>
<p>This article is a practical guide to building layered, reusable architecture in Next.js.</p>
<p>You'll learn about the App Router's colocation model, building scalable folder structures around features, sharing logic across apps with Turborepo, drawing clean data-fetching boundaries using Server Components, designing a testing strategy that matches your layer structure, and wiring up a CI/CD pipeline that only builds and tests what actually changed.</p>
<p>By the end, you'll have a blueprint you can actually use, not just admire.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-core-problem-coupling-without-intention">The Core Problem: Coupling Without Intention</a></p>
</li>
<li><p><a href="#heading-layer-1-the-app-router-and-colocation">Layer 1: The App Router and Colocation</a></p>
</li>
<li><p><a href="#heading-layer-2-feature-based-folder-structure">Layer 2: Feature-Based Folder Structure</a></p>
</li>
<li><p><a href="#heading-layer-3-monorepo-with-turborepo-sharing-logic-across-apps">Layer 3: Monorepo with Turborepo (Sharing Logic Across Apps)</a></p>
</li>
<li><p><a href="#heading-layer-4-server-components-and-data-fetching-boundaries">Layer 4: Server Components and Data-Fetching Boundaries</a></p>
</li>
<li><p><a href="#heading-layer-5-testing-strategy-for-a-layered-codebase">Layer 5: Testing Strategy for a Layered Codebase</a></p>
</li>
<li><p><a href="#heading-layer-6-cicd-with-turborepo">Layer 6: CI/CD with Turborepo</a></p>
</li>
<li><p><a href="#heading-putting-it-all-together-the-full-blueprint">Putting It All Together: The Full Blueprint</a></p>
</li>
<li><p><a href="#heading-common-pitfalls-and-how-to-avoid-them">Common Pitfalls and How to Avoid Them</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-the-core-problem-coupling-without-intention">The Core Problem: Coupling Without Intention</h2>
<p>When a component reaches directly into a global store, when a page imports a utility from three directories away, when your auth logic is spread across <code>/lib</code>, <code>/helpers</code>, and <code>/utils</code> with no clear owner, every file knows too much about every other file.</p>
<p>The app still runs. But now changing one thing breaks three others, onboarding takes a week, and adding a second app means copying half the first one.</p>
<p>Layered architecture solves this by giving everything a place, and making those places mean something.</p>
<h2 id="heading-layer-1-the-app-router-and-colocation">Layer 1: The App Router and Colocation</h2>
<p>Next.js 13+ introduced the App Router with a file-system-based routing model that does something subtly powerful: it lets you colocate everything related to a route <em>inside</em> that route's folder.</p>
<p>Before the App Router, pages lived in <code>/pages</code>, components lived in <code>/components</code>, and data fetching was scattered. The App Router flips this. A route segment can now own its layout, its loading and error states, its server actions, and even its local components, all in one place.</p>
<h3 id="heading-what-colocation-actually-means">What Colocation Actually Means</h3>
<p>Consider a <code>/dashboard</code> route. In the App Router model, its folder might look like this:</p>
<pre><code class="language-plaintext">app/
  dashboard/
    page.tsx              # The route entry point
    layout.tsx            # Dashboard-specific shell/navigation
    loading.tsx           # Streaming loading state
    error.tsx             # Error boundary
    components/
      StatsCard.tsx       # Used only within dashboard
      ActivityFeed.tsx
    lib/
      queries.ts          # Data fetching for this route only
      formatters.ts       # Dashboard-specific transforms
</code></pre>
<p>The key insight: <code>StatsCard.tsx</code> and <code>queries.ts</code> don't belong to your whole application, they belong to <code>/dashboard</code>. When you delete or refactor the dashboard, you delete or refactor one folder. Nothing else breaks.</p>
<p>This is colocation. It's not a new idea, but the App Router makes it idiomatic in Next.js for the first time.</p>
<h3 id="heading-the-rule-of-proximity">The Rule of Proximity</h3>
<p>A good heuristic: <em>a file should live as close as possible to where it's used.</em> If it's used in one route, it lives in that route's folder. If it's used by two routes under the same parent segment, it moves up one level. If it's used across the entire app, it belongs in a shared layer (more on that shortly).</p>
<pre><code class="language-plaintext">app/
  (marketing)/          # Route group , no URL segment
    layout.tsx          # Shared layout for marketing pages
    page.tsx
    about/
      page.tsx
  (dashboard)/
    layout.tsx          # Different shell for app routes
    dashboard/
      page.tsx
    settings/
      page.tsx
</code></pre>
<p>Route groups (folders wrapped in parentheses) let you share layouts across segments without polluting the URL. This is a clean way to separate concerns, marketing pages and app pages can have entirely different shells without any URL trickery.</p>
<h2 id="heading-layer-2-feature-based-folder-structure">Layer 2: Feature-Based Folder Structure</h2>
<p>Colocation handles the route level. But large applications have cross-cutting concerns – things that don't belong to any single route but aren't generic utilities either.</p>
<p>This is where most projects fall apart: the <code>/components</code> folder becomes a dumping ground, <code>/lib</code> becomes a junk drawer, and nobody agrees on where <code>useAuth</code> should live.</p>
<p>Feature-based folder structure brings order to this chaos.</p>
<h3 id="heading-organising-by-domain-not-by-file-type">Organising by Domain, Not by File Type</h3>
<p>Instead of grouping files by what they <em>are</em> (components, hooks, utils), group them by what they <em>do</em>.</p>
<pre><code class="language-plaintext">src/
  features/
    auth/
      components/
        LoginForm.tsx
        AuthGuard.tsx
      hooks/
        useAuth.ts
        useSession.ts
      lib/
        tokenStorage.ts
        validators.ts
      types.ts
      index.ts            # Public API , only export what others need

    billing/
      components/
        PricingTable.tsx
        SubscriptionBadge.tsx
      hooks/
        useSubscription.ts
      lib/
        stripe.ts
      types.ts
      index.ts

    notifications/
      ...
</code></pre>
<p>Each feature folder is a self-contained unit. It has its own components, hooks, utilities, and types. Crucially, it has a barrel file (<code>index.ts</code>) that defines its <em>public API</em>, the things other parts of the app are allowed to import.</p>
<h3 id="heading-enforcing-boundaries-with-barrel-exports">Enforcing Boundaries with Barrel Exports</h3>
<p>The <code>index.ts</code> is not optional. It's the mechanism that prevents features from becoming entangled.</p>
<pre><code class="language-typescript">// features/auth/index.ts
export { LoginForm } from './components/LoginForm';
export { AuthGuard } from './components/AuthGuard';
export { useAuth } from './hooks/useAuth';
export type { AuthUser, AuthState } from './types';

// NOT exported, internal implementation detail:
// tokenStorage.ts, validators.ts
</code></pre>
<p>Now, the rest of your app imports from <code>@/features/auth</code>, never from <code>@/features/auth/lib/tokenStorage</code>. If you refactor how tokens are stored internally, nothing outside the feature breaks. This is the essence of encapsulation, not just as a theoretical principle, but as a structural one enforced by your folder layout.</p>
<h3 id="heading-shared-vs-feature">Shared vs. Feature</h3>
<p>Not everything belongs in a feature. Truly generic utilities: a <code>cn()</code> classname helper, a date formatter, or a base HTTP client, for example, belong in a shared layer:</p>
<pre><code class="language-plaintext">src/
  shared/
    components/
      Button.tsx
      Modal.tsx
      Spinner.tsx
    hooks/
      useDebounce.ts
      useMediaQuery.ts
    lib/
      http.ts
      dates.ts
    ui/              # shadcn/ui or design system components
</code></pre>
<p>The rule: <code>shared/</code> has zero knowledge of any feature. Features can import from <code>shared/</code>. <code>shared/</code> never imports from a feature.</p>
<h2 id="heading-layer-3-monorepo-with-turborepo-sharing-logic-across-apps">Layer 3: Monorepo with Turborepo (Sharing Logic Across Apps)</h2>
<p>Single-repo architecture gets you far, but most teams eventually end up with multiple apps: a customer-facing Next.js app, an admin panel, a separate marketing site, maybe a set of API services.</p>
<p>The question becomes: <em>how do you share code between them without copy-pasting?</em></p>
<p>The answer is a monorepo with shared packages, and Turborepo is currently the best tool for Next.js teams doing this.</p>
<h3 id="heading-the-monorepo-shape">The Monorepo Shape</h3>
<p>A well-structured Turborepo looks like this:</p>
<pre><code class="language-plaintext">my-platform/
  apps/
    web/              # Customer-facing Next.js app
    admin/            # Internal admin panel (also Next.js)
    marketing/        # Marketing site
  packages/
    ui/               # Shared component library
    config/           # Shared ESLint, TypeScript, Tailwind configs
    auth/             # Shared auth utilities and types
    database/         # Prisma client + query helpers
    utils/            # Generic utilities
  turbo.json
  package.json        # Root workspace config
</code></pre>
<p><code>apps/</code> contains deployable applications. <code>packages/</code> contains shared code that apps depend on. Neither app imports directly from the other, all sharing flows through <code>packages/</code>.</p>
<h3 id="heading-setting-up-a-shared-package">Setting Up a Shared Package</h3>
<p>A package is just a folder with a <code>package.json</code> that other workspace members can depend on.</p>
<pre><code class="language-json">// packages/ui/package.json
{
  "name": "@my-platform/ui",
  "version": "0.0.1",
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "exports": {
    ".": "./src/index.ts"
  }
}
</code></pre>
<pre><code class="language-typescript">// packages/ui/src/index.ts
export { Button } from './Button';
export { Modal } from './Modal';
export { Card } from './Card';
</code></pre>
<p>Now your apps consume it like any npm package:</p>
<pre><code class="language-json">// apps/web/package.json
{
  "dependencies": {
    "@my-platform/ui": "*"
  }
}
</code></pre>
<pre><code class="language-tsx">// apps/web/app/dashboard/page.tsx
import { Card, Button } from '@my-platform/ui';
</code></pre>
<p>Change <code>Card</code> once in <code>packages/ui</code>, and every app that uses it gets the update, no copy-pasting, no drift.</p>
<p><strong>Important:</strong> Because the package points directly at TypeScript source files (not compiled output), each consuming Next.js app must tell the bundler to transpile it. Add this to your Next.js config:</p>
<pre><code class="language-ts">// apps/web/next.config.ts
const config: import('next').NextConfig = {
  transpilePackages: ['@my-platform/ui', '@my-platform/auth', '@my-platform/utils'],
};

export default config;
</code></pre>
<p>Without this, the build fails with syntax errors, Next.js doesn't transpile packages from <code>node_modules</code> or workspace dependencies by default. The alternative is compiling each package to <code>dist/</code> and pointing <code>exports</code> there, but that adds a build step to every package and slows down the dev feedback loop. For internal monorepo packages, <code>transpilePackages</code> is the simpler tradeoff.</p>
<h3 id="heading-the-turbojson-pipeline">The <code>turbo.json</code> Pipeline</h3>
<p>Turborepo's real power is its build pipeline. It understands the dependency graph between your packages and apps, caches build outputs, and runs tasks in parallel where possible.</p>
<pre><code class="language-json">// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "type-check": {
      "dependsOn": ["^build"],
      "outputs": []
    }
  }
}
</code></pre>
<p>The <code>^build</code> syntax means: <em>before building this package, build all its dependencies first.</em> So if <code>apps/web</code> depends on <code>packages/ui</code>, Turborepo ensures <code>packages/ui</code> is built before <code>apps/web</code> starts. Remote caching means if <code>packages/ui</code> hasn't changed, Turborepo skips rebuilding it entirely, even across CI runs and team members' machines.</p>
<h3 id="heading-what-goes-in-a-package-vs-an-app">What Goes in a Package vs. an App</h3>
<p>A useful litmus test:</p>
<table>
<thead>
<tr>
<th>Lives in <code>packages/</code></th>
<th>Lives in <code>apps/</code></th>
</tr>
</thead>
<tbody><tr>
<td>Design system / UI primitives</td>
<td>Route definitions</td>
</tr>
<tr>
<td>Auth utilities and types</td>
<td>App-specific layouts</td>
</tr>
<tr>
<td>Database client and queries</td>
<td>Feature-specific pages</td>
</tr>
<tr>
<td>Shared TypeScript configs</td>
<td>API route handlers</td>
</tr>
<tr>
<td>Analytics abstractions</td>
<td>Environment-specific config</td>
</tr>
<tr>
<td>Generic hooks (useDebounce)</td>
<td>App-specific business logic</td>
</tr>
</tbody></table>
<p>If two apps need the same logic, it goes in a package. If only one app needs it, it stays in that app, even if you <em>think</em> the other app might need it someday. Premature abstraction is just as damaging as none at all.</p>
<h2 id="heading-layer-4-server-components-and-data-fetching-boundaries">Layer 4: Server Components and Data-Fetching Boundaries</h2>
<p>The App Router's Server Components model is arguably the most architecturally significant change Next.js has ever shipped, and also the most misunderstood.</p>
<p>Most developers approach it as a performance optimisation. It is that, but it's more importantly an <em>architectural boundary</em>. Understanding where that boundary sits, and designing around it deliberately, is what separates scalable App Router codebases from ones that fight the framework.</p>
<h3 id="heading-the-mental-model-two-worlds">The Mental Model: Two Worlds</h3>
<p>Every component in the App Router lives in one of two worlds:</p>
<p><strong>Server Components</strong> (default) run exclusively on the server. They can <code>await</code> data directly, access databases, read environment variables, and reduce the JavaScript sent to the browser. They can't use browser APIs, <code>useState</code>, <code>useEffect</code>, or event handlers.</p>
<p><strong>Client Components</strong> (<code>'use client'</code>) run in the browser (and also during SSR/hydration). They can use hooks, handle events, and access browser APIs. They can't directly <code>await</code> server-side resources.</p>
<p>The directive <code>'use client'</code> doesn't mean <em>"this runs only in the browser"</em> , it means <em>"this is the boundary where the server-to-client handoff begins."</em> Any module <em>imported</em> by a Client Component becomes part of the client bundle.</p>
<p>But Server Components <em>passed as props</em> (typically via <code>children</code>) retain their server-only nature, they're rendered on the server and streamed as HTML, not included in the client bundle. This distinction is what makes the composition pattern below work.</p>
<h3 id="heading-designing-the-boundary">Designing the Boundary</h3>
<p>The goal is to push the <code>'use client'</code> boundary as far down the tree as possible, keeping data fetching and heavy logic on the server, and reserving Client Components for genuinely interactive leaves.</p>
<p>A pattern that works well in practice:</p>
<pre><code class="language-tsx">// app/dashboard/page.tsx , Server Component
// Fetches data, no 'use client' directive needed

import { getMetrics } from '@/features/analytics/lib/queries';
import { MetricsDashboard } from './components/MetricsDashboard';

export default async function DashboardPage() {
  const metrics = await getMetrics();   // Direct DB call , no API round-trip
  return &lt;MetricsDashboard data={metrics} /&gt;;
}
</code></pre>
<pre><code class="language-tsx">// app/dashboard/components/MetricsDashboard.tsx , Server Component
// Composes layout, delegates interactivity to leaves

import { StatsCard } from './StatsCard';
import { ChartSection } from './ChartSection';

export function MetricsDashboard({ data }) {
  return (
    &lt;div className="grid gap-6"&gt;
      &lt;StatsCard value={data.revenue} label="Revenue" /&gt;
      &lt;ChartSection points={data.trend} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-tsx">// app/dashboard/components/ChartSection.tsx , Client Component
// Interactive chart needs browser APIs

'use client';

import { useState } from 'react';
import { LineChart, RangeSelector } from '@my-platform/ui';

export function ChartSection({ points }) {
  const [range, setRange] = useState('7d');
  return (
    &lt;div&gt;
      &lt;RangeSelector value={range} onChange={setRange} /&gt;
      &lt;LineChart data={points.filter(/* range logic */)} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>The data flows from server to client in one direction. The server does the expensive work (database query), passes serialisable data down as props, and the client receives a ready-to-render dataset – no loading spinners, no client-side fetch waterfalls.</p>
<h3 id="heading-colocating-data-fetching-with-routes">Colocating Data Fetching with Routes</h3>
<p>A powerful pattern enabled by Server Components is colocating data fetching directly with the route that needs it, eliminating the need for global state management in many cases.</p>
<pre><code class="language-plaintext">app/
  orders/
    page.tsx              # await getOrders() , renders list
    [id]/
      page.tsx            # await getOrder(id) , renders single order
      loading.tsx         # Streaming skeleton while awaiting
      components/
        OrderTimeline.tsx  # Server Component , renders timeline data
        CancelButton.tsx  # 'use client' , needs click handler
</code></pre>
<p>Each page fetches its own data, scoped to what it needs. Nested layouts and pages can fetch concurrently when using <code>Promise.all</code> or parallel route segments. And <code>loading.tsx</code> gives you streaming suspense boundaries without writing a single <code>&lt;Suspense&gt;</code> wrapper manually.</p>
<h3 id="heading-when-to-use-a-fetch-layer-vs-direct-queries">When to Use a Fetch Layer vs. Direct Queries</h3>
<p>As apps scale, you'll want a consistent approach to data access. A practical pattern:</p>
<pre><code class="language-typescript">// packages/database/src/queries/orders.ts
// Runs on the server , can be imported in any Server Component

import { db } from '../client';

export async function getOrdersByUser(userId: string) {
  return db.order.findMany({
    where: { userId },
    include: { items: true },
    orderBy: { createdAt: 'desc' },
  });
}
</code></pre>
<pre><code class="language-typescript">// packages/database/src/index.ts
export { getOrdersByUser } from './queries/orders';
export { getProductById } from './queries/products';
// ...
</code></pre>
<p>Your Server Components import from <code>@my-platform/database</code>. Your Client Components never touch this package: they call API routes or Server Actions if they need to mutate data. This keeps the boundary clean and auditable.</p>
<h3 id="heading-server-actions-for-mutations">Server Actions for Mutations</h3>
<p>Data fetching flows through Server Components, but mutations need their own boundary. Server Actions (<code>'use server'</code>) let you define server-side functions that Client Components can call directly – no API route boilerplate needed.</p>
<pre><code class="language-typescript">// app/orders/[id]/actions.ts
'use server';

import { db } from '@my-platform/database';
import { revalidatePath } from 'next/cache';

export async function cancelOrder(orderId: string) {
  await db.order.update({
    where: { id: orderId },
    data: { status: 'cancelled', cancelledAt: new Date() },
  });

  revalidatePath(`/orders/${orderId}`);
}
</code></pre>
<pre><code class="language-tsx">// app/orders/[id]/components/CancelButton.tsx
'use client';

import { cancelOrder } from '../actions';
import { useTransition } from 'react';

export function CancelButton({ orderId }: { orderId: string }) {
  const [isPending, startTransition] = useTransition();

  return (
    &lt;button
      disabled={isPending}
      onClick={() =&gt; startTransition(() =&gt; cancelOrder(orderId))}
    &gt;
      {isPending ? 'Cancelling...' : 'Cancel Order'}
    &lt;/button&gt;
  );
}
</code></pre>
<p>The architectural decision:</p>
<ul>
<li><p><strong>use Server Actions for mutations that are colocated with a specific route</strong> (cancelling an order, updating a profile).</p>
</li>
<li><p><strong>Use API routes for mutations that are consumed by external clients</strong> (webhooks, mobile apps, third-party integrations).</p>
</li>
</ul>
<p>Server Actions keep mutation logic close to the UI that triggers it. API routes provide a stable contract for external consumers.</p>
<p>This completes the data flow picture: Server Components handle reads, Server Actions handle writes, and Client Components are the interactive surface that connects them.</p>
<h2 id="heading-layer-5-testing-strategy-for-a-layered-codebase">Layer 5: Testing Strategy for a Layered Codebase</h2>
<p>The testing pyramid is one of those concepts that sounds obvious in theory but falls apart in practice, usually because the codebase doesn't have clear boundaries to test against. When everything is tangled, every test becomes an integration test by accident.</p>
<p>The layered architecture you've built changes this: each layer has a defined surface area, so you can test each one at the right level of abstraction.</p>
<h3 id="heading-test-each-layer-at-the-right-granularity">Test Each Layer at the Right Granularity</h3>
<p>The layered architecture maps naturally onto the testing pyramid:</p>
<table>
<thead>
<tr>
<th>Layer</th>
<th>Test Type</th>
<th>Tools</th>
</tr>
</thead>
<tbody><tr>
<td><code>packages/</code> (utils, db queries)</td>
<td>Unit tests</td>
<td>Vitest</td>
</tr>
<tr>
<td><code>features/</code> (hooks, lib, components)</td>
<td>Unit + Integration</td>
<td>Vitest + React Testing Library</td>
</tr>
<tr>
<td>App Router pages (Server Components)</td>
<td>Integration</td>
<td>Vitest + custom render</td>
</tr>
<tr>
<td>Critical user flows (checkout, auth)</td>
<td>End-to-end</td>
<td>Playwright</td>
</tr>
</tbody></table>
<p>The goal: test shared packages exhaustively, test features thoroughly, test pages for integration correctness, and use E2E only for the flows that matter most.</p>
<p>Not everything needs an E2E test, and treating E2E as the default testing strategy is one of the most expensive mistakes a team can make.</p>
<h3 id="heading-unit-testing-shared-packages">Unit Testing Shared Packages</h3>
<p>Packages in <code>packages/</code> are the easiest to test. They're pure TypeScript with no framework coupling. Use Vitest:</p>
<pre><code class="language-typescript">// packages/utils/src/dates.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatRelativeDate } from './dates';

describe('formatRelativeDate', () =&gt; {
  beforeEach(() =&gt; {
    // Pin the clock to avoid flaky results near midnight
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
  });

  afterEach(() =&gt; {
    vi.useRealTimers();
  });

  it('returns "today" for dates within the current day', () =&gt; {
    expect(formatRelativeDate(new Date())).toBe('today');
  });

  it('returns "yesterday" for dates on the previous day', () =&gt; {
    const yesterday = new Date('2026-03-14T15:00:00Z');
    expect(formatRelativeDate(yesterday)).toBe('yesterday');
  });
});
</code></pre>
<p>Keep package tests colocated with the source file. A <code>dates.ts</code> file has a <code>dates.test.ts</code> sibling. No separate <code>__tests__</code> folders, those are relics of less structured codebases.</p>
<h3 id="heading-testing-feature-modules">Testing Feature Modules</h3>
<p>Features are where most of your business logic lives, so they get the most test coverage. The key rule: test the public API of the feature, not its internals.</p>
<pre><code class="language-typescript">// features/auth/hooks/useAuth.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAuth } from '../hooks/useAuth';
import { createWrapper } from '@/test/utils'; // your test provider wrapper

describe('useAuth', () =&gt; {
  it('returns authenticated state when session exists', async () =&gt; {
    const { result } = renderHook(() =&gt; useAuth(), {
      wrapper: createWrapper({ session: mockSession }),
    });

    expect(result.current.isAuthenticated).toBe(true);
    expect(result.current.user.email).toBe(mockSession.user.email);
  });

  it('redirects to login when session is null', async () =&gt; {
    const { result } = renderHook(() =&gt; useAuth(), {
      wrapper: createWrapper({ session: null }),
    });

    expect(result.current.isAuthenticated).toBe(false);
  });
});
</code></pre>
<p>Notice that the test imports from the hook directly, not from the feature's <code>index.ts</code> barrel. Feature index exports are public APIs. They're tested through integration tests that consume the feature as a whole. Internal hooks and utilities are tested at the unit level. Both are valid, and the distinction is intentional.</p>
<h3 id="heading-testing-server-components">Testing Server Components</h3>
<p>Server Components are async functions that return JSX. Testing them directly is still an evolving story. React's test renderer doesn't natively handle async components, and calling <code>await DashboardPage()</code> then passing the result to <code>render()</code> produces subtle issues (missing context, <code>act()</code> warnings, or outright failures depending on your setup).</p>
<p>The most reliable approach today is to <strong>test the layers separately</strong>: mock the data layer to verify it's called correctly, and test the presentational component with static props.</p>
<pre><code class="language-typescript">// app/dashboard/components/MetricsDashboard.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MetricsDashboard } from './MetricsDashboard';

describe('MetricsDashboard', () =&gt; {
  it('renders revenue metric from provided data', () =&gt; {
    render(
      &lt;MetricsDashboard data={{ revenue: 84200, trend: [] }} /&gt;
    );

    expect(screen.getByText('£84,200')).toBeInTheDocument();
  });
});
</code></pre>
<pre><code class="language-typescript">// features/analytics/lib/queries.test.ts
import { describe, it, expect } from 'vitest';
import { getMetrics } from './queries';

describe('getMetrics', () =&gt; {
  it('returns revenue and trend data', async () =&gt; {
    const metrics = await getMetrics();

    expect(metrics.revenue).toBeGreaterThan(0);
    expect(Array.isArray(metrics.trend)).toBe(true);
  });
});
</code></pre>
<p>The key insight: mock at the data layer boundary, not at the database or network layer. The data query has its own tests in <code>packages/database</code>. The presentational component has its own tests with static props. The Server Component page wires them together, and that wiring is verified by your E2E tests, which are better suited to catching integration issues across the async boundary.</p>
<h3 id="heading-end-to-end-tests-with-playwright">End-to-End Tests with Playwright</h3>
<p>Reserve Playwright for the flows that touch multiple layers and where a breakage would be catastrophic: authentication, checkout, and form submission with side effects. Don't use it for visual regressions or static content, as that's expensive and slow.</p>
<pre><code class="language-typescript">// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test('user can log in and reach dashboard', async ({ page }) =&gt; {
  await page.goto('/login');
  await page.fill('[name="email"]', 'test@example.com');
  await page.fill('[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
</code></pre>
<p>Colocate E2E tests in a top-level <code>e2e/</code> folder at the monorepo root. They span apps and don't belong inside any single app's directory.</p>
<h3 id="heading-configuring-vitest-across-the-monorepo">Configuring Vitest Across the Monorepo</h3>
<p>Each package and app has its own <code>vitest.config.ts</code>, but they can share a base config via a shared package:</p>
<pre><code class="language-typescript">// packages/config/vitest.base.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
    },
  },
});
</code></pre>
<pre><code class="language-typescript">// apps/web/vitest.config.ts
import { mergeConfig } from 'vitest/config';
import base from '@my-platform/config/vitest.base';

export default mergeConfig(base, {
  test: {
    include: ['src/**/*.test.{ts,tsx}', 'app/**/*.test.{ts,tsx}'],
  },
});
</code></pre>
<p>This ensures consistent test configuration across every app and package without duplication.</p>
<h2 id="heading-layer-6-cicd-with-turborepo">Layer 6: CI/CD with Turborepo</h2>
<p>A well-designed monorepo without a smart CI pipeline is just a big repo. Turborepo's real power emerges in CI, where it can cut build and test times dramatically through caching and intelligent task scheduling.</p>
<h3 id="heading-the-core-insight-only-run-what-changed">The Core Insight: Only Run What Changed</h3>
<p>Traditional CI pipelines run everything on every commit. In a monorepo, this means running tests for <code>apps/admin</code> when you only changed a utility in <code>apps/web</code>. Turborepo's dependency graph awareness eliminates this.</p>
<p>When you run <code>turbo test</code>, Turborepo:</p>
<ol>
<li><p>Builds the dependency graph from your <code>package.json</code> files</p>
</li>
<li><p>Checks which packages have changed (against the last cached state)</p>
</li>
<li><p>Runs tests only for changed packages and their dependents</p>
</li>
<li><p>Caches results. If nothing changed, it restores from cache instantly.</p>
</li>
</ol>
<p>A change to <code>packages/ui</code> triggers tests for <code>packages/ui</code>, <code>apps/web</code>, and <code>apps/admin</code> (since both depend on it). A change only to <code>apps/web</code> triggers tests for <code>apps/web</code> only.</p>
<h3 id="heading-remote-caching">Remote Caching</h3>
<p>Without remote caching, Turborepo's local cache doesn't help in CI – each run starts fresh. With remote caching, build and test artifacts are stored in the cloud and shared across all CI runners and developers' machines.</p>
<pre><code class="language-bash"># Authenticate with Turborepo remote cache (Vercel)
npx turbo login
npx turbo link
</code></pre>
<p>Or use a self-hosted cache server if you need to keep artifacts on your own infrastructure. Once configured, a CI run on a branch that touched only <code>apps/web</code> might take 45 seconds instead of 8 minutes, because every <code>packages/*</code> task restores from cache.</p>
<h3 id="heading-a-production-ready-github-actions-pipeline">A Production-Ready GitHub Actions Pipeline</h3>
<p>Here's a complete pipeline that uses Turborepo's caching, runs affected tasks only, and splits lint, test, and build into parallel jobs:</p>
<pre><code class="language-yaml"># .github/workflows/ci.yml
name: CI

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

env:
  TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
  TURBO_TEAM: ${{ secrets.TURBO_TEAM }}

jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo lint --filter="...[origin/main]"

  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo test --filter="...[origin/main]"

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx turbo build --filter="...[origin/main]"

  e2e:
    name: E2E Tests
    runs-on: ubuntu-latest
    needs: [build]
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - run: npm ci
      - run: npx playwright install --with-deps

      - name: Build the app (restores from Turborepo cache if unchanged)
        run: npx turbo build --filter="apps/web"

      - name: Run E2E tests
        run: npx turbo e2e
</code></pre>
<p>The E2E job assumes Playwright's <code>webServer</code> config handles starting the app automatically. Configure this in your <code>playwright.config.ts</code>:</p>
<pre><code class="language-typescript">// playwright.config.ts
export default defineConfig({
  webServer: {
    command: 'npm run start --prefix apps/web',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});
</code></pre>
<p>This way Playwright starts the production server before tests run and tears it down afterwards – no manual server management in CI.</p>
<p>The <code>--filter="...[origin/main]"</code> flag is the critical piece. It tells Turborepo to run tasks only for packages that have changed since the <code>main</code> branch, plus all packages that depend on those changed packages. This is the most impactful optimisation in the whole pipeline.</p>
<h3 id="heading-filtering-strategies">Filtering Strategies</h3>
<p>Turborepo's <code>--filter</code> flag is flexible and worth understanding:</p>
<pre><code class="language-bash"># Only run tasks for packages that changed vs main
turbo test --filter="...[origin/main]"

# Run tasks for a specific app and all its dependencies
turbo build --filter="apps/web..."

# Run tasks for everything except a specific app
turbo test --filter="!apps/admin"

# Run tasks for all apps (not packages)
turbo build --filter="./apps/*"
</code></pre>
<p>For most CI pipelines, <code>--filter="...[origin/main]"</code> on feature branches and <code>turbo run test build</code> (no filter) on <code>main</code> merges is the right split. You want fast feedback on PRs and confidence that everything still works on main.</p>
<h3 id="heading-deployment-pipeline-with-per-app-filtering">Deployment Pipeline with Per-App Filtering</h3>
<p>When deploying to Vercel, Netlify, or any platform with per-app deployments, Turborepo lets you detect which apps actually changed and skip deployments for unchanged ones:</p>
<pre><code class="language-yaml"># .github/workflows/deploy.yml
- name: Check if web app changed
  id: check-web
  run: |
    CHANGED=$(npx turbo run build --filter="apps/web...[origin/main]" --dry=json | jq '.packages | length')
    echo "changed=\(CHANGED" &gt;&gt; \)GITHUB_OUTPUT

- name: Deploy web
  if: steps.check-web.outputs.changed != '0'
  run: vercel deploy --prod
  env:
    VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
</code></pre>
<p>This ensures your admin app doesn't trigger a deployment when only the marketing site changed, reducing deploy times, costs, and the blast radius of any deployment failure.</p>
<h3 id="heading-environment-variable-management">Environment Variable Management</h3>
<p>One of the trickier parts of a monorepo CI setup is environment variables: each app needs its own secrets, but some are shared across apps.</p>
<p>A clean convention:</p>
<pre><code class="language-plaintext"># .env (repo root , shared across all apps in local dev)
DATABASE_URL=...
REDIS_URL=...

# apps/web/.env.local (web-specific overrides)
NEXT_PUBLIC_APP_URL=https://app.example.com
STRIPE_KEY=...

# apps/admin/.env.local (admin-specific)
NEXT_PUBLIC_APP_URL=https://admin.example.com
ADMIN_SECRET=...
</code></pre>
<p>In CI, store shared secrets as organisation-level GitHub secrets and app-specific secrets as repository-level secrets scoped to the appropriate environment.</p>
<p>Never store secrets in <code>turbo.json</code> or any committed file. Instead, use <code>env</code> in your pipeline steps and Turborepo's <code>globalEnv</code> field in <code>turbo.json</code> to declare which env vars should bust the cache when they change:</p>
<pre><code class="language-json">// turbo.json
{
  "globalEnv": ["NODE_ENV", "DATABASE_URL"],
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_APP_URL", "STRIPE_KEY"],
      "dependsOn": ["^build"],
      "outputs": [".next/**"]
    }
  }
}
</code></pre>
<p>This tells Turborepo: if <code>DATABASE_URL</code> changes, invalidate the cache for all tasks. If <code>NEXT_PUBLIC_APP_URL</code> changes, only invalidate the <code>build</code> task. Without this, you risk Turborepo restoring a cached build that was compiled against a different environment, a subtle and painful bug.</p>
<h2 id="heading-putting-it-all-together-the-full-blueprint">Putting It All Together: The Full Blueprint</h2>
<p>Here's what the complete architecture looks like assembled:</p>
<pre><code class="language-plaintext">my-platform/
  apps/
    web/
      app/
        (marketing)/
          layout.tsx
          page.tsx
          about/page.tsx
        (app)/
          layout.tsx            # Auth-protected shell
          dashboard/
            page.tsx            # Server Component , fetches data
            loading.tsx
            components/
              MetricsDashboard.tsx
              ChartSection.tsx  # 'use client'
          orders/
            page.tsx
            [id]/
              page.tsx
              components/
                OrderTimeline.tsx
                CancelButton.tsx  # 'use client'
      src/
        features/
          auth/
            components/
            hooks/
            lib/
            index.ts
          billing/
            ...
        shared/
          components/
          hooks/
          lib/
    admin/
      app/
        ...                     # Same layer structure
      src/
        features/
          ...
  packages/
    ui/                         # Shared primitives
    auth/                       # Shared auth logic
    database/                   # Prisma + queries
    config/                     # ESLint, TS, Tailwind configs
    utils/                      # Generic helpers
  turbo.json
  package.json
</code></pre>
<p>Notice how the <code>'use client'</code> boundary appears only at the interactive leaves: <code>ChartSection.tsx</code> needs <code>useState</code>, and <code>CancelButton.tsx</code> needs a click handler and <code>useTransition</code>. Everything above them (<code>MetricsDashboard.tsx</code>, <code>OrderTimeline.tsx</code>, the page components) stays on the server, fetching data and composing layout without shipping any JavaScript to the browser.</p>
<p>The layers stack cleanly:</p>
<ol>
<li><p><strong>Turborepo packages</strong>: the lowest layer. Generic, reusable, no app-specific knowledge.</p>
</li>
<li><p><strong>Shared feature layer</strong>: cross-cutting app concerns. Can consume packages, knows nothing of routes.</p>
</li>
<li><p><strong>Feature modules</strong>: domain logic, encapsulated behind barrel exports.</p>
</li>
<li><p><strong>App Router</strong>: routes, layouts, colocation. Consumes features and packages. Data flows through Server Components, interactivity is delegated to Client Component leaves.</p>
</li>
</ol>
<h2 id="heading-common-pitfalls-and-how-to-avoid-them">Common Pitfalls and How to Avoid Them</h2>
<p><strong>"I'll just put it in</strong> <code>/utils</code> <strong>for now."</strong> This is how junk drawers form. If you can't name what a utility belongs to, it probably needs a new feature folder, not a generic dumping ground.</p>
<p><strong>Over-extracting packages too early</strong>: Not everything needs to be a shared package. Start in the app, extract to a package only when a second consumer appears. The cost of premature abstraction is maintenance overhead and false coupling.</p>
<p><strong>Client Components at the top of every tree</strong>: If your route's <code>page.tsx</code> has <code>'use client'</code> at the top, you've lost most of what Server Components give you. Push the directive down to the interactive leaf.</p>
<p><strong>Circular package dependencies</strong>: If <code>packages/auth</code> imports from <code>packages/database</code> and <code>packages/database</code> imports from <code>packages/auth</code>, you have a cycle. Keep the dependency graph a DAG: each package should have one clear level of abstraction.</p>
<p><strong>Barrel files that export everything</strong>: The barrel file is a public API, not an index of every file in the folder. Export only what other parts of the app are meant to use.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>Good architecture isn't about finding the perfect structure, it's about making the right decisions easy and the wrong decisions hard.</p>
<ul>
<li><p><strong>Colocation</strong> makes it easy to find what you need.</p>
</li>
<li><p><strong>Feature modules</strong> make it hard to accidentally couple unrelated domains.</p>
</li>
<li><p><strong>Turborepo</strong> makes it easy to share code and hard to duplicate it.</p>
</li>
<li><p><strong>Server Components</strong> make it easy to fetch data where you need it and hard to send unnecessary JavaScript to the browser.</p>
</li>
</ul>
<p>None of these ideas are new. Layered architecture, separation of concerns, and encapsulation are decades-old principles. What Next.js and Turborepo give you is a modern toolkit to express them idiomatically in a JavaScript codebase.</p>
<p>The best time to set this up is at the start of a project. The second best time is now, before the next feature makes untangling things twice as hard.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Share Components Between Server and Client in NextJS ]]>
                </title>
                <description>
                    <![CDATA[ Next.js App Router splits your app into Server Components and Client Components. Server Components run on the server and keep secrets safe. Client Components run in the browser and handle interactivit ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-share-components-between-server-and-client-in-nextjs/</link>
                <guid isPermaLink="false">69c6eb7c7cf27065104ba964</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ components ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Fri, 27 Mar 2026 20:41:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/80c9b6dc-daaa-49d5-8c3a-f61bff3b7e11.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Next.js App Router splits your app into Server Components and Client Components. Server Components run on the server and keep secrets safe. Client Components run in the browser and handle interactivity. The challenge is sharing data and UI between them without breaking the rules of each environment.</p>
<p>This guide shows you how to share components and data between Server and Client Components in Next.js. You'll learn composition patterns, prop passing rules, and when to use each approach.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-are-server-and-client-components">What are Server and Client Components?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-pass-data-from-server-to-client-via-props">How to Pass Data from Server to Client via Props</a></p>
</li>
<li><p><a href="#heading-how-to-pass-server-components-as-children-to-client-components">How to Pass Server Components as Children to Client Components</a></p>
</li>
<li><p><a href="#heading-what-props-are-allowed-between-server-and-client">What Props Are Allowed Between Server and Client</a></p>
</li>
<li><p><a href="#heading-how-to-share-data-with-context-and-reactcache">How to Share Data with Context and React.cache</a></p>
</li>
<li><p><a href="#heading-how-to-use-third-party-components-in-both-environments">How to Use Third-Party Components in Both Environments</a></p>
</li>
<li><p><a href="#heading-how-to-prevent-environment-poisoning-with-server-only-and-client-only">How to Prevent Environment Poisoning with server-only and client-only</a></p>
</li>
<li><p><a href="#heading-real-world-examples">Real-World Examples</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-are-server-and-client-components">What are Server and Client Components?</h2>
<p>In the Next.js App Router, every component is a Server Component by default. Server Components run only on the server. They can fetch data from databases, use API keys, and keep sensitive logic out of the browser. They don't send JavaScript to the client, which reduces bundle size.</p>
<p>Client Components run on both the server (for the initial HTML) and the client (for interactivity). You mark them with the <code>"use client"</code> directive at the top of the file. They can use <code>useState</code>, <code>useEffect</code>, event handlers, and browser APIs like <code>localStorage</code> and <code>window</code>.</p>
<p>The key rule: <strong>Server Components can import and render Client Components, but Client Components can't import Server Components directly.</strong> They can only receive them as props (such as <code>children</code>).</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you follow along, you should have:</p>
<ul>
<li><p>Basic familiarity with React (components, props, hooks)</p>
</li>
<li><p>A Next.js project using the App Router (Next.js 13 or later)</p>
</li>
<li><p>Node.js installed (version 18 or later recommended)</p>
</li>
</ul>
<p>If you don't have a Next.js project yet, create one with:</p>
<pre><code class="language-bash">npx create-next-app@latest my-app
</code></pre>
<h2 id="heading-how-to-pass-data-from-server-to-client-via-props">How to Pass Data from Server to Client via Props</h2>
<p>The simplest way to share data between Server and Client Components is to pass it as props. The Server Component fetches the data, and the Client Component receives it and handles interactivity.</p>
<p>Here is a basic example. A page (Server Component) fetches a post and passes the like count to a <code>LikeButton</code> (Client Component):</p>
<pre><code class="language-jsx">// app/post/[id]/page.jsx (Server Component)
import LikeButton from '@/app/ui/like-button';
import { getPost } from '@/lib/data';

export default async function PostPage({ params }) {
  const { id } = await params;
  const post = await getPost(id);

  return (
    &lt;div&gt;
      &lt;h1&gt;{post.title}&lt;/h1&gt;
      &lt;p&gt;{post.content}&lt;/p&gt;
      &lt;LikeButton likes={post.likes} postId={post.id} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/ui/like-button.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function LikeButton({ likes, postId }) {
  const [count, setCount] = useState(likes);

  const handleLike = () =&gt; {
    setCount((c) =&gt; c + 1);
    // Call API or Server Action to persist
  };

  return (
    &lt;button onClick={handleLike}&gt;
      {count} likes
    &lt;/button&gt;
  );
}
</code></pre>
<p>The Server Component fetches data on the server. The Client Component receives plain values (<code>likes</code>, <code>postId</code>) and manages state and events. This pattern keeps data fetching on the server and interactivity on the client.</p>
<h2 id="heading-how-to-pass-server-components-as-children-to-client-components">How to Pass Server Components as Children to Client Components</h2>
<p>You can pass a Server Component as the <code>children</code> prop (or any prop) to a Client Component. The Server Component still renders on the server. The Client Component receives the rendered output, not the component code.</p>
<p>This is useful when you want a Client Component to wrap or control the layout of server-rendered content. For example, a modal that shows server-fetched data:</p>
<pre><code class="language-jsx">// app/ui/modal.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function Modal({ children }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    &lt;&gt;
      &lt;button onClick={() =&gt; setIsOpen(true)}&gt;Open&lt;/button&gt;
      {isOpen &amp;&amp; (
        &lt;div className="modal-overlay"&gt;
          &lt;div className="modal-content"&gt;{children}&lt;/div&gt;
        &lt;/div&gt;
      )}
    &lt;/&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/cart/page.jsx (Server Component)
import Modal from '@/app/ui/modal';
import Cart from '@/app/ui/cart';

export default function CartPage() {
  return (
    &lt;Modal&gt;
      &lt;Cart /&gt;
    &lt;/Modal&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/ui/cart.jsx (Server Component - no 'use client')
import { getCart } from '@/lib/cart';

export default async function Cart() {
  const items = await getCart();

  return (
    &lt;ul&gt;
      {items.map((item) =&gt; (
        &lt;li key={item.id}&gt;{item.name}&lt;/li&gt;
      ))}
    &lt;/ul&gt;
  );
}
</code></pre>
<p><code>Cart</code> is a Server Component that fetches cart data. It's passed as <code>children</code> to <code>Modal</code>, which is a Client Component. The server renders <code>Cart</code> first. The RSC Payload includes the rendered result. The client receives that output and displays it inside the modal. The cart data never runs on the client.</p>
<p>You can use the same pattern with named props (slots):</p>
<pre><code class="language-jsx">// app/ui/tabs.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function Tabs({ tabs, children }) {
  const [activeIndex, setActiveIndex] = useState(0);

  return (
    &lt;div&gt;
      &lt;div className="tab-list"&gt;
        {tabs.map((tab, i) =&gt; (
          &lt;button
            key={tab.id}
            onClick={() =&gt; setActiveIndex(i)}
            className={activeIndex === i ? 'active' : ''}
          &gt;
            {tab.label}
          &lt;/button&gt;
        ))}
      &lt;/div&gt;
      &lt;div className="tab-content"&gt;{children[activeIndex]}&lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/dashboard/page.jsx (Server Component)
import Tabs from '@/app/ui/tabs';
import Overview from '@/app/ui/overview';
import Analytics from '@/app/ui/analytics';

export default function DashboardPage() {
  const tabs = [
    { id: 'overview', label: 'Overview' },
    { id: 'analytics', label: 'Analytics' },
  ];

  return (
    &lt;Tabs tabs={tabs}&gt;
      &lt;Overview /&gt;
      &lt;Analytics /&gt;
    &lt;/Tabs&gt;
  );
}
</code></pre>
<p><code>Overview</code> and <code>Analytics</code> can be Server Components that fetch their own data. They render on the server, and the client receives the pre-rendered output.</p>
<h2 id="heading-what-props-are-allowed-between-server-and-client">What Props Are Allowed Between Server and Client</h2>
<p>Props passed from Server Components to Client Components must be <strong>serializable</strong>. React serializes them into the RSC Payload so they can be sent to the client.</p>
<h3 id="heading-allowed-types">Allowed Types</h3>
<ul>
<li><p>Strings, numbers, booleans</p>
</li>
<li><p><code>null</code> and <code>undefined</code></p>
</li>
<li><p>Plain objects (no functions, no class instances)</p>
</li>
<li><p>Arrays of serializable values</p>
</li>
<li><p>JSX (Server Components as children or other props)</p>
</li>
<li><p>Server Actions (functions with <code>"use server"</code>)</p>
</li>
</ul>
<h3 id="heading-not-allowed">Not Allowed</h3>
<ul>
<li><p>Functions (except Server Actions)</p>
</li>
<li><p><code>Date</code> objects</p>
</li>
<li><p>Class instances</p>
</li>
<li><p>Symbols</p>
</li>
<li><p><code>Map</code>, <code>Set</code>, <code>WeakMap</code>, <code>WeakSet</code></p>
</li>
<li><p>Objects with custom prototypes</p>
</li>
<li><p>Buffers, <code>ArrayBuffer</code>, typed arrays</p>
</li>
</ul>
<p>If you need to pass a <code>Date</code>, convert it to a string or number first:</p>
<pre><code class="language-jsx">&lt;ClientComponent createdAt={post.createdAt.toISOString()} /&gt;
</code></pre>
<p>If you use MongoDB, convert <code>ObjectId</code> to a string:</p>
<pre><code class="language-jsx">&lt;PostThread userId={user._id.toString()} /&gt;
</code></pre>
<h3 id="heading-passing-server-actions-as-props">Passing Server Actions as props</h3>
<p>Server Actions are async functions marked with <code>"use server"</code>. You can pass them as props to Client Components. They are serialized by reference, not by value.</p>
<pre><code class="language-jsx">// app/actions/post.js
'use server';

export async function likePost(postId) {
  // Update database
  revalidatePath(`/post/${postId}`);
}
</code></pre>
<pre><code class="language-jsx">// app/post/[id]/page.jsx (Server Component)
import LikeButton from '@/app/ui/like-button';
import { likePost } from '@/app/actions/post';

export default async function PostPage({ params }) {
  const post = await getPost((await params).id);

  return &lt;LikeButton likes={post.likes} postId={post.id} onLike={likePost} /&gt;;
}
</code></pre>
<pre><code class="language-jsx">// app/ui/like-button.jsx (Client Component)
'use client';

export default function LikeButton({ likes, postId, onLike }) {
  const handleClick = () =&gt; {
    onLike(postId);
  };

  return &lt;button onClick={handleClick}&gt;{likes} likes&lt;/button&gt;;
}
</code></pre>
<p>You can also bind arguments:</p>
<pre><code class="language-jsx">&lt;LikeButton onLike={likePost.bind(null, post.id)} /&gt;
</code></pre>
<h2 id="heading-how-to-share-data-with-context-and-reactcache">How to Share Data with Context and React.cache</h2>
<p>React Context doesn't work in Server Components. To share data between Server and Client Components, you can combine a Client Component context provider with <code>React.cache</code> for server-side memoization.</p>
<p>Create a cached fetch function:</p>
<pre><code class="language-js">// lib/user.js
import { cache } from 'react';

export const getUser = cache(async () =&gt; {
  const res = await fetch('https://api.example.com/user');
  return res.json();
});
</code></pre>
<p>Create a provider that accepts a promise and stores it in context:</p>
<pre><code class="language-jsx">// app/providers/user-provider.jsx
'use client';

import { createContext } from 'react';

export const UserContext = createContext(null);

export default function UserProvider({ children, userPromise }) {
  return (
    &lt;UserContext.Provider value={userPromise}&gt;
      {children}
    &lt;/UserContext.Provider&gt;
  );
}
</code></pre>
<p>In your root layout, pass the promise without awaiting it:</p>
<pre><code class="language-jsx">// app/layout.jsx
import UserProvider from '@/app/providers/user-provider';
import { getUser } from '@/lib/user';

export default function RootLayout({ children }) {
  const userPromise = getUser();

  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;UserProvider userPromise={userPromise}&gt;{children}&lt;/UserProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p>Client Components use <code>use()</code> to unwrap the promise:</p>
<pre><code class="language-jsx">// app/ui/profile.jsx
'use client';

import { use, useContext } from 'react';
import { UserContext } from '@/app/providers/user-provider';

export default function Profile() {
  const userPromise = useContext(UserContext);
  if (!userPromise) {
    throw new Error('Profile must be used within UserProvider');
  }
  const user = use(userPromise);

  return &lt;p&gt;Welcome, {user.name}&lt;/p&gt;;
}
</code></pre>
<p>Wrap the component in <code>Suspense</code> for loading states:</p>
<pre><code class="language-jsx">// app/dashboard/page.jsx
import { Suspense } from 'react';
import Profile from '@/app/ui/profile';

export default function DashboardPage() {
  return (
    &lt;Suspense fallback={&lt;div&gt;Loading profile...&lt;/div&gt;}&gt;
      &lt;Profile /&gt;
    &lt;/Suspense&gt;
  );
}
</code></pre>
<p>Server Components can call <code>getUser()</code> directly. Because it's wrapped in <code>React.cache</code>, multiple calls in the same request return the same result:</p>
<pre><code class="language-jsx">// app/settings/page.jsx
import { getUser } from '@/lib/user';

export default async function SettingsPage() {
  const user = await getUser();
  return &lt;h1&gt;Settings for {user.name}&lt;/h1&gt;;
}
</code></pre>
<p><code>React.cache</code> is scoped per request. Each request has its own memoization – there's no sharing across requests.</p>
<h2 id="heading-how-to-use-third-party-components-in-both-environments">How to Use Third-Party Components in Both Environments</h2>
<p>Some third-party components use <code>useState</code>, <code>useEffect</code>, or browser APIs but don't have <code>"use client"</code> in their source. If you use them in a Server Component, you'll get an error.</p>
<p>Wrap them in your own Client Component:</p>
<pre><code class="language-jsx">// app/ui/carousel-wrapper.jsx
'use client';

import { Carousel } from 'acme-carousel';

export default Carousel;
</code></pre>
<p>Now you can use it in a Server Component:</p>
<pre><code class="language-jsx">// app/gallery/page.jsx
import Carousel from '@/app/ui/carousel-wrapper';

export default function GalleryPage() {
  return (
    &lt;div&gt;
      &lt;h1&gt;Gallery&lt;/h1&gt;
      &lt;Carousel /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>If the third-party component is already used inside a Client Component, you don't need a wrapper. The parent's <code>"use client"</code> boundary is enough:</p>
<pre><code class="language-jsx">'use client';

import { Carousel } from 'acme-carousel';

export default function Gallery() {
  return &lt;Carousel /&gt;;
}
</code></pre>
<h2 id="heading-how-to-prevent-environment-poisoning-with-server-only-and-client-only">How to Prevent Environment Poisoning with server-only and client-only</h2>
<p>It's easy to accidentally import server-only code (database clients, API keys) into a Client Component. To catch this at build time, use the <code>server-only</code> package.</p>
<p>Install it:</p>
<pre><code class="language-bash">npm install server-only
</code></pre>
<p>Add it at the top of files that must never run on the client:</p>
<pre><code class="language-js">// lib/data.js
import 'server-only';

export async function getSecretData() {
  const res = await fetch('https://api.example.com/data', {
    headers: {
      authorization: process.env.API_KEY,
    },
  });
  return res.json();
}
</code></pre>
<p>If you import <code>getSecretData</code> (or any symbol from this file) into a Client Component, the build will fail with a clear error.</p>
<p>For client-only code (for example, code that uses <code>window</code>), use <code>client-only</code>:</p>
<pre><code class="language-bash">npm install client-only
</code></pre>
<pre><code class="language-typescript">// lib/analytics.js
import 'client-only';

export function trackEvent(name) {
  if (typeof window !== 'undefined') {
    window.analytics?.track(name);
  }
}
</code></pre>
<p>Importing this into a Server Component will cause a build error.</p>
<h2 id="heading-real-world-examples">Real-World Examples</h2>
<h3 id="heading-example-1-layout-with-shared-server-and-client-pieces">Example 1: Layout with Shared Server and Client Pieces</h3>
<p>Keep the layout as a Server Component. Only the interactive parts are Client Components:</p>
<pre><code class="language-jsx">// app/layout.jsx (Server Component)
import Logo from '@/app/ui/logo';
import Search from '@/app/ui/search';

export default function Layout({ children }) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;nav&gt;
          &lt;Logo /&gt;
          &lt;Search /&gt;
        &lt;/nav&gt;
        &lt;main&gt;{children}&lt;/main&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/ui/logo.jsx (Server Component - no directive)
export default function Logo() {
  return &lt;img src="/logo.svg" alt="Logo" /&gt;;
}
</code></pre>
<pre><code class="language-jsx">// app/ui/search.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function Search() {
  const [query, setQuery] = useState('');

  return (
    &lt;input
      type="search"
      value={query}
      onChange={(e) =&gt; setQuery(e.target.value)}
    /&gt;
  );
}
</code></pre>
<p><code>Logo</code> stays on the server. <code>Search</code> is interactive and runs on the client. The layout composes both.</p>
<h3 id="heading-example-2-product-page-with-server-data-and-client-add-to-cart">Example 2: Product Page with Server Data and Client Add-to-Cart</h3>
<pre><code class="language-jsx">// app/product/[id]/page.jsx (Server Component)
import { getProduct } from '@/lib/products';
import AddToCartButton from '@/app/ui/add-to-cart-button';

export default async function ProductPage({ params }) {
  const { id } = await params;
  const product = await getProduct(id);

  return (
    &lt;div&gt;
      &lt;h1&gt;{product.name}&lt;/h1&gt;
      &lt;p&gt;{product.description}&lt;/p&gt;
      &lt;p&gt;${product.price}&lt;/p&gt;
      &lt;AddToCartButton productId={product.id} price={product.price} /&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/ui/add-to-cart-button.jsx (Client Component)
'use client';

import { useState } from 'react';

export default function AddToCartButton({ productId, price }) {
  const [added, setAdded] = useState(false);

  const handleClick = () =&gt; {
    // Call Server Action or API
    setAdded(true);
  };

  return (
    &lt;button onClick={handleClick} disabled={added}&gt;
      {added ? 'Added!' : `Add to Cart - $${price}`}
    &lt;/button&gt;
  );
}
</code></pre>
<h3 id="heading-example-3-theme-provider-wrapping-server-content">Example 3: Theme Provider Wrapping Server Content</h3>
<pre><code class="language-jsx">// app/providers/theme-provider.jsx (Client Component)
'use client';

import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext('light');

export function useTheme() {
  return useContext(ThemeContext);
}

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    &lt;ThemeContext.Provider value={{ theme, setTheme }}&gt;
      {children}
    &lt;/ThemeContext.Provider&gt;
  );
}
</code></pre>
<pre><code class="language-jsx">// app/layout.jsx (Server Component)
import ThemeProvider from '@/app/providers/theme-provider';

export default function RootLayout({ children }) {
  return (
    &lt;html&gt;
      &lt;body&gt;
        &lt;ThemeProvider&gt;{children}&lt;/ThemeProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p><code>ThemeProvider</code> is a Client Component. It wraps <code>children</code>, which can be Server Components. Render providers as deep as possible in the tree so Next.js can optimize static Server Components.</p>
<h3 id="heading-example-4-shared-utility-without-directives">Example 4: Shared Utility Without Directives</h3>
<p>Pure utilities (no hooks, no browser APIs) can be shared. They run in the environment of the component that imports them:</p>
<pre><code class="language-js">// lib/format.js (shared - no directive)
export function formatPrice(amount) {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency: 'USD',
  }).format(amount);
}
</code></pre>
<pre><code class="language-jsx">// Server Component
import { formatPrice } from '@/lib/format';

export default async function ProductPage() {
  const product = await getProduct();
  return &lt;p&gt;{formatPrice(product.price)}&lt;/p&gt;;
}
</code></pre>
<pre><code class="language-jsx">// Client Component
'use client';

import { formatPrice } from '@/lib/format';

export default function PriceDisplay({ amount }) {
  return &lt;span&gt;{formatPrice(amount)}&lt;/span&gt;;
}
</code></pre>
<p><code>formatPrice</code> is a pure function. It works in both Server and Client Components.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Sharing components and data between Server and Client Components in Next.js comes down to a few patterns:</p>
<ul>
<li><p><strong>Pass data as props</strong> – Server Components fetch data and pass serializable values to Client Components.</p>
</li>
<li><p><strong>Pass Server Components as children</strong> – Client Components can wrap server-rendered content via <code>children</code> or slot props.</p>
</li>
<li><p><strong>Use serializable props only</strong> – Stick to primitives, plain objects, arrays, and Server Actions. Convert <code>Date</code> and <code>ObjectId</code> to strings.</p>
</li>
<li><p><strong>Share data with Context + React.cache</strong> – Use a Client Component provider that receives a promise and <code>React.cache</code> for server-side deduplication.</p>
</li>
<li><p><strong>Wrap third-party components</strong> – Add <code>"use client"</code> wrappers for libraries that use client-only features.</p>
</li>
<li><p><strong>Use server-only and client-only</strong> – Prevent accidental imports across the server/client boundary.</p>
</li>
</ul>
<p>Keep Server Components at the top of the tree and push Client Components down to leaf nodes. This reduces JavaScript sent to the browser while keeping interactivity where you need it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Build a Loom Clone with Next.js 15 and Mux ]]>
                </title>
                <description>
                    <![CDATA[ We just posted a course on the freeCodeCamp.org YouTube channel that will teach you how to build a professional screen recording platform from scratch. Using Next.js 15 and Mux, you will create a "Loo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/loom-clone-next-javascript-mux/</link>
                <guid isPermaLink="false">69a5efa7e8e1f9df72e3a2ee</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ youtube ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Beau Carnes ]]>
                </dc:creator>
                <pubDate>Mon, 02 Mar 2026 20:14:31 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5f68e7df6dfc523d0a894e7c/eb0cef28-ed95-49f1-97d0-c66911848aa2.jpg" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>We just posted a course on the <a href="http://freeCodeCamp.org"><strong>freeCodeCamp.org</strong></a> YouTube channel that will teach you how to build a professional screen recording platform from scratch. Using Next.js 15 and Mux, you will create a "Loom Clone" that handles everything from recording to AI-generated summaries. I created this course.</p>
<p>The main technologies used are Next.js and Mux. This project will help you understand how professional video sites actually work. The project features:</p>
<ul>
<li><p><strong>Browser-Based Recording:</strong> Capture your screen and microphone directly in the browser using standard media APIs.</p>
</li>
<li><p><strong>Smart Uploads:</strong> Use "Direct Upload" to send videos straight to the cloud, which saves server bandwidth and memory.</p>
</li>
<li><p><strong>AI Integration:</strong> Automatically transcribe audio via OpenAI’s Whisper model and generate video titles and tags using Mux AI.</p>
</li>
<li><p><strong>Modern Video Tech:</strong> Learn how HLS and Adaptive Bitrate Streaming provide smooth playback by switching quality levels based on internet speed.</p>
</li>
<li><p><strong>Professional Features:</strong> Add custom watermarks to your videos and create a dashboard with animated thumbnails.</p>
</li>
</ul>
<p>Watch the full course on the <a href="http://freeCodeCamp.org">freeCodeCamp.org</a> YouTube channel (1-hour watch).</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/IBTx5aGj-6U?si=jFSY2kFANE1rkIie" frameborder="0" allowfullscreen="" title="Embedded content" loading="lazy"></iframe></div> ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Prevent IDOR Vulnerabilities in Next.js API Routes ]]>
                </title>
                <description>
                    <![CDATA[ Imagine this situation: A user logs in successfully to your application, but upon loading their dashboard, they see someone else’s data. Why does this happen? The authentication worked, the session is ]]>
                </description>
                <link>https://www.freecodecamp.org/news/prevent-idor-in-nextjs/</link>
                <guid isPermaLink="false">69a1f073d4053a09f3430559</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authentication ]]>
                    </category>
                
                    <category>
                        <![CDATA[ authorization ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayodele Aransiola ]]>
                </dc:creator>
                <pubDate>Fri, 27 Feb 2026 19:28:51 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/b14a67ea-e78b-4ebd-996f-98da3a0a8027.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Imagine this situation: A user logs in successfully to your application, but upon loading their dashboard, they see someone else’s data.</p>
<p>Why does this happen? The authentication worked, the session is valid, the user is authenticated, but the authorization failed.</p>
<p>This specific issue is called <strong>IDOR (Insecure Direct Object Reference)</strong>. It’s one of the most common security bugs and is categorized under <strong>Broken Object Level Authorization (BOLA)</strong> in the OWASP API Security Top 10.</p>
<p>In this tutorial, you’ll learn:</p>
<ul>
<li><p>Why IDOR happens</p>
</li>
<li><p>Why authentication alone is not enough</p>
</li>
<li><p>How object-level authorization works</p>
</li>
<li><p>How to fix IDOR properly in Next.js API routes</p>
</li>
<li><p>How to design safer APIs from the start</p>
</li>
</ul>
<h2 id="heading-table-of-content">Table of Content</h2>
<ul>
<li><p><a href="#heading-table-of-content">Table of Content</a></p>
</li>
<li><p><a href="#heading-authentication-vs-authorization">Authentication vs. Authorization</a></p>
</li>
<li><p><a href="#heading-what-is-an-idor-vulnerability">What is an IDOR Vulnerability?</a></p>
</li>
<li><p><a href="#heading-the-vulnerable-pattern-in-nextjs">The Vulnerable Pattern in Next.js</a></p>
</li>
<li><p><a href="#heading-how-to-handle-idor-in-nextjs">How to Handle IDOR in Next.js</a></p>
<ul>
<li><a href="#heading-object-level-authorization">Object-Level Authorization</a></li>
</ul>
</li>
<li><p><a href="#heading-how-to-design-safer-endpoints-apime">How to Design Safer Endpoints (/api/me)</a></p>
</li>
<li><p><a href="#heading-mental-model-for-api-design">Mental Model for API Design</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-authentication-vs-authorization">Authentication vs. Authorization</h2>
<p>Before writing further, let’s clarify something critical.</p>
<ul>
<li><p><strong>Authentication answers:</strong> Who are you?</p>
</li>
<li><p><strong>Authorization answers:</strong> What are you allowed to access?</p>
</li>
</ul>
<p>In IDOR scenarios, authentication works (the user is logged in), while authorization is missing or incomplete. That distinction is the core lesson of this article.</p>
<h2 id="heading-what-is-an-idor-vulnerability">What is an IDOR Vulnerability?</h2>
<p>An IDOR vulnerability happens when your API fetches a resource by an identifier (like a user ID), and then you do not verify that the requester owns or is allowed to access that resource.</p>
<p>Example of such a request:</p>
<pre><code class="language-plaintext">GET /api/users/123
</code></pre>
<p>The code above is an HTTP <strong>GET</strong> request to the <code>/api/users/123</code> route. The <code>GET</code> method is used to request data from the server. This indicates that the client is requesting a specific user with the ID <code>123</code> and this request returns the user data in a response (often in JSON format).</p>
<p>If your backend makes the request using a similar structure to the code snippet below without checking who is making the request, you have an IDOR vulnerability, even if the user is logged in.</p>
<pre><code class="language-tsx">db.user.findUnique({ where: { id: "123" } })
</code></pre>
<p>What the code does is to query the database for a single user record. The <code>db.user</code> part refers to the <code>user</code> model/table and <code>findUnique()</code> is a method that returns only one record based on a unique field. Inside the method, the <code>where</code> clause specifies the filter condition and <code>{ id: "123" }</code> tells the database to find the user whose unique <code>id</code> equals <code>"123"</code>. If a matching record exists, it returns that user object; otherwise, it returns <code>null</code>.</p>
<h2 id="heading-the-vulnerable-pattern-in-nextjs">The Vulnerable Pattern in Next.js</h2>
<p>Looking at this Next.js App Router API route:</p>
<pre><code class="language-tsx">// app/api/users/[id]/route.ts
import { NextResponse } from "next/server";
import { db } from "@/lib/db";

export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}
</code></pre>
<p>Before going to the implication of this code snippet, let's understand what the code does. It defines a dynamic API route for <code>/api/users/[id]</code>. The exported <code>GET</code> function is an async route handler that runs when a GET request is made to this endpoint. It receives the request object and a <code>params</code> object, where <code>params.id</code> contains the dynamic <code>[id]</code> in the URL segment. The <code>db.user.findUnique()</code> method queries the database for a user whose <code>id</code> matches <code>params.id</code>, and the <code>select</code> option limits the returned fields to <code>id</code>, <code>email</code>, and <code>name</code>. Finally, <code>NextResponse.json()</code> sends the retrieved user data back to the client as a JSON response.</p>
<p>Now, to the implication, the code is a bad approach because the route accepts a user ID from the URL, fetches that user directly from the database, and returns the result. There is no session validation, no ownership check, and no role check.</p>
<p>If a logged-in user changes the <code>id</code> in the URL, they may access other users’ data. This is simply IDOR.</p>
<h2 id="heading-how-to-handle-idor-in-nextjs">How to Handle IDOR in Next.js</h2>
<p>The first element of defense is verifying identity. We’ll use <code>getServerSession</code> from NextAuth (adjust if using another auth provider). This change ensures that you read the session from the cookies, verify it on the server side, and ensure the user has a valid ID. This prevents unauthenticated access.</p>
<pre><code class="language-tsx">// lib/auth.ts
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/authOptions";

export async function requireSession() {
  const session = await getServerSession(authOptions);

  if (!session?.user?.id) {
    return null;
  }

  return session;
}
</code></pre>
<p>The code above defines an authentication helper function called <code>requireSession</code>. The <code>getServerSession(authOptions)</code> function retrieves the current user session on the server using the provided authentication configuration. The optional chaining (<code>session?.user?.id</code>) in the <code>if</code> block that follows safely checks whether a logged-in user and their <code>id</code> exist. If no valid session or user ID is found, the function returns <code>null</code>, indicating the request is unauthenticated. Otherwise, it returns the full <code>session</code> object so it can be used in protected routes or server logic.</p>
<p>You have successfully confirmed that the user and session exist; now, update the route:</p>
<pre><code class="language-tsx">export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}
</code></pre>
<p>The fix is incomplete yet, but in the above code, you’ve prevented anonymous access. The <code>GET</code> handler calls the <code>requireSession()</code> that was created earlier to verify that the request is authenticated. If no valid session is returned, it immediately responds with a JSON error message and a <code>401 Unauthorized</code> HTTP status. If the user is authenticated, it proceeds to call <code>db.user.findUnique()</code> to fetch the user whose <code>id</code> matches <code>params.id</code>, selecting only the <code>id</code>, <code>email</code>, and <code>name</code> fields. Finally, it returns the retrieved user data as a JSON response using <code>NextResponse.json()</code>.</p>
<p>Something is still missing. Can you guess? Any authenticated user can still request any resource by changing the URL path to the request they want. How? This leads us to object-level authorization.</p>
<h3 id="heading-object-level-authorization">Object-Level Authorization</h3>
<p>An object-level authorization ensures that a user can only access their own data (unless explicitly permitted).</p>
<p>The improvement to the code would be to add an ownership check. The adjustment ensures the API request checks if the requester is authenticated and owns the requested object. If either fails, access is denied.</p>
<pre><code class="language-tsx">export async function GET(
  req: Request,
  { params }: { params: { id: string } }
) {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  if (session.user.id !== params.id) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }

  const user = await db.user.findUnique({
    where: { id: params.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}
</code></pre>
<p>Let's take a look at what happened in the code, the <code>GET</code> handler first authenticates the request using <code>requireSession()</code>, returning a <code>401</code> response if no valid session exists. It then performs an authorization check by comparing <code>session.user.id</code> with <code>params.id</code>. If they do not match, it returns a <code>403 Forbidden</code> response, preventing users from accessing other users’ data. If both checks pass, it queries the database using <code>db.user.findUnique()</code> to retrieve the specified user and limits the result to selected fields. Finally, it sends the user data back as a JSON response. With this, you’ve enforced an <strong>object-level authorization</strong>.</p>
<h2 id="heading-how-to-design-safer-endpoints-apime">How to Design Safer Endpoints (<code>/api/me</code>)</h2>
<p>The safest approach in designing your endpoint is to eliminate the risk entirely. Instead of allowing users to specify IDs (<code>/api/users/:id</code>), use <code>/api/me</code>, because the server already knows the user’s identity from the session.</p>
<pre><code class="language-tsx">// app/api/me/route.ts
export async function GET() {
  const session = await requireSession();

  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const user = await db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, email: true, name: true },
  });

  return NextResponse.json({ user });
}
</code></pre>
<p>This approach makes sure that your API only returns data for the currently authenticated user. It first calls <code>requireSession()</code> to ensure the request is authenticated, returning a <code>401</code> response if no session exists. Instead of using a URL parameter, it reads the user’s ID directly from <code>session.user.id</code>, ensuring the user can only access their own data. It then calls <code>db.user.findUnique()</code> to retrieve that user from the database, selecting only specific fields, and returns the result as a JSON response.</p>
<p>You can be confident with this approach because the client cannot manipulate user IDs. The server gets the user identity from a trusted source, and the attack surface is reduced. This is called <code>secure-by-design</code> <strong>API model</strong>.</p>
<p>Now, you should clearly understand that authentication does not imply authorization. Hence,</p>
<ul>
<li><p>IDOR occurs when object ownership is not verified</p>
</li>
<li><p>Every API route that accepts an ID must validate access</p>
</li>
<li><p>Safer API design reduces vulnerability surface</p>
</li>
<li><p>Authorization must always run on the server</p>
</li>
</ul>
<h2 id="heading-mental-model-for-api-design">Mental Model for API Design</h2>
<p>When writing any API route, answer these questions:</p>
<ol>
<li><p>Who is making this request?</p>
</li>
<li><p>What object are they requesting?</p>
</li>
<li><p>Does policy allow them to access it?</p>
</li>
</ol>
<p>If you cannot clearly answer all three, your route may be vulnerable.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>IDOR vulnerabilities happen when APIs trust user-supplied identifiers without verifying ownership or permission.</p>
<p>To prevent them in Next.js, authenticate every private route, enforce object-level authorization, centralize authorization logic, and write tests for forbidden access.</p>
<p>Security is not about adding logins, it’s about enforcing security policy on every object access.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Feature Flag System with Next.js and Supabase ]]>
                </title>
                <description>
                    <![CDATA[ Feature flags are powerful tools that let you control which features are visible to users without deploying new code. They enable gradual rollouts, A/B testing, and instant feature toggles, which are all essential for modern software development. In ... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-feature-flag-system-with-nextjs-and-supabase/</link>
                <guid isPermaLink="false">69851c4ec8140c13f9fa09c8</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase ]]>
                    </category>
                
                    <category>
                        <![CDATA[   feature flags ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Aniebo ]]>
                </dc:creator>
                <pubDate>Thu, 05 Feb 2026 22:40:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770312675718/c462d3b5-5369-45e0-ad47-c91b441fe96f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Feature flags are powerful tools that let you control which features are visible to users without deploying new code. They enable gradual rollouts, A/B testing, and instant feature toggles, which are all essential for modern software development.</p>
<p>In this article, we’ll build a real, production-ready feature flag system, not just a simple boolean toggle.</p>
<p>Specifically, we’ll implement:</p>
<ul>
<li><p>A global on/off flag to enable or disable features instantly</p>
</li>
<li><p>User-specific flags to grant access to individual users (for beta testing or internal users)</p>
</li>
<li><p>Percentage-based rollouts to gradually expose features to a subset of users</p>
</li>
<li><p>A React-powered admin dashboard to manage flags without redeploying</p>
</li>
<li><p>Client-side and server-side enforcement, so features are gated consistently everywhere</p>
</li>
</ul>
<p>By the end, we’ll finish by wiring a real Todo feature behind a feature flag, showing how entire pages and components can be safely toggled on and off in production.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-are-feature-flags">What Are Feature Flags?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-setup">Project Setup</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-database-schema-design">Database Schema Design</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-supabase">Setting Up Supabase</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-building-the-core-feature-flag-logic">Building the Core Feature Flag Logic</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-setting-up-react-query">Setting Up React Query</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-creating-the-react-hook">Creating the React Hook</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-building-the-admin-dashboard">Building the Admin Dashboard</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-implementing-a-real-world-example">Implementing a Real-World Example</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-server-side-usage">Server-Side Usage</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-why-react-query">Why React Query?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you begin, make sure you have:</p>
<ul>
<li><p>Node.js 18 or higher installed</p>
</li>
<li><p>A basic understanding of React and Next.js</p>
</li>
<li><p>Familiarity with TypeScript</p>
</li>
<li><p>A Supabase account (free tier works perfectly)</p>
</li>
<li><p>A code editor like VS Code</p>
</li>
<li><p>Basic understanding of React Query (TanStack Query) for server state management</p>
</li>
</ul>
<h2 id="heading-what-are-feature-flags">What Are Feature Flags?</h2>
<p>Feature flags (also called feature toggles) are configuration mechanisms that let you enable or disable features in your application without changing code. Think of them as light switches for your features.</p>
<p>Here are some common use cases:</p>
<ul>
<li><p><strong>Gradual rollouts</strong>: Release a feature to 10% of users first, then gradually increase</p>
</li>
<li><p><strong>User-specific access</strong>: Enable features for beta testers or VIP users</p>
</li>
<li><p><strong>Emergency kill switches</strong>: Instantly disable a feature if something goes wrong</p>
</li>
<li><p><strong>A/B testing</strong>: Test different versions of features with different user groups</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<p>Start by creating a new Next.js project with TypeScript:</p>
<pre><code class="lang-bash">npx create-next-app@latest supabase-feature-flag --typescript --tailwind --app
<span class="hljs-built_in">cd</span> supabase-feature-flag
</code></pre>
<p>Next, install the required dependencies:</p>
<pre><code class="lang-bash">npm install @supabase/ssr @supabase/supabase-js @tanstack/react-query
</code></pre>
<p>The <code>@supabase/ssr</code> package provides server-side rendering support for Supabase, which is essential for Next.js App Router. <code>@tanstack/react-query</code> provides powerful server state management with automatic caching, invalidation, and real-time updates, which is perfect for feature flags that need to reflect changes immediately without page refreshes.</p>
<h2 id="heading-database-schema-design">Database Schema Design</h2>
<p>Before writing any code, you need to design your database schema. A feature flag needs several properties:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769945095969/86610ba1-e0c8-4c0c-a500-8cdc061555ba.webp" alt="Database-schema-design" class="image--center mx-auto" width="2300" height="1246" loading="lazy"></p>
<ul>
<li><p>A unique key to identify the flag</p>
</li>
<li><p>A name and description for human readability</p>
</li>
<li><p>An enabled/disabled state</p>
</li>
<li><p>Support for user-specific access</p>
</li>
<li><p>Support for percentage-based rollouts</p>
</li>
</ul>
<p>Here's the SQL migration that creates the <code>feature_flags</code> table:</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Create feature_flags table</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> feature_flags (
  <span class="hljs-keyword">id</span> <span class="hljs-keyword">UUID</span> PRIMARY <span class="hljs-keyword">KEY</span> <span class="hljs-keyword">DEFAULT</span> gen_random_uuid(),
  <span class="hljs-keyword">key</span> <span class="hljs-built_in">TEXT</span> <span class="hljs-keyword">UNIQUE</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  <span class="hljs-keyword">name</span> <span class="hljs-built_in">TEXT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  description <span class="hljs-built_in">TEXT</span>,
  enabled <span class="hljs-built_in">BOOLEAN</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-literal">false</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  enabled_for_users JSONB <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">'[]'</span>::jsonb,
  enabled_for_percent <span class="hljs-built_in">INTEGER</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-number">0</span> <span class="hljs-keyword">CHECK</span> (enabled_for_percent &gt;= <span class="hljs-number">0</span> <span class="hljs-keyword">AND</span> enabled_for_percent &lt;= <span class="hljs-number">100</span>),
  metadata JSONB <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">'{}'</span>::jsonb,
  created_at <span class="hljs-built_in">TIMESTAMP</span> <span class="hljs-keyword">WITH</span> <span class="hljs-built_in">TIME</span> ZONE <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NOW</span>() <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  updated_at <span class="hljs-built_in">TIMESTAMP</span> <span class="hljs-keyword">WITH</span> <span class="hljs-built_in">TIME</span> ZONE <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NOW</span>() <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>
);
</code></pre>
<p>Let's break down each field:</p>
<ul>
<li><p><code>id</code>: A unique identifier for each flag</p>
</li>
<li><p><code>key</code>: A unique string identifier (like "new-dashboard" or "beta-feature")</p>
</li>
<li><p><code>name</code>: A human-readable name</p>
</li>
<li><p><code>description</code>: Optional description of what the flag controls</p>
</li>
<li><p><code>enabled</code>: Global on/off switch</p>
</li>
<li><p><code>enabled_for_users</code>: JSON array of user IDs who have access</p>
</li>
<li><p><code>enabled_for_percent</code>: Percentage of users who should see the feature (0-100)</p>
</li>
<li><p><code>metadata</code>: Flexible JSON field for additional configuration</p>
</li>
<li><p><code>created_at</code> and <code>updated_at</code>: Timestamps for tracking</p>
</li>
</ul>
<p>The migration also includes indexes for performance:</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Create indexes for fast lookups</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> idx_feature_flags_key <span class="hljs-keyword">ON</span> feature_flags(<span class="hljs-keyword">key</span>);
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> idx_feature_flags_enabled <span class="hljs-keyword">ON</span> feature_flags(enabled);
</code></pre>
<p>Indexes on <code>key</code> and <code>enabled</code> ensure fast queries when checking flag status.</p>
<h2 id="heading-setting-up-supabase">Setting Up Supabase</h2>
<h3 id="heading-step-1-create-a-supabase-project">Step 1: Create a Supabase Project</h3>
<p>To start, go to <a target="_blank" href="http://supabase.com">supabase.com</a> and sign up or log in. Then click on "New Project". Fill in your project details and wait for it to initialize.</p>
<h3 id="heading-step-2-run-the-migration">Step 2: Run the Migration</h3>
<p>In your Supabase dashboard, navigate to the SQL Editor:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769945875099/72595159-3301-422e-a648-49602c4088ec.png" alt="Supabase-row-level-security(RLS)-policies" class="image--center mx-auto" width="3020" height="1650" loading="lazy"></p>
<p>Then click "New Query". Copy and paste the complete migration SQL (including the indexes and RLS policies shown below) and click "Run".</p>
<p>Here's the complete migration with Row Level Security (RLS) policies:</p>
<pre><code class="lang-sql"><span class="hljs-comment">-- Create feature_flags table</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> feature_flags (
  <span class="hljs-keyword">id</span> <span class="hljs-keyword">UUID</span> PRIMARY <span class="hljs-keyword">KEY</span> <span class="hljs-keyword">DEFAULT</span> gen_random_uuid(),
  <span class="hljs-keyword">key</span> <span class="hljs-built_in">TEXT</span> <span class="hljs-keyword">UNIQUE</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  <span class="hljs-keyword">name</span> <span class="hljs-built_in">TEXT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  description <span class="hljs-built_in">TEXT</span>,
  enabled <span class="hljs-built_in">BOOLEAN</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-literal">false</span> <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  enabled_for_users JSONB <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">'[]'</span>::jsonb,
  enabled_for_percent <span class="hljs-built_in">INTEGER</span> <span class="hljs-keyword">DEFAULT</span> <span class="hljs-number">0</span> <span class="hljs-keyword">CHECK</span> (enabled_for_percent &gt;= <span class="hljs-number">0</span> <span class="hljs-keyword">AND</span> enabled_for_percent &lt;= <span class="hljs-number">100</span>),
  metadata JSONB <span class="hljs-keyword">DEFAULT</span> <span class="hljs-string">'{}'</span>::jsonb,
  created_at <span class="hljs-built_in">TIMESTAMP</span> <span class="hljs-keyword">WITH</span> <span class="hljs-built_in">TIME</span> ZONE <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NOW</span>() <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>,
  updated_at <span class="hljs-built_in">TIMESTAMP</span> <span class="hljs-keyword">WITH</span> <span class="hljs-built_in">TIME</span> ZONE <span class="hljs-keyword">DEFAULT</span> <span class="hljs-keyword">NOW</span>() <span class="hljs-keyword">NOT</span> <span class="hljs-literal">NULL</span>
);

<span class="hljs-comment">-- Create indexes</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> idx_feature_flags_key <span class="hljs-keyword">ON</span> feature_flags(<span class="hljs-keyword">key</span>);
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> idx_feature_flags_enabled <span class="hljs-keyword">ON</span> feature_flags(enabled);

<span class="hljs-comment">-- Auto-update updated_at timestamp</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">OR</span> <span class="hljs-keyword">REPLACE</span> <span class="hljs-keyword">FUNCTION</span> update_updated_at_column()
<span class="hljs-keyword">RETURNS</span> <span class="hljs-keyword">TRIGGER</span> <span class="hljs-keyword">AS</span> $$
<span class="hljs-keyword">BEGIN</span>
  NEW.updated_at = <span class="hljs-keyword">NOW</span>();
  RETURN NEW;
<span class="hljs-keyword">END</span>;
$$ language 'plpgsql';

<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TRIGGER</span> update_feature_flags_updated_at
  <span class="hljs-keyword">BEFORE</span> <span class="hljs-keyword">UPDATE</span> <span class="hljs-keyword">ON</span> feature_flags
  <span class="hljs-keyword">FOR</span> <span class="hljs-keyword">EACH</span> <span class="hljs-keyword">ROW</span>
  <span class="hljs-keyword">EXECUTE</span> <span class="hljs-keyword">FUNCTION</span> update_updated_at_column();

<span class="hljs-comment">-- Enable Row Level Security</span>
<span class="hljs-keyword">ALTER</span> <span class="hljs-keyword">TABLE</span> feature_flags <span class="hljs-keyword">ENABLE</span> <span class="hljs-keyword">ROW</span> <span class="hljs-keyword">LEVEL</span> <span class="hljs-keyword">SECURITY</span>;

<span class="hljs-comment">-- Policy: Allow public read access</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">POLICY</span> <span class="hljs-string">"Allow public read access"</span>
  <span class="hljs-keyword">ON</span> feature_flags
  <span class="hljs-keyword">FOR</span> <span class="hljs-keyword">SELECT</span>
  <span class="hljs-keyword">USING</span> (<span class="hljs-literal">true</span>);

<span class="hljs-comment">-- Policy: Allow public write access (for admin operations)</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">POLICY</span> <span class="hljs-string">"Allow public write access"</span>
  <span class="hljs-keyword">ON</span> feature_flags
  <span class="hljs-keyword">FOR</span> <span class="hljs-keyword">ALL</span>
  <span class="hljs-keyword">USING</span> (<span class="hljs-literal">true</span>)
  <span class="hljs-keyword">WITH</span> <span class="hljs-keyword">CHECK</span> (<span class="hljs-literal">true</span>);
</code></pre>
<p>The RLS policies allow:</p>
<ul>
<li><p>Public read access: Anyone can check if a feature flag is enabled</p>
</li>
<li><p>Public write access: Allows the admin dashboard to create/update flags (in production, you'd restrict this further)</p>
</li>
</ul>
<h3 id="heading-step-3-get-your-api-credentials">Step 3: Get Your API Credentials</h3>
<p>Go to Settings and then API in your Supabase project. Copy your Project URL and Publishable Key. THen create a <code>.env.local</code> file in your project root:</p>
<pre><code class="lang-bash">NEXT_PUBLIC_SUPABASE_URL=your_project_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your_publishable_key
</code></pre>
<h2 id="heading-building-the-core-feature-flag-logic">Building the Core Feature Flag Logic</h2>
<p>Now let's build the core logic for checking feature flags. You'll create separate utilities for client-side and server-side usage.</p>
<h3 id="heading-typescript-types">TypeScript Types</h3>
<p>First, define the types you'll use throughout the application:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// types/feature-flag.ts</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> FeatureFlag {
  id: <span class="hljs-built_in">string</span>;
  key: <span class="hljs-built_in">string</span>;
  name: <span class="hljs-built_in">string</span>;
  description: <span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>;
  enabled: <span class="hljs-built_in">boolean</span>;
  enabled_for_users: <span class="hljs-built_in">string</span>[];
  enabled_for_percent: <span class="hljs-built_in">number</span>;
  metadata: Record&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt;;
  created_at: <span class="hljs-built_in">string</span>;
  updated_at: <span class="hljs-built_in">string</span>;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> FeatureFlagCheckResult {
  enabled: <span class="hljs-built_in">boolean</span>;
  reason?: <span class="hljs-built_in">string</span>;
}
</code></pre>
<p>The <code>FeatureFlag</code> interface matches your database schema. The <code>FeatureFlagCheckResult</code> includes a <code>reason</code> field that explains why a flag is enabled or disabled, which is useful for debugging.</p>
<h3 id="heading-supabase-client-setup">Supabase Client Setup</h3>
<p>Create the Supabase client for client-side usage:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// lib/supabase/client.ts</span>
<span class="hljs-keyword">import</span> { createBrowserClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"@supabase/ssr"</span>;

<span class="hljs-keyword">const</span> supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
<span class="hljs-keyword">const</span> supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> createClient = <span class="hljs-function">() =&gt;</span>
  createBrowserClient(supabaseUrl!, supabaseKey!);
</code></pre>
<p>The <code>createBrowserClient</code> function from <code>@supabase/ssr</code> creates a client optimized for browser usage.</p>
<p>For server-side usage:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// lib/supabase/server.ts</span>
<span class="hljs-keyword">import</span> { createServerClient, <span class="hljs-keyword">type</span> CookieOptions } <span class="hljs-keyword">from</span> <span class="hljs-string">"@supabase/ssr"</span>;
<span class="hljs-keyword">import</span> { cookies } <span class="hljs-keyword">from</span> <span class="hljs-string">"next/headers"</span>;

<span class="hljs-keyword">const</span> supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
<span class="hljs-keyword">const</span> supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> createClient = <span class="hljs-function">(<span class="hljs-params">cookieStore: ReturnType&lt;<span class="hljs-keyword">typeof</span> cookies&gt;</span>) =&gt;</span> {
  <span class="hljs-keyword">return</span> createServerClient(
    supabaseUrl!,
    supabaseKey!,
    {
      cookies: {
        getAll() {
          <span class="hljs-keyword">return</span> cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          <span class="hljs-keyword">try</span> {
            cookiesToSet.forEach(<span class="hljs-function">(<span class="hljs-params">{ name, value, options }</span>) =&gt;</span>
              cookieStore.set(name, value, options)
            )
          } <span class="hljs-keyword">catch</span> {
            <span class="hljs-comment">// The `setAll` method was called from a Server Component.</span>
            <span class="hljs-comment">// This can be ignored if you have middleware refreshing</span>
            <span class="hljs-comment">// user sessions.</span>
          }
        },
      },
    },
  );
};
</code></pre>
<p>This server client handles cookies properly for Next.js server components and API routes.</p>
<h3 id="heading-client-side-feature-flag-logic">Client-Side Feature Flag Logic</h3>
<p>Create the client-side utility for checking feature flags:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// lib/feature-flags/client.ts</span>
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/lib/supabase/client'</span>;
<span class="hljs-keyword">import</span> { FeatureFlag, FeatureFlagCheckResult } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/types/feature-flag'</span>;

<span class="hljs-comment">// Simple cache with 5 second TTL (React Query handles primary caching)</span>
<span class="hljs-comment">// This cache is just for reducing redundant calls within a very short window</span>
<span class="hljs-keyword">const</span> cache = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>&lt;<span class="hljs-built_in">string</span>, { data: FeatureFlag | <span class="hljs-literal">null</span>; expires: <span class="hljs-built_in">number</span> }&gt;();
<span class="hljs-keyword">const</span> CACHE_TTL = <span class="hljs-number">5000</span>; <span class="hljs-comment">// 5 seconds - short enough to not interfere with React Query invalidation</span>

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getCached</span>(<span class="hljs-params">key: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">FeatureFlag</span> | <span class="hljs-title">null</span> | <span class="hljs-title">undefined</span> </span>{
  <span class="hljs-keyword">const</span> cached = cache.get(key);
  <span class="hljs-keyword">if</span> (cached &amp;&amp; cached.expires &gt; <span class="hljs-built_in">Date</span>.now()) {
    <span class="hljs-keyword">return</span> cached.data;
  }
  <span class="hljs-keyword">return</span> <span class="hljs-literal">undefined</span>;
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">setCached</span>(<span class="hljs-params">key: <span class="hljs-built_in">string</span>, data: FeatureFlag | <span class="hljs-literal">null</span></span>): <span class="hljs-title">void</span> </span>{
  cache.set(key, { data, expires: <span class="hljs-built_in">Date</span>.now() + CACHE_TTL });
}
</code></pre>
<p>The cache reduces database queries by storing flag data in memory for 5 seconds. This is a secondary cache layer – React Query handles the primary caching and automatic invalidation, ensuring changes reflect immediately across all components.</p>
<p>The <code>getFeatureFlag</code> function fetches a flag from the database:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getFeatureFlag</span>(<span class="hljs-params">key: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">FeatureFlag</span> | <span class="hljs-title">null</span>&gt; </span>{
  <span class="hljs-keyword">const</span> cached = getCached(key);
  <span class="hljs-keyword">if</span> (cached !== <span class="hljs-literal">undefined</span>) <span class="hljs-keyword">return</span> cached;

  <span class="hljs-keyword">const</span> supabase = createClient();
  <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase
    .from(<span class="hljs-string">'feature_flags'</span>)
    .select(<span class="hljs-string">'*'</span>)
    .eq(<span class="hljs-string">'key'</span>, key)
    .single();

  <span class="hljs-keyword">if</span> (error) {
    <span class="hljs-keyword">if</span> (error.code === <span class="hljs-string">'PGRST116'</span>) {
      setCached(key, <span class="hljs-literal">null</span>);
      <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
    }
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error fetching feature flag:'</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }

  setCached(key, data);
  <span class="hljs-keyword">return</span> data;
}
</code></pre>
<p>The function first checks the cache. If the flag isn't cached, it queries Supabase. The error code <code>PGRST116</code> means "not found." In that case, you cache <code>null</code> to avoid repeated queries for non-existent flags.</p>
<p>The core logic is in <code>isFeatureEnabled</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">isFeatureEnabled</span>(<span class="hljs-params">
  key: <span class="hljs-built_in">string</span>,
  userId?: <span class="hljs-built_in">string</span>
</span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">FeatureFlagCheckResult</span>&gt; </span>{
  <span class="hljs-keyword">const</span> flag = <span class="hljs-keyword">await</span> getFeatureFlag(key);

  <span class="hljs-keyword">if</span> (!flag) {
    <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'Flag not found'</span> };
  }

  <span class="hljs-keyword">if</span> (!flag.enabled) {
    <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'Flag is globally disabled'</span> };
  }

  <span class="hljs-comment">// Check user-specific access</span>
  <span class="hljs-keyword">if</span> (userId &amp;&amp; flag.enabled_for_users.length &gt; <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">if</span> (flag.enabled_for_users.includes(userId)) {
      <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">true</span>, reason: <span class="hljs-string">'User has explicit access'</span> };
    }
    <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'User not in allowed list'</span> };
  }

  <span class="hljs-comment">// Check percentage rollout</span>
  <span class="hljs-keyword">if</span> (flag.enabled_for_percent &gt; <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">const</span> hash = simpleHash(userId || key);
    <span class="hljs-keyword">const</span> percentage = hash % <span class="hljs-number">100</span>;
    <span class="hljs-keyword">const</span> enabled = percentage &lt; flag.enabled_for_percent;

    <span class="hljs-keyword">return</span> {
      enabled,
      reason: enabled
        ? <span class="hljs-string">`User falls within <span class="hljs-subst">${flag.enabled_for_percent}</span>% rollout`</span>
        : <span class="hljs-string">`User falls outside <span class="hljs-subst">${flag.enabled_for_percent}</span>% rollout`</span>,
    };
  }

  <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">true</span>, reason: <span class="hljs-string">'Flag is globally enabled'</span> };
}
</code></pre>
<p>The function follows this logic:</p>
<ol>
<li><p><strong>Flag doesn't exist</strong>: Return disabled</p>
</li>
<li><p><strong>Flag is globally disabled</strong>: Return disabled</p>
</li>
<li><p><strong>User-specific list exists</strong>: Check if the user is in the list</p>
</li>
<li><p><strong>Percentage rollout is set</strong>: Use a hash function to assign users to buckets deterministically</p>
</li>
<li><p><strong>Otherwise</strong>: Flag is globally enabled</p>
</li>
</ol>
<p>The hash function ensures consistent assignment, so that the same user always gets the same result:</p>
<pre><code class="lang-typescript"><span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">simpleHash</span>(<span class="hljs-params">str: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">number</span> </span>{
  <span class="hljs-keyword">let</span> hash = <span class="hljs-number">0</span>;
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; str.length; i++) {
    <span class="hljs-keyword">const</span> char = str.charCodeAt(i);
    hash = (hash &lt;&lt; <span class="hljs-number">5</span>) - hash + char;
    hash = hash &amp; hash;
  }
  <span class="hljs-keyword">return</span> <span class="hljs-built_in">Math</span>.abs(hash);
}
</code></pre>
<p>This creates a deterministic hash, so <code>simpleHash("user-123")</code> always returns the same number, ensuring consistent feature flag decisions.</p>
<h3 id="heading-server-side-feature-flag-logic">Server-Side Feature Flag Logic</h3>
<p>The server-side version is similar but uses the server Supabase client:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// lib/feature-flags/server.ts</span>
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/lib/supabase/server'</span>;
<span class="hljs-keyword">import</span> { cookies } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/headers'</span>;
<span class="hljs-keyword">import</span> { FeatureFlag, FeatureFlagCheckResult } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/types/feature-flag'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">getFeatureFlag</span>(<span class="hljs-params">key: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">FeatureFlag</span> | <span class="hljs-title">null</span>&gt; </span>{
  <span class="hljs-keyword">const</span> cookieStore = <span class="hljs-keyword">await</span> cookies();
  <span class="hljs-keyword">const</span> supabase = createClient(cookieStore);

  <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase
    .from(<span class="hljs-string">'feature_flags'</span>)
    .select(<span class="hljs-string">'*'</span>)
    .eq(<span class="hljs-string">'key'</span>, key)
    .single();

  <span class="hljs-keyword">if</span> (error) {
    <span class="hljs-keyword">if</span> (error.code === <span class="hljs-string">'PGRST116'</span>) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
    <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error fetching feature flag:'</span>, error);
    <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;
  }

  <span class="hljs-keyword">return</span> data;
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">isFeatureEnabled</span>(<span class="hljs-params">
  key: <span class="hljs-built_in">string</span>,
  userId?: <span class="hljs-built_in">string</span>
</span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">FeatureFlagCheckResult</span>&gt; </span>{
  <span class="hljs-keyword">const</span> flag = <span class="hljs-keyword">await</span> getFeatureFlag(key);

  <span class="hljs-keyword">if</span> (!flag) {
    <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'Flag not found'</span> };
  }

  <span class="hljs-keyword">if</span> (!flag.enabled) {
    <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'Flag is globally disabled'</span> };
  }

  <span class="hljs-comment">// Check user-specific access</span>
  <span class="hljs-keyword">if</span> (userId &amp;&amp; flag.enabled_for_users.length &gt; <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">if</span> (flag.enabled_for_users.includes(userId)) {
      <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">true</span>, reason: <span class="hljs-string">'User has explicit access'</span> };
    }
    <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'User not in allowed list'</span> };
  }

  <span class="hljs-comment">// Check percentage rollout</span>
  <span class="hljs-keyword">if</span> (flag.enabled_for_percent &gt; <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">const</span> hash = simpleHash(userId || key);
    <span class="hljs-keyword">const</span> percentage = hash % <span class="hljs-number">100</span>;
    <span class="hljs-keyword">const</span> enabled = percentage &lt; flag.enabled_for_percent;

    <span class="hljs-keyword">return</span> {
      enabled,
      reason: enabled
        ? <span class="hljs-string">`User falls within <span class="hljs-subst">${flag.enabled_for_percent}</span>% rollout`</span>
        : <span class="hljs-string">`User falls outside <span class="hljs-subst">${flag.enabled_for_percent}</span>% rollout`</span>,
    };
  }

  <span class="hljs-keyword">return</span> { enabled: <span class="hljs-literal">true</span>, reason: <span class="hljs-string">'Flag is globally enabled'</span> };
}

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">simpleHash</span>(<span class="hljs-params">str: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">number</span> </span>{
  <span class="hljs-keyword">let</span> hash = <span class="hljs-number">0</span>;
  <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; str.length; i++) {
    <span class="hljs-keyword">const</span> char = str.charCodeAt(i);
    hash = (hash &lt;&lt; <span class="hljs-number">5</span>) - hash + char;
    hash = hash &amp; hash;
  }
  <span class="hljs-keyword">return</span> <span class="hljs-built_in">Math</span>.abs(hash);
}
</code></pre>
<p>The logic is identical to the client version, but it uses the server Supabase client that handles cookies correctly.</p>
<h2 id="heading-setting-up-react-query">Setting Up React Query</h2>
<p>We’ll rely heavily on React Query throughout this tutorial because feature flags are server-driven values that can change at runtime while users are actively using the application.</p>
<p>React Query provides robust server-state management through caching, background refetching, and cache invalidation. This allows feature flag changes to propagate automatically across the app without forcing page refreshes or manual state synchronization.</p>
<h3 id="heading-create-the-query-provider">Create the Query Provider</h3>
<p>First, create the <code>providers/QueryProvider.tsx</code> file to configure and initialize React Query for the entire application:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;

<span class="hljs-keyword">import</span> { QueryClient, QueryClientProvider } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">QueryProvider</span>(<span class="hljs-params">{ children }: { children: React.ReactNode }</span>) </span>{
  <span class="hljs-keyword">const</span> [queryClient] = useState(
    <span class="hljs-function">() =&gt;</span>
      <span class="hljs-keyword">new</span> QueryClient({
        defaultOptions: {
          queries: {
            <span class="hljs-comment">// With SSR, we usually want to set some default staleTime</span>
            <span class="hljs-comment">// above 0 to avoid refetching immediately on the client</span>
            staleTime: <span class="hljs-number">20</span> * <span class="hljs-number">1000</span>,
            refetchOnWindowFocus: <span class="hljs-literal">true</span>,
            refetchOnReconnect: <span class="hljs-literal">true</span>,
          },
        },
      })
  );

  <span class="hljs-keyword">return</span> (
    &lt;QueryClientProvider client={queryClient}&gt;{children}&lt;/QueryClientProvider&gt;
  );
}
</code></pre>
<p>What’s happening in this code:</p>
<ul>
<li><p>This file runs on the client because React Query relies on React hooks, which only execute in the browser.</p>
</li>
<li><p>A single QueryClient instance is created and stored in state so it persists across renders and isn’t recreated.</p>
</li>
<li><p>The QueryClient defines how server data is cached, when it becomes stale, and when it should be refetched.</p>
</li>
</ul>
<p>This is important because components no longer need to handle fetch logic, loading states, or caching manually. Also, server data is shared and reused across components instead of being refetched repeatedly. And feature flag updates propagate automatically, keeping the app consistent without manual refreshes.</p>
<h3 id="heading-add-provider-to-root-layout">Add Provider to Root Layout</h3>
<p>Next, update the <code>app/layout.tsx</code> file to wrap the application with the React Query provider.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { Metadata } <span class="hljs-keyword">from</span> <span class="hljs-string">'next'</span>
<span class="hljs-keyword">import</span> <span class="hljs-string">'./globals.css'</span>
<span class="hljs-keyword">import</span> { QueryProvider } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/providers/QueryProvider'</span>

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> metadata: Metadata = {
  title: <span class="hljs-string">'Feature Flag System'</span>,
  description: <span class="hljs-string">'Production-ready feature flag system with Next.js and Supabase'</span>,
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">RootLayout</span>(<span class="hljs-params">{
  children,
}: {
  children: React.ReactNode
}</span>) </span>{
  <span class="hljs-keyword">return</span> (
    &lt;html lang=<span class="hljs-string">"en"</span>&gt;
      &lt;body&gt;
        &lt;QueryProvider&gt;{children}&lt;/QueryProvider&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  )
}
</code></pre>
<p>We wrap the entire application with <code>QueryProvider</code>, so React Query can manage server data globally across the app.</p>
<h4 id="heading-what-queryprovider-actually-does">What <code>QueryProvider</code> actually does</h4>
<p><code>QueryProvider</code> creates and shares a single Query Client that is responsible for:</p>
<ul>
<li><p>Caching data fetched from the server</p>
</li>
<li><p>Tracking loading and error states</p>
</li>
<li><p>Automatically refetching data when needed</p>
</li>
<li><p>Synchronizing data between components</p>
</li>
</ul>
<p>By wrapping the app, every component inside it can use React Query hooks without any extra setup.</p>
<h2 id="heading-creating-the-react-hook">Creating the React Hook</h2>
<p>Create the <code>hooks/useFeatureFlag.ts</code> file to check whether a feature flag is enabled for a user, using React Query to cache and share the data across components:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;

<span class="hljs-keyword">import</span> { useQuery } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { isFeatureEnabled } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/lib/feature-flags/client'</span>;
<span class="hljs-keyword">import</span> { FeatureFlagCheckResult } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/types/feature-flag'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useFeatureFlag</span>(<span class="hljs-params">key: <span class="hljs-built_in">string</span>, userId?: <span class="hljs-built_in">string</span></span>) </span>{
  <span class="hljs-keyword">const</span> {
    data: result = { enabled: <span class="hljs-literal">false</span>, reason: <span class="hljs-string">'Loading...'</span> },
    isLoading: loading,
    error,
  } = useQuery&lt;FeatureFlagCheckResult&gt;({
    queryKey: [<span class="hljs-string">'featureFlag'</span>, key, userId],
    queryFn: <span class="hljs-function">() =&gt;</span> isFeatureEnabled(key, userId),
    staleTime: <span class="hljs-number">30</span> * <span class="hljs-number">1000</span>,
    refetchOnWindowFocus: <span class="hljs-literal">true</span>,
  });

  <span class="hljs-keyword">return</span> { ...result, loading, error };
}
</code></pre>
<p>What’s happening in the code:</p>
<ul>
<li><p><code>'use client';</code> ensures this file runs in the browser because React Query hooks can only run on the client.</p>
</li>
<li><p><code>useQuery</code> fetches the feature flag status from the server and caches the result.</p>
</li>
<li><p><code>queryKey: ['featureFlag', key, userId]</code> uniquely identifies this query so React Query can cache it separately for each feature and user.</p>
</li>
<li><p><code>queryFn: () =&gt; isFeatureEnabled(key, userId)</code> is the function that actually checks if the feature is enabled.</p>
</li>
<li><p><code>staleTime: 30 * 1000</code> keeps the cached data fresh for 30 seconds before refetching.</p>
</li>
<li><p><code>refetchOnWindowFocus: true</code> automatically refetches the data when the user switches back to the tab.</p>
</li>
<li><p><code>return { ...result, loading, error }</code> makes it easy for components to access the flag status, loading state, and any errors.</p>
</li>
</ul>
<h3 id="heading-admin-hooks-for-managing-flags">Admin Hooks for Managing Flags</h3>
<p>We’re going to create a set of React Query hooks to manage feature flags from the admin dashboard. These hooks allow you to fetch, create, update, and delete feature flags while automatically keeping your UI in sync.</p>
<h4 id="heading-step-1-create-the-hooks-file">Step 1: Create the hooks file</h4>
<p>Start by creating a new file at <code>hooks/useFeatureFlags.ts</code> file. This is where we’ll implement all the hooks for the admin to manage feature flags.</p>
<p>After creating the <code>hooks/useFeatureFlags.ts</code> file, import React Query hooks and the FeatureFlag type so we can fetch, update, create, and delete feature flags with type safety and caching.</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;

<span class="hljs-keyword">import</span> { useQuery, useMutation, useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@tanstack/react-query'</span>;
<span class="hljs-keyword">import</span> { FeatureFlag } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/types/feature-flag'</span>;
</code></pre>
<p>In this code:</p>
<ul>
<li><p><code>useQuery</code> fetches and caches server data automatically.</p>
</li>
<li><p><code>useMutation</code> sends updates, creates, or deletes data on the server.</p>
</li>
<li><p><code>useQueryClient</code> gives access to the query cache so we can invalidate or update queries after mutations.</p>
</li>
<li><p><code>FeatureFlag</code> is the TypeScript type definition for feature flags, ensuring our hooks use correct data structures.</p>
</li>
</ul>
<h4 id="heading-step-2-add-a-hook-to-fetch-all-feature-flags">Step 2: Add a hook to fetch all feature flags</h4>
<p>Next, in the same <code>hooks/useFeatureFlags.ts</code> file, add a hook to fetch all feature flags for the admin page. This hook will allow components to retrieve the list of flags and automatically cache the data using React Query.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useFeatureFlags</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">return</span> useQuery&lt;FeatureFlag[]&gt;({
    queryKey: [<span class="hljs-string">'featureFlags'</span>],
    queryFn: <span class="hljs-keyword">async</span> () =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/feature-flags'</span>);
      <span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> response.json();
      <span class="hljs-keyword">return</span> data || [];
    },
    staleTime: <span class="hljs-number">30</span> * <span class="hljs-number">1000</span>,
  });
}
</code></pre>
<p>What’s happening in the code:</p>
<ul>
<li><p><code>useQuery</code> fetches all feature flags from the server and caches them automatically.</p>
</li>
<li><p><code>queryKey: ['featureFlags']</code> uniquely identifies this query so React Query can manage caching and refetching.</p>
</li>
<li><p><code>queryFn</code> is an async function that calls the <code>/api/feature-flags</code> endpoint and returns the data.</p>
</li>
<li><p><code>staleTime: 30 * 1000</code> keeps the cached data fresh for 30 seconds before refetching.</p>
</li>
</ul>
<p>This matters because admin components always display the latest flags without manual refresh. Also, cached data reduces unnecessary network requests. Finally, any component using this hook will automatically update when the flags change.</p>
<h4 id="heading-step-3-add-a-hook-to-update-a-feature-flag">Step 3: Add a hook to update a feature flag</h4>
<p>Next, in the same <code>hooks/useFeatureFlags.ts</code> file, add a hook to update an existing feature flag. This hook will allow the admin to modify a flag and ensure all components using it get the updated value automatically.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useUpdateFeatureFlag</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> queryClient = useQueryClient();

  <span class="hljs-keyword">return</span> useMutation({
    mutationFn: <span class="hljs-keyword">async</span> ({
      key,
      updates,
    }: {
      key: <span class="hljs-built_in">string</span>;
      updates: Partial&lt;FeatureFlag&gt;;
    }) =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/feature-flags/<span class="hljs-subst">${key}</span>`</span>, {
        method: <span class="hljs-string">'PATCH'</span>,
        headers: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span> },
        body: <span class="hljs-built_in">JSON</span>.stringify(updates),
      });

      <span class="hljs-keyword">if</span> (!response.ok) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Failed to update feature flag'</span>);
      }

      <span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> response.json();
      <span class="hljs-keyword">return</span> data;
    },
    onSuccess: <span class="hljs-function">(<span class="hljs-params">data, variables</span>) =&gt;</span> {
      <span class="hljs-comment">// Invalidate and refetch feature flags list</span>
      queryClient.invalidateQueries({ queryKey: [<span class="hljs-string">'featureFlags'</span>] });
      <span class="hljs-comment">// Invalidate the specific feature flag check</span>
      queryClient.invalidateQueries({
        queryKey: [<span class="hljs-string">'featureFlag'</span>, variables.key],
      });
      <span class="hljs-comment">// Invalidate all feature flag checks (in case userId was involved)</span>
      queryClient.invalidateQueries({ queryKey: [<span class="hljs-string">'featureFlag'</span>] });
    },
  });
}
</code></pre>
<p>What’s happening in the code:</p>
<ul>
<li><p><code>useMutation</code> creates a function to update a feature flag on the server.</p>
</li>
<li><p><code>mutationFn</code> is an async function that sends a PATCH request to <code>/api/feature-flags/${key}</code> with the updated data.</p>
</li>
<li><p><code>onSuccess</code> runs after a successful update to <strong>invalidate cached queries</strong> so the latest data is available everywhere:</p>
<ul>
<li><p><code>['featureFlags']</code> updates the full list of flags.</p>
</li>
<li><p><code>['featureFlag', variables.key]</code> updates the specific flag that was changed.</p>
</li>
<li><p><code>['featureFlag']</code> updates any other cached flag checks (for example, per user checks).</p>
</li>
</ul>
</li>
</ul>
<h4 id="heading-step-4-add-a-hook-to-delete-a-feature-flag">Step 4: Add a hook to delete a feature flag</h4>
<p>Next, add the <code>useDeleteFeatureFlag</code> hook to delete a feature flag. This hook allows the admin to remove a flag from the system and ensures the UI updates automatically everywhere the flag was used.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useDeleteFeatureFlag</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> queryClient = useQueryClient();

  <span class="hljs-keyword">return</span> useMutation({
    mutationFn: <span class="hljs-keyword">async</span> (key: <span class="hljs-built_in">string</span>) =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/feature-flags/<span class="hljs-subst">${key}</span>`</span>, {
        method: <span class="hljs-string">'DELETE'</span>,
      });

      <span class="hljs-keyword">if</span> (!response.ok) {
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Failed to delete feature flag'</span>);
      }
    },
    onSuccess: <span class="hljs-function">(<span class="hljs-params">_, key</span>) =&gt;</span> {
      <span class="hljs-comment">// Invalidate and refetch feature flags list</span>
      queryClient.invalidateQueries({ queryKey: [<span class="hljs-string">'featureFlags'</span>] });
      <span class="hljs-comment">// Invalidate the specific feature flag check</span>
      queryClient.invalidateQueries({ queryKey: [<span class="hljs-string">'featureFlag'</span>, key] });
    },
  });
}
</code></pre>
<p>What’s happening in the code:</p>
<ul>
<li><p><code>useMutation</code> creates a function to delete a feature flag from the server.</p>
</li>
<li><p><code>mutationFn</code> is an async function that sends a DELETE request to <code>/api/feature-flags/${key}</code>.</p>
</li>
<li><p><code>onSuccess</code> runs after the flag is successfully deleted to <strong>invalidate the cache</strong> so the UI updates:</p>
<ul>
<li><p><code>['featureFlags']</code> refetches the full list of flags.</p>
</li>
<li><p><code>['featureFlag', key]</code> removes the deleted flag from any cached queries.</p>
</li>
</ul>
</li>
</ul>
<h4 id="heading-step-5-add-a-hook-to-create-a-new-feature-flag">Step 5: Add a hook to create a new feature flag</h4>
<p>Finally, add the <code>useCreateFeatureFlag</code> hook to create a new feature flag. This allows the admin to add new flags and ensures the dashboard updates automatically when a new flag is created.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useCreateFeatureFlag</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> queryClient = useQueryClient();

  <span class="hljs-keyword">return</span> useMutation({
    mutationFn: <span class="hljs-keyword">async</span> (flag: {
      key: <span class="hljs-built_in">string</span>;
      name: <span class="hljs-built_in">string</span>;
      description?: <span class="hljs-built_in">string</span>;
      enabled?: <span class="hljs-built_in">boolean</span>;
      enabled_for_users?: <span class="hljs-built_in">string</span>[];
      enabled_for_percent?: <span class="hljs-built_in">number</span>;
      metadata?: Record&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt;;
    }) =&gt; {
      <span class="hljs-keyword">const</span> response = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/feature-flags'</span>, {
        method: <span class="hljs-string">'POST'</span>,
        headers: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span> },
        body: <span class="hljs-built_in">JSON</span>.stringify(flag),
      });

      <span class="hljs-keyword">if</span> (!response.ok) {
        <span class="hljs-keyword">const</span> error = <span class="hljs-keyword">await</span> response.json();
        <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(error.error || <span class="hljs-string">'Failed to create feature flag'</span>);
      }

      <span class="hljs-keyword">const</span> { data } = <span class="hljs-keyword">await</span> response.json();
      <span class="hljs-keyword">return</span> data;
    },
    onSuccess: <span class="hljs-function">() =&gt;</span> {
      <span class="hljs-comment">// Invalidate and refetch feature flags list</span>
      queryClient.invalidateQueries({ queryKey: [<span class="hljs-string">'featureFlags'</span>] });
    },
  });
}
</code></pre>
<p>What’s happening in the code:</p>
<ul>
<li><p><code>useMutation</code> creates a function to send a new feature flag to the server.</p>
</li>
<li><p><code>mutationFn</code> is an async function that posts the flag data to <code>/api/feature-flags</code>.</p>
</li>
<li><p><code>onSuccess</code> runs after a successful creation to <strong>invalidate the cached list of flags</strong>, so the admin dashboard shows the new flag immediately.</p>
</li>
</ul>
<h3 id="heading-feature-flag-gate-component">Feature Flag Gate Component</h3>
<p>Now we’ll create a wrapper component to conditionally render UI based on feature flags. This helps you show or hide parts of your app depending on whether a flag is enabled for a user.</p>
<p>Create the <code>components/FeatureFlagGate.tsx</code> file and add the following code:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;

<span class="hljs-keyword">import</span> { ReactNode } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> { useFeatureFlag } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/hooks/useFeatureFlag'</span>;

<span class="hljs-keyword">interface</span> FeatureFlagGateProps {
  flagKey: <span class="hljs-built_in">string</span>;
  userId?: <span class="hljs-built_in">string</span>;
  children: ReactNode;
  fallback?: ReactNode;
  showLoading?: ReactNode;
}

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">FeatureFlagGate</span>(<span class="hljs-params">{
  flagKey,
  userId,
  children,
  fallback = <span class="hljs-literal">null</span>,
  showLoading = <span class="hljs-literal">null</span>,
}: FeatureFlagGateProps</span>) </span>{
  <span class="hljs-keyword">const</span> { enabled, loading } = useFeatureFlag(flagKey, userId);

  <span class="hljs-keyword">if</span> (loading &amp;&amp; showLoading !== <span class="hljs-literal">null</span>) {
    <span class="hljs-keyword">return</span> &lt;&gt;{showLoading}&lt;/&gt;;
  }

  <span class="hljs-keyword">if</span> (!enabled) {
    <span class="hljs-keyword">return</span> &lt;&gt;{fallback}&lt;/&gt;;
  }

  <span class="hljs-keyword">return</span> &lt;&gt;{children}&lt;/&gt;;
}
</code></pre>
<p>What’s happening in the code:</p>
<ul>
<li><p><code>useFeatureFlag(flagKey, userId)</code> checks whether the feature is enabled for a specific user and tracks loading state.</p>
</li>
<li><p><code>loading &amp;&amp; showLoading !== null</code>: if the data is still loading, render the optional <code>showLoading</code> UI.</p>
</li>
<li><p><code>!enabled</code>: if the feature is disabled, render the optional <code>fallback</code> UI.</p>
</li>
<li><p><code>children</code> renders the actual content only if the flag is enabled and not loading.</p>
</li>
</ul>
<p>This makes it easy to conditionally render features without scattering logic throughout your components. It also supports custom loading and fallback UI for better user experience. And it works with React Query caching automatically, so flag changes propagate immediately.</p>
<h2 id="heading-building-the-admin-dashboard">Building the Admin Dashboard</h2>
<p>Admins need a way to manage feature flags in your app. To do this, we’ll create API routes that support CRUD operations (Create, Read, Update, Delete). These routes will interact with Supabase to store and modify flag data.</p>
<h3 id="heading-step-1-create-the-main-feature-flags-api-route">Step 1: Create the main feature flags API route</h3>
<p>Start by creating the <code>app/api/feature-flags/route.ts</code> file. This file will handle fetching all feature flags (GET) and creating new ones (POST).</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { NextRequest, NextResponse } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/server'</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/lib/supabase/server'</span>;
<span class="hljs-keyword">import</span> { cookies } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/headers'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">GET</span>(<span class="hljs-params">request: NextRequest</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> cookieStore = <span class="hljs-keyword">await</span> cookies();
    <span class="hljs-keyword">const</span> supabase = createClient(cookieStore);
    <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'feature_flags'</span>)
      .select(<span class="hljs-string">'*'</span>)
      .order(<span class="hljs-string">'created_at'</span>, { ascending: <span class="hljs-literal">false</span> });

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-keyword">return</span> NextResponse.json({ data });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> NextResponse.json(
      { error: <span class="hljs-string">'Internal server error'</span> },
      { status: <span class="hljs-number">500</span> }
    );
  }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">POST</span>(<span class="hljs-params">request: NextRequest</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> cookieStore = <span class="hljs-keyword">await</span> cookies();
    <span class="hljs-keyword">const</span> supabase = createClient(cookieStore);
    <span class="hljs-keyword">const</span> body = <span class="hljs-keyword">await</span> request.json();

    <span class="hljs-keyword">if</span> (!body.key || !body.name) {
      <span class="hljs-keyword">return</span> NextResponse.json(
        { error: <span class="hljs-string">'key and name are required'</span> },
        { status: <span class="hljs-number">400</span> }
      );
    }

    <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'feature_flags'</span>)
      .insert({
        key: body.key,
        name: body.name,
        description: body.description || <span class="hljs-literal">null</span>,
        enabled: body.enabled || <span class="hljs-literal">false</span>,
        enabled_for_users: body.enabled_for_users || [],
        enabled_for_percent: body.enabled_for_percent || <span class="hljs-number">0</span>,
        metadata: body.metadata || {},
      })
      .select()
      .single();

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-keyword">return</span> NextResponse.json({ data }, { status: <span class="hljs-number">201</span> });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> NextResponse.json(
      { error: <span class="hljs-string">'Internal server error'</span> },
      { status: <span class="hljs-number">500</span> }
    );
  }
}
</code></pre>
<p>What’s happening in this file:</p>
<ul>
<li><p><code>GET</code> fetches all feature flags from Supabase, ordered by creation date.</p>
</li>
<li><p><code>POST</code> creates a new feature flag in Supabase. It checks that <code>key</code> and <code>name</code> exist, and sets defaults for optional fields.</p>
</li>
<li><p><code>createClient(cookieStore)</code> authenticates requests with <code>Supabase</code> using cookies from the client.</p>
</li>
<li><p><code>NextResponse.json()</code> sends JSON responses with data or error messages.</p>
</li>
</ul>
<h3 id="heading-step-2-create-the-dynamic-route-for-individual-flags">Step 2: Create the dynamic route for individual flags</h3>
<p>Next, Create the file <code>app/api/feature-flags/[key]/route.ts</code>. This route handles updating (PATCH) and deleting (DELETE) individual flags based on their <code>key</code>.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { NextRequest, NextResponse } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/server'</span>;
<span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/lib/supabase/server'</span>;
<span class="hljs-keyword">import</span> { cookies } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/headers'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">PATCH</span>(<span class="hljs-params">
  request: NextRequest,
  { params }: { params: { key: <span class="hljs-built_in">string</span> } }
</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> cookieStore = <span class="hljs-keyword">await</span> cookies();
    <span class="hljs-keyword">const</span> supabase = createClient(cookieStore);
    <span class="hljs-keyword">const</span> body = <span class="hljs-keyword">await</span> request.json();

    <span class="hljs-keyword">const</span> { data, error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'feature_flags'</span>)
      .update({
        ...(body.name !== <span class="hljs-literal">undefined</span> &amp;&amp; { name: body.name }),
        ...(body.description !== <span class="hljs-literal">undefined</span> &amp;&amp; { description: body.description }),
        ...(body.enabled !== <span class="hljs-literal">undefined</span> &amp;&amp; { enabled: body.enabled }),
        ...(body.enabled_for_users !== <span class="hljs-literal">undefined</span> &amp;&amp; {
          enabled_for_users: body.enabled_for_users,
        }),
        ...(body.enabled_for_percent !== <span class="hljs-literal">undefined</span> &amp;&amp; {
          enabled_for_percent: body.enabled_for_percent,
        }),
      })
      .eq(<span class="hljs-string">'key'</span>, params.key)
      .select()
      .single();

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-keyword">return</span> NextResponse.json({ data });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> NextResponse.json(
      { error: <span class="hljs-string">'Internal server error'</span> },
      { status: <span class="hljs-number">500</span> }
    );
  }
}

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">DELETE</span>(<span class="hljs-params">
  request: NextRequest,
  { params }: { params: { key: <span class="hljs-built_in">string</span> } }
</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> cookieStore = <span class="hljs-keyword">await</span> cookies();
    <span class="hljs-keyword">const</span> supabase = createClient(cookieStore);

    <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'feature_flags'</span>)
      .delete()
      .eq(<span class="hljs-string">'key'</span>, params.key);

    <span class="hljs-keyword">if</span> (error) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-keyword">return</span> NextResponse.json({ success: <span class="hljs-literal">true</span> });
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-keyword">return</span> NextResponse.json(
      { error: <span class="hljs-string">'Internal server error'</span> },
      { status: <span class="hljs-number">500</span> }
    );
  }
}
</code></pre>
<p>What’s happening in this file:</p>
<ul>
<li><p><code>PATCH</code> updates a feature flag with only the fields provided in the request body.</p>
<ul>
<li>Uses the spread operator <code>(...)</code> to include only fields that exist.</li>
</ul>
</li>
<li><p><code>DELETE</code> removes the feature flag identified by <code>key</code>.</p>
</li>
<li><p><code>eq('key', params.key)</code> ensures the operation targets the correct flag.</p>
</li>
</ul>
<h3 id="heading-step-3-create-the-admin-dashboard-ui">Step 3: Create the Admin Dashboard UI</h3>
<p>Now, create the file <code>app/admin/page.tsx</code>. This is the main admin dashboard where you can view, create, and manage feature flags.</p>
<p>With React Query, the admin dashboard becomes much cleaner and automatically updates when flags change:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;

<span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> { FeatureFlagList } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/components/admin/FeatureFlagList'</span>;
<span class="hljs-keyword">import</span> { CreateFeatureFlagModal } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/components/admin/CreateFeatureFlagModal'</span>;
<span class="hljs-keyword">import</span> { useFeatureFlags } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/hooks/useFeatureFlags'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">AdminPage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [showCreateModal, setShowCreateModal] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> { data: flags = [], isLoading: loading } = useFeatureFlags();

  <span class="hljs-keyword">return</span> (
    &lt;div className=<span class="hljs-string">'min-h-screen bg-gray-50 py-8'</span>&gt;
      &lt;div className=<span class="hljs-string">'max-w-7xl mx-auto px-4 sm:px-6 lg:px-8'</span>&gt;
        &lt;div className=<span class="hljs-string">'mb-8 flex justify-between items-center'</span>&gt;
          &lt;div&gt;
            &lt;h1 className=<span class="hljs-string">'text-3xl font-bold text-gray-900'</span>&gt;
              Feature Flags Admin
            &lt;/h1&gt;
            &lt;p className=<span class="hljs-string">'mt-2 text-sm text-gray-600'</span>&gt;
              Manage your feature flags and rollouts
            &lt;/p&gt;
          &lt;/div&gt;
          &lt;button
            onClick={<span class="hljs-function">() =&gt;</span> setShowCreateModal(<span class="hljs-literal">true</span>)}
            className=<span class="hljs-string">'px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors'</span>
          &gt;
            Create Feature Flag
          &lt;/button&gt;
        &lt;/div&gt;

        {loading ? (
          &lt;div className=<span class="hljs-string">'text-center py-12'</span>&gt;
            &lt;div className=<span class="hljs-string">'inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600'</span>&gt;&lt;/div&gt;
            &lt;p className=<span class="hljs-string">'mt-4 text-gray-600'</span>&gt;Loading feature flags...&lt;/p&gt;
          &lt;/div&gt;
        ) : (
          &lt;FeatureFlagList flags={flags} /&gt;
        )}

        {showCreateModal &amp;&amp; (
          &lt;CreateFeatureFlagModal
            onClose={<span class="hljs-function">() =&gt;</span> setShowCreateModal(<span class="hljs-literal">false</span>)}
            onSuccess={<span class="hljs-function">() =&gt;</span> {
              setShowCreateModal(<span class="hljs-literal">false</span>);
            }}
          /&gt;
        )}
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>What’s happening in this file:</p>
<ul>
<li><p><code>'use client';</code> marks this page as a client component so hooks like <code>useState</code> and React Query can run.</p>
</li>
<li><p><code>useState</code> manages whether the “Create Feature Flag” modal is open or closed.</p>
</li>
<li><p><code>useFeatureFlags()</code> fetches all feature flags from the API and keeps the list in sync automatically.</p>
</li>
<li><p><code>const { data: flags = [], isLoading: loading }</code> :</p>
<ul>
<li><p><code>flags</code> contains the feature flag list</p>
</li>
<li><p><code>loading</code> tracks whether the data is still being fetched</p>
</li>
</ul>
</li>
<li><p><code>loading ? ... : &lt;FeatureFlagList /&gt;</code> :</p>
<ul>
<li><p>Shows a loading spinner while flags are being fetched</p>
</li>
<li><p>Renders the feature flag table once data is available</p>
</li>
</ul>
</li>
<li><p><code>FeatureFlagList</code> displays all feature flags and their current states.</p>
</li>
</ul>
<h3 id="heading-using-feature-flags-mutations-in-components">Using Feature Flags Mutations in Components</h3>
<p>Earlier, we created mutation hooks for creating, updating, and deleting feature flags. Now let’s see how those hooks are actually used inside UI components to trigger updates and keep the interface in sync.</p>
<p>Here's how to use the mutation hooks in your components:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>

<span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>
<span class="hljs-keyword">import</span> { FeatureFlag } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/types/feature-flag'</span>
<span class="hljs-keyword">import</span> { EditFeatureFlagModal } <span class="hljs-keyword">from</span> <span class="hljs-string">'./EditFeatureFlagModal'</span>
<span class="hljs-keyword">import</span> {
  useUpdateFeatureFlag,
  useDeleteFeatureFlag,
} <span class="hljs-keyword">from</span> <span class="hljs-string">'@/hooks/useFeatureFlags'</span>

<span class="hljs-keyword">interface</span> FeatureFlagCardProps {
  flag: FeatureFlag
}

<span class="hljs-keyword">export</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">FeatureFlagCard</span>(<span class="hljs-params">{ flag }: FeatureFlagCardProps</span>) </span>{
  <span class="hljs-keyword">const</span> [isEditing, setIsEditing] = useState(<span class="hljs-literal">false</span>)
  <span class="hljs-keyword">const</span> updateFlag = useUpdateFeatureFlag()
  <span class="hljs-keyword">const</span> deleteFlag = useDeleteFeatureFlag()

  <span class="hljs-keyword">const</span> handleToggle = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> updateFlag.mutateAsync({
        key: flag.key,
        updates: { enabled: !flag.enabled },
      })
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error toggling flag:'</span>, error)
      alert(<span class="hljs-string">'Failed to toggle feature flag'</span>)
    }
  }

  <span class="hljs-keyword">const</span> handleDelete = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!confirm(<span class="hljs-string">`Are you sure you want to delete "<span class="hljs-subst">${flag.name}</span>"?`</span>)) {
      <span class="hljs-keyword">return</span>
    }

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">await</span> deleteFlag.mutateAsync(flag.key)
    } <span class="hljs-keyword">catch</span> (error) {
      <span class="hljs-built_in">console</span>.error(<span class="hljs-string">'Error deleting flag:'</span>, error)
      alert(<span class="hljs-string">'Failed to delete feature flag'</span>)
    }
  }

  <span class="hljs-keyword">return</span> (
    &lt;&gt;
      &lt;div className=<span class="hljs-string">"bg-white rounded-lg shadow p-6"</span>&gt;
        &lt;div className=<span class="hljs-string">"flex items-start justify-between"</span>&gt;
          &lt;div className=<span class="hljs-string">"flex-1"</span>&gt;
            &lt;div className=<span class="hljs-string">"flex items-center gap-3"</span>&gt;
              &lt;h3 className=<span class="hljs-string">"text-lg font-semibold text-gray-900"</span>&gt;
                {flag.name}
              &lt;/h3&gt;
              &lt;span
                className={<span class="hljs-string">`px-2 py-1 text-xs font-medium rounded-full <span class="hljs-subst">${
                  flag.enabled
                    ? <span class="hljs-string">'bg-green-100 text-green-800'</span>
                    : <span class="hljs-string">'bg-gray-100 text-gray-800'</span>
                }</span>`</span>}
              &gt;
                {flag.enabled ? <span class="hljs-string">'Enabled'</span> : <span class="hljs-string">'Disabled'</span>}
              &lt;/span&gt;
            &lt;/div&gt;
            &lt;p className=<span class="hljs-string">"mt-1 text-sm text-gray-600 font-mono"</span>&gt;{flag.key}&lt;/p&gt;
            {flag.description &amp;&amp; (
              &lt;p className=<span class="hljs-string">"mt-2 text-sm text-gray-500"</span>&gt;{flag.description}&lt;/p&gt;
            )}

            &lt;div className=<span class="hljs-string">"mt-4 flex flex-wrap gap-4 text-sm text-gray-600"</span>&gt;
              {flag.enabled_for_users.length &gt; <span class="hljs-number">0</span> &amp;&amp; (
                &lt;div&gt;
                  &lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Users:&lt;/span&gt;{<span class="hljs-string">' '</span>}
                  {flag.enabled_for_users.length} user(s)
                &lt;/div&gt;
              )}
              {flag.enabled_for_percent &gt; <span class="hljs-number">0</span> &amp;&amp; (
                &lt;div&gt;
                  &lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Rollout:&lt;/span&gt;{<span class="hljs-string">' '</span>}
                  {flag.enabled_for_percent}%
                &lt;/div&gt;
              )}
            &lt;/div&gt;
          &lt;/div&gt;

          &lt;div className=<span class="hljs-string">"flex items-center gap-2 ml-4"</span>&gt;
            &lt;button
              onClick={handleToggle}
              disabled={updateFlag.isPending}
              className={<span class="hljs-string">`px-3 py-1.5 text-sm font-medium rounded transition-colors <span class="hljs-subst">${
                flag.enabled
                  ? <span class="hljs-string">'bg-red-100 text-red-700 hover:bg-red-200'</span>
                  : <span class="hljs-string">'bg-green-100 text-green-700 hover:bg-green-200'</span>
              }</span> disabled:opacity-50`</span>}
            &gt;
              {updateFlag.isPending
                ? <span class="hljs-string">'...'</span>
                : flag.enabled
                ? <span class="hljs-string">'Disable'</span>
                : <span class="hljs-string">'Enable'</span>}
            &lt;/button&gt;
            &lt;button
              onClick={<span class="hljs-function">() =&gt;</span> setIsEditing(<span class="hljs-literal">true</span>)}
              className=<span class="hljs-string">"px-3 py-1.5 text-sm font-medium text-blue-700 bg-blue-100 rounded hover:bg-blue-200 transition-colors"</span>
            &gt;
              Edit
            &lt;/button&gt;
            &lt;button
              onClick={handleDelete}
              className=<span class="hljs-string">"px-3 py-1.5 text-sm font-medium text-red-700 bg-red-100 rounded hover:bg-red-200 transition-colors"</span>
            &gt;
              Delete
            &lt;/button&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;

      {isEditing &amp;&amp; (
        &lt;EditFeatureFlagModal
          flag={flag}
          onClose={<span class="hljs-function">() =&gt;</span> setIsEditing(<span class="hljs-literal">false</span>)}
          onSuccess={<span class="hljs-function">() =&gt;</span> {
            setIsEditing(<span class="hljs-literal">false</span>)
          }}
        /&gt;
      )}
    &lt;/&gt;
  )
}
</code></pre>
<p>The component above works without any manual refetching or state synchronization logic. The moment a mutation succeeds, React Query automatically updates or invalidates the relevant cached data. That behavior is what enables the smooth, real-time updates you see in the UI.</p>
<p>Because all feature flag data is managed by React Query:</p>
<ul>
<li><p>When a flag is toggled, React Query automatically invalidates the relevant queries</p>
</li>
<li><p>Any component using that flag immediately receives the updated value</p>
</li>
<li><p>The admin dashboard stays in sync with the rest of the application without extra code</p>
</li>
<li><p>Loading and error states are handled consistently across all mutations.</p>
</li>
</ul>
<h2 id="heading-implementing-a-real-world-example">Implementing a Real-World Example</h2>
<p>To make all of this concrete, let’s walk through a real-world example.</p>
<p>In this section, we’ll build a simple Todo feature that is <strong>entirely controlled by a feature flag</strong>. When the flag is disabled, users see a message explaining that the feature isn’t available. When it’s enabled, the full Todo interface appears instantly without redeploying or refreshing the page.</p>
<p>This demonstrates how feature flags can safely gate entire pages or features in a live application.</p>
<p>Below is what the experience looks like as the feature flag is toggled on and off from the admin dashboard:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769946162379/33b4a9e3-5284-477e-b8c5-ecc0e137e7c1.png" alt="feature-flag-disabled-page" class="image--center mx-auto" width="2996" height="1402" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769946216049/edea8722-c298-4b2b-a4b5-bcc8306fb15f.png" alt="feature-flag-enabled-page" class="image--center mx-auto" width="2852" height="1536" loading="lazy"></p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770299915271/54c124ac-24d5-4d3f-825c-0507675053ee.png" alt="Todo-app-with-feature-flag" class="image--center mx-auto" width="1900" height="904" loading="lazy"></p>
<h3 id="heading-step-1-create-the-todo-page-file">Step 1: Create the Todo Page File</h3>
<p>Create a new file <code>app/todos/page.tsx</code>. This page shows how to use a feature flag to conditionally render a full component. Let's build a todo app that's controlled by a feature flag. This demonstrates real-world usage.</p>
<p>At the top of the file, import the hooks we need and define the Todo interface.</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;

<span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;
<span class="hljs-keyword">import</span> { FeatureFlagGate } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/components/FeatureFlagGate'</span>;

<span class="hljs-keyword">interface</span> Todo {
  id: <span class="hljs-built_in">string</span>;
  text: <span class="hljs-built_in">string</span>;
  completed: <span class="hljs-built_in">boolean</span>;
}
</code></pre>
<h3 id="heading-step-2-initialize-state-in-the-component">Step 2: Initialize state in the component</h3>
<p>Create the component and define state for todos and the input:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">TodosPage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [todos, setTodos] = useState&lt;Todo[]&gt;([]);
  <span class="hljs-keyword">const</span> [inputValue, setInputValue] = useState(<span class="hljs-string">''</span>);
</code></pre>
<p>Here’s what’s going on<strong>:</strong></p>
<ul>
<li><p><code>todos</code> stores all todo items.</p>
</li>
<li><p><code>inputValue</code> tracks what the user types in the input field.</p>
</li>
</ul>
<h3 id="heading-step-3-load-and-save-todos-from-localstorage">Step 3: Load and save todos from localStorage</h3>
<pre><code class="lang-typescript">  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">const</span> saved = <span class="hljs-built_in">localStorage</span>.getItem(<span class="hljs-string">'todos'</span>);
    <span class="hljs-keyword">if</span> (saved) setTodos(<span class="hljs-built_in">JSON</span>.parse(saved));
  }, []);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-built_in">localStorage</span>.setItem(<span class="hljs-string">'todos'</span>, <span class="hljs-built_in">JSON</span>.stringify(todos));
  }, [todos]);
</code></pre>
<p>In this code, the first <code>useEffect</code> loads saved todos from the browser’s <code>localStorage</code> when the component mounts. The second <code>useEffect</code> saves todos whenever the list changes. This ensures your todos persist across page reloads.</p>
<h3 id="heading-step-4-add-helper-functions">Step 4: Add helper functions</h3>
<pre><code class="lang-typescript">  <span class="hljs-keyword">const</span> addTodo = <span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (inputValue.trim()) {
      setTodos([...todos, { 
        id: <span class="hljs-built_in">Date</span>.now().toString(), 
        text: inputValue.trim(), 
        completed: <span class="hljs-literal">false</span> 
      }]);
      setInputValue(<span class="hljs-string">''</span>);
    }
  };

  <span class="hljs-keyword">const</span> toggleTodo = <span class="hljs-function">(<span class="hljs-params">id: <span class="hljs-built_in">string</span></span>) =&gt;</span> {
    setTodos(todos.map(<span class="hljs-function"><span class="hljs-params">todo</span> =&gt;</span> 
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  <span class="hljs-keyword">const</span> deleteTodo = <span class="hljs-function">(<span class="hljs-params">id: <span class="hljs-built_in">string</span></span>) =&gt;</span> {
    setTodos(todos.filter(<span class="hljs-function"><span class="hljs-params">todo</span> =&gt;</span> todo.id !== id));
  };
</code></pre>
<p>In this code,</p>
<ul>
<li><p><code>addTodo</code> adds a new todo item with a unique <code>id</code> and resets the input.</p>
</li>
<li><p><code>toggleTodo</code> marks a todo as completed or incomplete.</p>
</li>
<li><p><code>deleteTodo</code> removes a todo from the list.</p>
</li>
</ul>
<h3 id="heading-step-5-render-the-todo-app-inside-the-featureflaggate">Step 5: Render the Todo App inside the FeatureFlagGate</h3>
<pre><code class="lang-xml">  return (
    <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"max-w-2xl mx-auto p-8"</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"text-4xl font-bold mb-6"</span>&gt;</span>Todo App<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>

      <span class="hljs-tag">&lt;<span class="hljs-name">FeatureFlagGate</span>
        <span class="hljs-attr">flagKey</span>=<span class="hljs-string">"test"</span>
        <span class="hljs-attr">fallback</span>=<span class="hljs-string">{</span>
          &lt;<span class="hljs-attr">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">h3</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"font-semibold mb-2"</span>&gt;</span>Feature Not Available<span class="hljs-tag">&lt;/<span class="hljs-name">h3</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Enable the "test" feature flag in the admin dashboard.<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        }
      &gt;
</code></pre>
<p>What’s going on here:</p>
<ul>
<li><p>We wrap the Todo app in <code>FeatureFlagGate</code>.</p>
</li>
<li><p><code>flagKey="test"</code> only shows the Todo app if this feature flag is enabled.</p>
</li>
<li><p><code>fallback</code> displays a message when the feature is disabled.</p>
</li>
</ul>
<h3 id="heading-step-6-add-the-input-field-and-add-button">Step 6: Add the input field and Add button</h3>
<pre><code class="lang-xml">        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"mb-6"</span>&gt;</span>
          <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"flex gap-2"</span>&gt;</span>
            <span class="hljs-tag">&lt;<span class="hljs-name">input</span>
              <span class="hljs-attr">type</span>=<span class="hljs-string">"text"</span>
              <span class="hljs-attr">value</span>=<span class="hljs-string">{inputValue}</span>
              <span class="hljs-attr">onChange</span>=<span class="hljs-string">{(e)</span> =&gt;</span> setInputValue(e.target.value)}
              onKeyPress={(e) =&gt; e.key === 'Enter' &amp;&amp; addTodo()}
              placeholder="What needs to be done?"
              className="flex-1 px-4 py-3 border rounded-lg"
            /&gt;
            <span class="hljs-tag">&lt;<span class="hljs-name">button</span>
              <span class="hljs-attr">onClick</span>=<span class="hljs-string">{addTodo}</span>
              <span class="hljs-attr">className</span>=<span class="hljs-string">"px-6 py-3 bg-purple-600 text-white rounded-lg"</span>
            &gt;</span>
              Add
            <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
          <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
</code></pre>
<p>In this code,</p>
<ul>
<li><p>Input field captures new todo text.</p>
</li>
<li><p>Pressing Enter or clicking Add triggers <code>addTodo</code>.</p>
</li>
<li><p>It’s styled with Tailwind CSS for spacing and rounded borders.</p>
</li>
</ul>
<h3 id="heading-step-8-display-the-list-of-todos">Step 8: Display the list of todos</h3>
<pre><code class="lang-xml">
        <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">className</span>=<span class="hljs-string">"space-y-2"</span>&gt;</span>
          {todos.map((todo) =&gt; (
            <span class="hljs-tag">&lt;<span class="hljs-name">div</span>
              <span class="hljs-attr">key</span>=<span class="hljs-string">{todo.id}</span>
              <span class="hljs-attr">className</span>=<span class="hljs-string">"flex items-center gap-3 p-4 bg-gray-50 rounded-lg"</span>
            &gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">button</span>
                <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> toggleTodo(todo.id)}
                className={`w-6 h-6 rounded-full border-2 ${
                  todo.completed
                    ? 'bg-green-500 border-green-500'
                    : 'border-gray-300'
                }`}
              &gt;
                {todo.completed &amp;&amp; '✓'}
              <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">className</span>=<span class="hljs-string">{todo.completed</span> ? '<span class="hljs-attr">line-through</span>' <span class="hljs-attr">:</span> ''}&gt;</span>
                {todo.text}
              <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span>
              <span class="hljs-tag">&lt;<span class="hljs-name">button</span>
                <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> deleteTodo(todo.id)}
                className="text-red-500"
              &gt;
                Delete
              <span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span>
            <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
          ))}
        <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
      <span class="hljs-tag">&lt;/<span class="hljs-name">FeatureFlagGate</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span>
  );
}
</code></pre>
<p>This code,</p>
<ul>
<li><p>Loops over <code>todos</code> to display each item.</p>
</li>
<li><p>Shows a toggle button to mark as complete, a delete button, and the todo text.</p>
</li>
<li><p>Completed todos get a <strong>line-through</strong> style.</p>
</li>
<li><p>All interaction happens <strong>inside the FeatureFlagGate</strong>, so users only see this when the flag is enabled.<br>  The entire todo app is wrapped in <code>FeatureFlagGate</code>. When the "test" flag is disabled, users see a message instead of the app. When enabled, they see the full todo interface.</p>
</li>
</ul>
<h3 id="heading-todo-app-overview">Todo App Overview</h3>
<p>This Todo app demonstrates how feature flags work in a live application. It shows how admins can enable or disable the "test" feature flag dynamically from the dashboard.</p>
<p><code>FeatureFlagGate</code> ensures the interface updates immediately when the flag changes, and entire components or pages can be toggled on or off safely using feature flags.</p>
<h2 id="heading-server-side-usage">Server-Side Usage</h2>
<p>Feature flags shouldn’t only live on the client. In many cases, you’ll want to enforce them on the server as well, especially for APIs, background jobs, or sensitive business logic.</p>
<p>In this example, we’ll protect an API route by checking a feature flag on the server before returning any data.</p>
<p>First, create the API route file <code>app/api/some-feature/route.ts</code>. This demonstrates how to check a feature flag on the server before returning data.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { isFeatureEnabled } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/lib/feature-flags/server'</span>;
<span class="hljs-keyword">import</span> { NextResponse } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/server'</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">GET</span>(<span class="hljs-params">request: Request</span>) </span>{
  <span class="hljs-keyword">const</span> userId = request.headers.get(<span class="hljs-string">'user-id'</span>) || <span class="hljs-literal">undefined</span>;
  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> isFeatureEnabled(<span class="hljs-string">'new-feature'</span>, userId);

  <span class="hljs-keyword">if</span> (!result.enabled) {
    <span class="hljs-keyword">return</span> NextResponse.json(
      { error: <span class="hljs-string">'Feature not available'</span> },
      { status: <span class="hljs-number">403</span> }
    );
  }

  <span class="hljs-comment">// Feature is enabled, proceed with logic</span>
  <span class="hljs-keyword">return</span> NextResponse.json({ data: <span class="hljs-string">'Feature content'</span> });
}
</code></pre>
<p>What’s happening in this file:</p>
<ul>
<li><p>File creation: <code>app/api/some-feature/route.ts</code> defines a new API route.</p>
</li>
<li><p><code>isFeatureEnabled</code>: Checks whether the <code>'new-feature'</code> flag is active for the user.</p>
</li>
<li><p>Conditional response: Returns a <code>403</code> error if the feature is disabled. Otherwise, proceeds normally.</p>
</li>
<li><p>Server-side gating: Lets you protect entire endpoints, so users only access functionality when the feature is enabled.</p>
</li>
</ul>
<h2 id="heading-why-react-query">Why React Query?</h2>
<p>Feature flags introduce a unique challenge because they must remain consistent across the entire UI even as they change dynamically. Without a dedicated server-state solution, you’d need to manually refetch data, coordinate updates between components, and handle edge cases where parts of the UI fall out of sync.</p>
<p>React Query treats feature flags as shared server state. Once fetched, flags are cached and reused across components. When an admin updates a flag, the cache is invalidated and refetched in the background, triggering immediate UI updates everywhere the flag is used. This makes React Query a natural fit for feature flags, where correctness, consistency, and real-time updates are critical.</p>
<h3 id="heading-real-world-impact">Real-World Impact</h3>
<p>Before React Query:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// Had to manually refetch after every update</span>
<span class="hljs-keyword">const</span> handleToggle = <span class="hljs-keyword">async</span> () =&gt; {
  <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/feature-flags/<span class="hljs-subst">${key}</span>`</span>, { method: <span class="hljs-string">'PATCH'</span>, ... });
  fetchFlags(); <span class="hljs-comment">// Manual refetch</span>
  <span class="hljs-comment">// Other components still show old data until page refresh</span>
};
</code></pre>
<p>After React Query:</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// Automatic cache invalidation - everything updates instantly</span>
<span class="hljs-keyword">const</span> updateFlag = useUpdateFeatureFlag();
<span class="hljs-keyword">await</span> updateFlag.mutateAsync({ key, updates });
<span class="hljs-comment">// All components using this flag automatically update!</span>
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You’ve now built a complete, production-ready feature flag system using Next.js and Supabase. The system supports global toggles, user-specific access, and percentage-based rollouts, all backed by a flexible database schema.</p>
<p>Feature flags can be checked on both the client and the server, ensuring consistent behavior across UI components and API routes. With React Query handling caching and invalidation, changes made in the admin dashboard propagate instantly throughout the application without deploying or refreshing the page.</p>
<p>Feature flags are a foundational tool for modern development. They let you deploy code safely, test new ideas with real users, and react quickly when something goes wrong. With this setup in place, you can confidently extend the system with audit logs, analytics, scheduled rollouts, or deeper CI/CD integrations as your product grows.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
