<?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>Fri, 21 Aug 2026 02:14:48 +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[ 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;margin:0 auto" width="600" height="400" 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;margin:0 auto" width="600" height="400" 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;margin:0 auto" width="600" height="400" 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;margin:0 auto" width="600" height="400" 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;margin:0 auto" width="600" height="400" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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>
        
            <item>
                <title>
                    <![CDATA[ Why Your UI Won’t Update: Debugging Stale Data and Caching in React Apps ]]>
                </title>
                <description>
                    <![CDATA[ Your UI doesn’t “randomly” refuse to update. In most cases, it’s rendering cached data, which is data that was saved somewhere so the app doesn’t have to do the same work again. Caching is great for performance, but it becomes a pain when you don’t r... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/why-your-ui-wont-update-debugging-stale-data-and-caching-in-react-apps/</link>
                <guid isPermaLink="false">6984d41160b1e5f9aeccaa9e</guid>
                
                    <category>
                        <![CDATA[ caching ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Frontend Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwadamisi Samuel ]]>
                </dc:creator>
                <pubDate>Thu, 05 Feb 2026 17:32:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1770312709391/8442f6df-1133-47f7-a035-02c958145811.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your UI doesn’t “randomly” refuse to update. In most cases, it’s rendering cached data, which is data that was saved somewhere so the app doesn’t have to do the same work again.</p>
<p>Caching is great for performance, but it becomes a pain when you don’t realize which layer is reusing old data.</p>
<p>If you’ve ever seen this:</p>
<ul>
<li><p>You update a profile name, but the screen still shows the old one.</p>
</li>
<li><p>You delete an item, but it stays in the list.</p>
</li>
<li><p>Your API returns fresh JSON, but the page refuses to change.</p>
</li>
<li><p>You deploy a fix, but your teammate still sees the old behavior.</p>
</li>
</ul>
<p>You’re probably hitting a cache.</p>
<p>What makes this especially confusing is that not all stale UI comes from “real” caches. Modern web apps have multiple places where data can be reused, saved, or replayed between your UI, your API and when your app is deployed. When you don’t have a clear mental model of these layers, debugging turns into guesswork.</p>
<p>This article lays out a practical guide of the five most common caching layers that cause stale UI, plus one non-cache trap that looks exactly like one. The goal is to help you quickly identify where stale data is coming from, so you can fix the right thing instead of “refreshing harder.”</p>
<h2 id="heading-why-it-matters">Why it Matters</h2>
<p>I first ran into this while building an app where the UI wouldn’t update after a successful change. The API returned 200 OK, the database was correct, but the screen stayed stale. I assumed something was wrong with my code or state logic. Instead, the issue was coming from a caching layer I hadn’t invalidated. That’s the real problem with stale UI, you can’t debug it effectively unless you know which layer might be serving cached data.</p>
<p>When you understand where caching happens:</p>
<ul>
<li><p>You debug faster by identifying the layer instead of guessing.</p>
</li>
<li><p>You avoid production-only bugs caused by caching defaults.</p>
</li>
<li><p>You stop chasing React issues when the data was never fresh.</p>
</li>
</ul>
<p>This article gives you a simple mental model to pinpoint the layer and fix the right thing.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-it-matters">Why it Matters</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-the-mental-model">The Mental Model</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-non-cache-cause">Non-Cache Cause</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cache-1-react-query-cache">Cache 1: React Query Cache</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cache-2-nextjs-fetch-caching">Cache 2: Next.js fetch() Caching</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cache-3-browser-http-cache-a-saved-copy-in-your-browser">Cache 3: Browser HTTP Cache (a Saved Copy in Your Browser)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cache-4-cdnhosting-cache">Cache 4: CDN/Hosting Cache</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-cache-5-service-worker-cache-only-if-your-site-is-a-pwa">Cache 5: Service Worker Cache (Only if Your Site is a PWA)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-10-second-debug-guide">10-Second Debug Guide</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-prevention-set-caching-intentionally">Prevention: Set Caching Intentionally</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-recap">Recap</a></p>
</li>
</ul>
<h2 id="heading-the-mental-model">The Mental Model</h2>
<p>When your UI shows data, it feels like it comes straight from your API. In reality, the request/response path can hit multiple reuse points.</p>
<h2 id="heading-non-cache-cause">Non-Cache Cause</h2>
<p>Duplicated React local state (same symptoms as caching). This one isn’t a formal cache, but it causes a lot of “why didn’t it update?” bugs especially for beginners.</p>
<h3 id="heading-the-common-trap">The common trap:</h3>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> [name, setName] = useState(user.name) <span class="hljs-comment">// initialized once</span>
</code></pre>
<p><code>useState</code> only uses its argument during the initial render. On every subsequent render, React ignores this value and preserves the existing state.</p>
<p>If <code>user.name</code> later changes (for example, after fresh API data arrives), the <code>name</code> state will not update automatically. At that point, <code>name</code> becomes a stale copy of <code>user.name</code>, and the UI renders outdated data unless you manually synchronize it.</p>
<p>This happens because you have duplicated state:</p>
<ul>
<li><p><code>user.name</code> is the source of truth.</p>
</li>
<li><p><code>name</code> state is a local snapshot taken once.</p>
</li>
</ul>
<p>React does not keep duplicated state in sync for you.</p>
<p>Correct patterns:</p>
<ol>
<li>Render directly from the source when possible.</li>
</ol>
<p>If the value is not being edited locally, do not copy it into state:</p>
<pre><code class="lang-javascript">&lt;span&gt;{user.name}&lt;/span&gt;
</code></pre>
<p>This guarantees the UI always reflects the latest data.</p>
<ol start="2">
<li>Explicitly synchronize local state when editable state is required.</li>
</ol>
<p>If you need local, editable state (for example, a controlled input), you must opt in to synchronization:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> [name, setName] = useState(user.name);  

    useEffect(<span class="hljs-function">() =&gt;</span> {    
        setName(user.name); 
     }, [user.name]);
</code></pre>
<p>This effect runs only when <code>user.name</code> changes, explicitly updating local state to match the new source value.</p>
<h2 id="heading-cache-1-react-query-cache">Cache 1: React Query Cache</h2>
<p>React Query (TanStack Query) stores query results in a QueryClient cache (in memory by default) so your UI can render quickly and avoid unnecessary network requests. When a component needs data, React Query can return cached data immediately and then decide whether to fetch the data again based on options like <code>staleTime</code> and “refetch” behaviors (on mount, window focus, reconnect).</p>
<h3 id="heading-common-failure-mode-mutation-succeeds-but-the-ui-stays-old">Common failure mode: mutation succeeds, but the UI stays old</h3>
<p>A 200 OK only confirms the mutation request succeeded. It does not automatically update the cached query data your UI is rendering.</p>
<p>After a mutation, one of these usually happens:</p>
<ul>
<li><p>The query that renders the screen was not invalidated/fetched</p>
</li>
<li><p>You invalidated the wrong query key (the UI reads from a different key)</p>
</li>
<li><p>The UI is rendering local React state that’s out of sync (not the query result)</p>
</li>
</ul>
<p>The simplest “safe” pattern is: invalidate the exact query key your UI uses, so it fetches fresh data.</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">import</span> { useMutation, useQueryClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"@tanstack/react-query"</span>;

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">useUpdateProfile</span>(<span class="hljs-params">userId: string</span>) </span>{
  <span class="hljs-keyword">const</span> queryClient = useQueryClient();

  <span class="hljs-keyword">return</span> useMutation({
    <span class="hljs-attr">mutationFn</span>: updateProfileRequest,
    <span class="hljs-attr">onSuccess</span>: <span class="hljs-function">() =&gt;</span> {
      <span class="hljs-comment">// Invalidate the same key your UI query uses (example: ["user", userId])</span>
      queryClient.invalidateQueries({ <span class="hljs-attr">queryKey</span>: [<span class="hljs-string">"user"</span>, userId] });
    },
  });
}
</code></pre>
<p>If your UI uses a different key (for example <code>["me"]</code> or <code>["user", userId, "profile"]</code>), you must invalidate that key instead, React Query won’t “figure it out” from the URL.</p>
<h3 id="heading-query-keys-react-query-caches-by-key-not-url">Query Keys: React Query Caches by Key, not URL</h3>
<p>React Query does not cache by endpoint URL. The query key is the identity of the cached data. If two different requests share the same key, React Query treats them as the same data and they can overwrite each other.</p>
<p>You should avoid keys like <code>["user"]</code> (too broad), and use keys like <code>["user", userId]</code> and <code>["users", { page, search, filter }]</code>.</p>
<p><strong>Two settings that control “when it will refetch”:</strong></p>
<ul>
<li><p><strong>staleTime:</strong> how long cached data is treated as fresh. While data is fresh, React Query is less likely to refetch automatically.</p>
</li>
<li><p><strong>gcTime (formerly cacheTime):</strong> how long unused query data stays in memory after it’s no longer used by any component, before it’s garbage collected.</p>
</li>
</ul>
<h2 id="heading-cache-2-nextjs-fetch-caching">Cache 2: Next.js fetch() Caching</h2>
<p>This is the one that surprises a lot of frontend devs. Next.js can cache results to speed things up. That means your server might return a previously saved copy of:</p>
<ul>
<li><p>The API data it fetched, or</p>
</li>
<li><p>The page it already built</p>
</li>
</ul>
<p>This is often the first time frontend developers encounter server-side caching behavior that affects UI correctness. So, even if your database has the new value, you can still see the old one, because Next.js didn’t fetch the API again, or didn’t rebuild the page this time.</p>
<p>This mainly applies to the App Router (Next.js calls these saved copies the Data Cache and Full Route Cache).</p>
<h3 id="heading-what-youll-notice-when-this-happens">What you’ll notice when this happens</h3>
<ul>
<li><p>You refresh the page and it still shows the old value.</p>
</li>
<li><p>Your API is correct (Postman/curl shows the new email), but the UI is stuck.</p>
</li>
<li><p>Sometimes it “fixes itself” after a short wait (because the saved copy refreshes on a timer).</p>
</li>
</ul>
<p>For example: “I updated my profile email, but prod still shows the old one”</p>
<p>The page (reads email on the server):</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// app/settings/page.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">SettingsPage</span>(<span class="hljs-params"></span>) </span>{
 <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.example.com/users/42"</span>, {
  <span class="hljs-attr">method</span>: <span class="hljs-string">"GET"</span>,
})
  <span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> res.json();

  <span class="hljs-keyword">return</span> (
    <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">main</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">h1</span>&gt;</span>Settings<span class="hljs-tag">&lt;/<span class="hljs-name">h1</span>&gt;</span>
      <span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Email: {user.email}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span>
    <span class="hljs-tag">&lt;/<span class="hljs-name">main</span>&gt;</span></span>
  );
}
</code></pre>
<p>You submit an “Update email” form, the API returns <strong>200 OK</strong>, the database is updated, but /settings still shows the previous email in production.</p>
<p>That usually means you’re seeing a saved copy somewhere on the server side.</p>
<h3 id="heading-how-to-debug-it">How to debug it</h3>
<h4 id="heading-step-1-reproduce-in-a-production-like-run">Step 1: Reproduce in a production-like run</h4>
<p>Caching can behave differently in development. Run:</p>
<pre><code class="lang-bash">next build &amp;&amp; next start
</code></pre>
<p>Then test again.</p>
<h4 id="heading-step-2-confirm-whether-the-request-is-reaching-your-nextjs-server-at-all">Step 2: Confirm whether the request is reaching your Next.js server at all</h4>
<p>Add a log inside the page:</p>
<pre><code class="lang-javascript"><span class="hljs-built_in">console</span>.log(<span class="hljs-string">"Rendering /settings at"</span>, <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString());
</code></pre>
<p>Then reload settings twice.</p>
<ul>
<li><p>If you see a new timestamp every reload, the request is reaching your server and the page code is running.</p>
</li>
<li><p>If you don’t see logs in production, your request may not be reaching your server at all (often because a hosting/CDN layer is serving a saved copy before Next.js runs). You’ll confirm that in the CDN section later.</p>
</li>
</ul>
<h4 id="heading-step-3-force-nextjs-to-ask-your-api-every-time">Step 3: Force Next.js to ask your API every time</h4>
<p>Change the fetch to:</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.example.com/me"</span>, {
  <span class="hljs-attr">method</span>: <span class="hljs-string">"GET"</span>,
  <span class="hljs-attr">cache</span>: <span class="hljs-string">"no-store"</span>,
});
</code></pre>
<p>This means: don’t save this response – always fetch it again.</p>
<p>If this fixes the stale email then the problem was a saved copy of the API response (Data Cache).</p>
<h4 id="heading-step-4-if-the-email-is-still-stale-force-nextjs-to-rebuild-the-page-every-request">Step 4: If the email is still stale, force Next.js to rebuild the page every request</h4>
<p>Add this to the page file:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// app/settings/page.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> dynamic = <span class="hljs-string">"force-dynamic"</span>;
</code></pre>
<p>This means: don’t serve a saved copy of the page; rebuild it per request.</p>
<h3 id="heading-a-beginner-safe-setup-for-the-user-settings-pages-with-some-of-the-suggestions">A “beginner-safe” setup for the user settings pages with some of the suggestions:</h3>
<pre><code class="lang-javascript"><span class="hljs-comment">// app/settings/page.tsx</span>
<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> dynamic = <span class="hljs-string">"force-dynamic"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">SettingsPage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.example.com/me"</span>, { <span class="hljs-attr">cache</span>: <span class="hljs-string">"no-store"</span> });
  <span class="hljs-keyword">const</span> me = <span class="hljs-keyword">await</span> res.json();
  <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">p</span>&gt;</span>Email: {me.email}<span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span></span>;
}
</code></pre>
<p>When you want caching for speed, but still need real time updates, these are some options you can take:</p>
<h4 id="heading-option-a-refresh-the-saved-copy-every-n-seconds">Option A: Refresh the saved copy every N seconds</h4>
<p>Good for public pages, not ideal for “my settings must update now.”</p>
<pre><code class="lang-javascript"><span class="hljs-keyword">await</span> fetch(url, { <span class="hljs-attr">next</span>: { <span class="hljs-attr">revalidate</span>: <span class="hljs-number">60</span> } });
</code></pre>
<p>This means: “You can reuse a saved copy, but refresh it at most every 60 seconds.”</p>
<h4 id="heading-option-b-refresh-right-after-the-update-best-for-update-email-flows">Option B: Refresh right after the update (best for “update email” flows)</h4>
<p>If you update the email on the server (Server Action or API route), tell Next.js to throw away the saved copy for /settings page so the next visit is fresh:</p>
<pre><code class="lang-javascript"><span class="hljs-comment">// app/settings/actions.ts</span>
<span class="hljs-string">"use server"</span>;

<span class="hljs-keyword">import</span> { revalidatePath } <span class="hljs-keyword">from</span> <span class="hljs-string">"next/cache"</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">updateEmail</span>(<span class="hljs-params">email: string</span>) </span>{
  <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">"https://api.example.com/me/email"</span>, {
    <span class="hljs-attr">method</span>: <span class="hljs-string">"PUT"</span>,
    <span class="hljs-attr">headers</span>: { <span class="hljs-string">"content-type"</span>: <span class="hljs-string">"application/json"</span> },
    <span class="hljs-attr">body</span>: <span class="hljs-built_in">JSON</span>.stringify({ email }),
  });

  <span class="hljs-comment">// Tell Next.js: next request to /settings should be rebuilt</span>
  revalidatePath(<span class="hljs-string">"/settings"</span>);
}
</code></pre>
<p><strong>Note</strong>: Next.js caching details can differ by version and by App Router vs Pages Router. Instead of trying to memorize defaults, debug by setting the behavior explicitly (no-store, revalidate, force-dynamic) and observe what changes.</p>
<h2 id="heading-cache-3-browser-http-cache-a-saved-copy-in-your-browser">Cache 3: Browser HTTP Cache (a Saved Copy in Your Browser)</h2>
<p>Sometimes the browser reuses a saved copy of an API response (from memory or disk), so it doesn’t fully fetch it again.</p>
<h3 id="heading-what-youll-notice">What you’ll notice</h3>
<p>You open DevTools, and the network shows (from memory cache) or (from disk cache).</p>
<h3 id="heading-fast-check">Fast check</h3>
<p>DevTools → Network</p>
<ul>
<li><p>Turn on Disable cache (only works while DevTools is open)</p>
</li>
<li><p>Reload and retry</p>
</li>
</ul>
<h3 id="heading-why-it-happens">Why it happens</h3>
<p>Usually your server allows caching via headers like Cache-Control or ETag (which can lead to 304 Not Modified).</p>
<h2 id="heading-cache-4-cdnhosting-cache">Cache 4: CDN/Hosting Cache</h2>
<p>This is often a production-only cache, which is why frontend bugs can appear “impossible” to reproduce locally. In production, a CDN/hosting layer can serve a saved copy of a response before your request reaches your server. That’s why “prod is stale, local is fine” happens.</p>
<h3 id="heading-what-youll-notice-1">What you’ll notice</h3>
<ul>
<li><p>Prod is stale, local is fine</p>
</li>
<li><p>Different users see different results (different regions/POPs)</p>
</li>
<li><p>Pages are very fast even right after data changed</p>
</li>
</ul>
<h3 id="heading-fast-check-1">Fast check</h3>
<p>Open DevTools → Network → click the request → Response Headers</p>
<ul>
<li><p>Age: if present and increasing, it’s strong evidence you’re getting a cached response from an intermediary cache</p>
</li>
<li><p>Provider headers can hint HIT/MISS (examples: x-vercel-cache, cf-cache-status)</p>
</li>
<li><p>Source (Age header, HTTP caching): <a target="_blank" href="https://www.rfc-editor.org/rfc/rfc9111">https://www.rfc-editor.org/rfc/rfc9111</a></p>
</li>
</ul>
<h3 id="heading-quick-diagnostic-check">Quick diagnostic check</h3>
<p>Change the URL slightly by adding this to the end of the URL:</p>
<pre><code class="lang-javascript">?debug=<span class="hljs-number">1700000000000</span>
</code></pre>
<p>If the new URL shows fresh data, the edge was likely caching the original URL. This doesn’t fix it for everyone, you’d still need correct cache settings or a purge/invalidation on your CDN.</p>
<h2 id="heading-cache-5-service-worker-cache-only-if-your-site-is-a-pwa">Cache 5: Service Worker Cache (Only if Your Site is a PWA)</h2>
<p>If your site has a service worker, it can return a saved response before the network runs. This can make new deployments or new data seem “ignored.”</p>
<h3 id="heading-what-youll-notice-2">What you’ll notice</h3>
<ul>
<li><p>Works in Incognito but not normal mode</p>
</li>
<li><p>Hard refresh doesn’t help</p>
</li>
<li><p>DevTools “Disable cache” doesn’t fully explain it</p>
</li>
</ul>
<h3 id="heading-fast-check-chrome">Fast check (Chrome)</h3>
<p>Open DevTools → Application → Service Workers</p>
<ul>
<li><p>enable Bypass for network, or Unregister temporarily</p>
</li>
<li><p>reload and retest</p>
</li>
</ul>
<h2 id="heading-10-second-debug-guide">10-Second Debug Guide</h2>
<p>Stale data is rarely random: it usually means a cache layer is doing its job, just not in the way you expect. Modern applications stack multiple caches, so debugging is less about fixing code immediately and more about locating the layer responsible.</p>
<p>Think of this as a quick cheat sheet to figure out which cache layer might be serving stale data, so you can focus your debugging on the right layer.</p>
<ul>
<li><p>No request in Network? Go to <code>Cache 1 (React Query)</code>, then Local state, then <code>Cache 5 (Service worker)</code>.</p>
</li>
<li><p>Request exists, but response is old? Go to <code>Cache 3 (Browser)</code>, <code>Cache 4 (CDN)</code>, then <code>Cache 2 (Next.js)</code>.</p>
</li>
<li><p>Response is fresh, UI is old? Go back to <code>Cache 1 (invalidating / query keys)</code> and Local state.</p>
</li>
</ul>
<p>Once you know the likely layer, use the Fast check in that section to confirm it.</p>
<h2 id="heading-prevention-set-caching-intentionally">Prevention: Set Caching Intentionally</h2>
<p>Most stale-data bugs happen because caching settings were never chosen but the defaults were.</p>
<ul>
<li><p>User-specific pages (settings/admin/dashboard): default to fresh: Next.js: use cache: "no-store" on important fetches, and/or force dynamic routes when needed.</p>
</li>
<li><p>Public pages (marketing/blog/docs): saving + revalidate is usually fine: Decide a revalidate window that matches the business need (seconds/minutes/hours).</p>
</li>
<li><p>React Query: set staleTime based on how often the data actually changes, and make query keys match the inputs.</p>
</li>
<li><p>APIs: set Cache-Control / Vary intentionally so shared caches don’t mix user-specific responses.</p>
</li>
</ul>
<h2 id="heading-recap">Recap</h2>
<p>Caching itself isn’t the problem. Stale UI happens when a cache exists but you didn’t choose it intentionally or align it with the data’s freshness requirements.</p>
<p>If the UI won’t update, it’s usually because you’re seeing a saved copy from React Query, Next.js, the browser, a CDN, or a service worker. And sometimes it’s not a cache at all, it’s local React state</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI Social Media Post Scheduler Using Gemini and Late API in Next.js ]]>
                </title>
                <description>
                    <![CDATA[ Businesses, social media managers, and content creators need to post consistently across multiple platforms to maintain an active online presence and engage their audiences. But scheduling and publish ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-social-media-post-scheduler-using-gemini-and-late-api-in-nextjs/</link>
                <guid isPermaLink="false">697cebbfa59b6f85b8ca1c5f</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ social media ]]>
                    </category>
                
                    <category>
                        <![CDATA[ TypeScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #ai-tools ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ David Asaolu ]]>
                </dc:creator>
                <pubDate>Fri, 30 Jan 2026 17:34:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769794478505/be596e27-4c88-45b3-8547-f715c82e0eda.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Businesses, social media managers, and content creators need to post consistently across multiple platforms to maintain an active online presence and engage their audiences.</p>
<p>But scheduling and publishing posts manually across multiple platforms can be time-consuming and repetitive. An AI-powered social media scheduler can automate this process by generating content, optimising posts, and scheduling them across multiple platforms from a single interface.</p>
<p>In this tutorial, you’ll learn how to build an AI-powered social media post scheduler using an LLM (Claude, Gemini, or an OpenAI model), the Zernio API, and Next.js.</p>
<p>You’ll use the LLM to generate engaging social media content from user prompts, Next.js to build the application’s frontend and backend, and the Zernio API to schedule and publish posts across multiple social media platforms <a href="https://zernio.com/blog/social-media-posting-api">through a single API integration</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-zernio">What is Zernio?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-build-the-nextjs-app-interface">How to Build the Next.js App Interface</a></p>
</li>
<li><p><a href="#heading-how-to-integrate-llms-for-post-generation">How to Integrate LLMs for Post Generation</a></p>
</li>
<li><p><a href="#heading-how-to-schedule-social-media-posts-with-zernio-api">How to Schedule Social Media Posts with Zernio API</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-zernio">What is Zernio?</h2>
<p>Zernio is a unified social media management and messaging API for scheduling and publishing posts, managing DMs and comments, retrieving post analytics, and managing cross-platform ads across 16 social media platforms.</p>
<p>With a single API integration, you can create post queues with defined time slots, manage conversations from a unified social inbox, analyse post and inbox performance, and create and <a href="https://zernio.com/social-media-ads">manage social media ads</a>.</p>
<p>Zernio gives you an API to build on: consistent JSON requests and responses, one auth token, a hosted <a href="https://zernio.com/blog/social-media-api-for-ai-agents-the-complete-integration-guide">MCP server for AI assistants</a>, a unified social inbox, and your content hits Instagram, Slack, TikTok, X (Twitter), LinkedIn, YouTube, and <a href="https://docs.zernio.com/platforms">ten other platforms</a> in a single call.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ed863a66f01b6ea4e9542a9/6cd68b26-202c-40e4-9d0b-dadb20920150.png" alt="6cd68b26-202c-40e4-9d0b-dadb20920150" style="display:block;margin:0 auto" width="2048" height="1098" loading="lazy">

<h3 id="heading-why-choose-zernio-for-social-media-scheduling">Why Choose Zernio for Social Media Scheduling?</h3>
<p>Managing social media scheduling across multiple platforms requires significant development and maintenance effort. Zernio simplifies this by providing a unified platform and API that handles social media publishing across multiple platforms.</p>
<p>Here are some reasons why Zernio stands out:</p>
<ol>
<li><p><strong>Unified API for Cross-Platform Scheduling:</strong> Zernio offers a <a href="https://zernio.com/blog/unified-social-media-api">unified API for scheduling and publishing</a> content across multiple social media platforms. The API handles platform-specific requirements behind the scenes, including media uploads, API rate limits with built-in automatic retries, and a simplified authentication process across multiple platforms.</p>
<p>Zernio API provides built-in scheduling features such as bulk scheduling, content queues, and cross-platform publishing, giving you the flexibility to build a consistent social media scheduling workflow or integrations while Zernio handles the platform compatibility.</p>
</li>
<li><p><strong>AI Agent Support:</strong> Zernio provides a hosted MCP server that allows <a href="https://zernio.com/agents">MCP-compatible AI assistants</a> and agents to interact with your social media workflows. You can connect Zernio to an AI assistant and use natural language to perform supported tasks such as scheduling and managing posts, retrieving analytics, and managing messages and social media ads.</p>
<p>Zernio also supports integration with <a href="https://docs.zernio.com/resources/integrations#automation-platforms">low-code automation tools</a> such as Make, Zapier, and n8n, allowing you to connect social media workflows with other applications and automate repetitive tasks without building custom integrations from scratch.</p>
</li>
<li><p><strong>Zero API Maintenance for SaaS Apps:</strong> Building direct integrations with multiple social media platforms requires ongoing maintenance. Each platform has its own authentication requirements, API endpoints, permissions, publishing rules, rate limits, and API version changes. Zernio abstracts these platform-specific requirements behind its unified API and manages the underlying platform integrations to maintain compatibility with each platform’s API.</p>
</li>
<li><p><strong>Centralised Dashboard:</strong> Zernio’s dashboard enables you to manage social media accounts, schedule posts, monitor analytics, and manage other social media activities from one place.</p>
<p>From the dashboard, you can connect and manage social accounts, organise scheduled content with a content calendar, track post performance, view API request logs and activity, and handle comments and DMs through a unified social inbox.</p>
<p>The dashboard also supports <a href="https://zernio.com/blog/comment-to-dm-automation">built-in DM automation workflows</a>, cross-platform ads management, and webhook configuration. Having these capabilities in one place makes it easier to manage your social media infrastructure without switching between multiple platform dashboards.</p>
</li>
</ol>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/My13tsF6_lA" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-prerequisites">Prerequisites</h2>
<p>To follow this tutorial, you should have a basic understanding of Next.js and React. You’ll also need the following:</p>
<ul>
<li><p><strong>LLM API</strong>: An API key for OpenAI, Claude, or Google Gemini to generate social media content from user prompts.</p>
</li>
<li><p><strong>Zernio API key</strong>: Required to authenticate requests to the Zernio API and schedule and publish posts across connected social media accounts.</p>
</li>
<li><p><strong>Social media account</strong>: At least one supported social media account connected to Zernio for post scheduling and publishing.</p>
</li>
<li><p><strong>Node.js</strong>: Required to install dependencies and run the Next.js application locally.</p>
</li>
</ul>
<h2 id="heading-how-to-build-the-nextjs-app-interface">How to Build the Next.js App Interface</h2>
<p>In this section, you’ll build the user interface for the application. The app uses a single-page route with conditional rendering to display scheduled posts, an AI prompt input, and a form for creating or scheduling posts.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ed863a66f01b6ea4e9542a9/5f279c48-ce89-491d-847d-2fbbe280d13a.gif" alt="5f279c48-ce89-491d-847d-2fbbe280d13a" style="display:block;margin:0 auto" width="800" height="443" loading="lazy">

<h3 id="heading-setup-and-installation">Setup and Installation</h3>
<p>Create a new Next.js project using the following code snippet:</p>
<pre><code class="language-plaintext">npx create-next-app post-scheduler
</code></pre>
<p>Install the project dependencies. We’ll use <a href="https://github.com/iamkun/dayjs">Day.js</a> to work with dates and times when scheduling social media posts. The <code>@google/genai</code> package provides access to the Google Gemini API for generating social media content.</p>
<pre><code class="language-bash">npm install @google/genai dayjs utc
</code></pre>
<p>Next, create a <code>.env.local</code> file in the root of your Next.js project and add your Gemini API key:</p>
<pre><code class="language-bash">GEMINI_API_KEY=&lt;paste_your API key&gt;
</code></pre>
<p>Once everything is set up, let’s start building! 🚀</p>
<h3 id="heading-building-the-app-user-interface">Building the App User Interface</h3>
<p>Before we proceed, create a <code>types.d.ts</code> file within your Next.js project and copy the following code snippet into the file:</p>
<pre><code class="language-typescript">interface Post {
    _id: string;
    content: string;
    scheduledFor: string;
    status: string;
}

interface AIFormProps {
    handleGeneratePost: (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; void;
    useAI: boolean;
    setUseAI: React.Dispatch&lt;React.SetStateAction&lt;boolean&gt;&gt;;
    prompt: string;
    setPrompt: React.Dispatch&lt;React.SetStateAction&lt;string&gt;&gt;;
    disableBtn: boolean;
}

interface FormProps {
    handlePostSubmit: (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; void;
    content: string;
    setContent: React.Dispatch&lt;React.SetStateAction&lt;string&gt;&gt;;
    date: string;
    setDate: React.Dispatch&lt;React.SetStateAction&lt;string&gt;&gt;;
    disableBtn: boolean;
    setUseAI: React.Dispatch&lt;React.SetStateAction&lt;boolean&gt;&gt;;
    useAI: boolean;
}
</code></pre>
<p>The <code>types.d.ts</code> file defines all the data structures and type declarations used throughout the application.</p>
<p>Copy the following code snippet into the <code>app/page.tsx</code> file:</p>
<pre><code class="language-typescript">"use client";
import Nav from "./components/Nav";
import { useState } from "react";
import NewPost from "./components/NewPost";
import PostsQueue from "./components/PostsQueue";

export default function Page() {
    const [showPostQueue, setShowPostQueue] = useState&lt;boolean&gt;(false);
    return (
        &lt;div className='w-full h-screen'&gt;
            &lt;Nav showPostQueue={showPostQueue} setShowPostQueue={setShowPostQueue} /&gt;
            {showPostQueue ? &lt;PostsQueue /&gt; : &lt;NewPost /&gt;}
        &lt;/div&gt;
    );
}
</code></pre>
<p>The <strong>Page</strong> component renders the <strong>Nav</strong> component and uses conditional rendering to display either the <strong>PostsQueue</strong> or <strong>NewPost</strong> component based on the value of the <code>showPostQueue</code> state.</p>
<p>Create a <strong>components</strong> folder to store the page components used in the application.</p>
<pre><code class="language-bash">cd app
mkdir components &amp;&amp; cd components
touch Nav.tsx NewPost.tsx PostElement.tsx PostsQueue.tsx
</code></pre>
<p>Add the code snippet below to the <code>Nav.tsx</code> file:</p>
<pre><code class="language-typescript">export default function Nav({
    showPostQueue,
    setShowPostQueue,
}: {
    showPostQueue: boolean;
    setShowPostQueue: React.Dispatch&lt;React.SetStateAction&lt;boolean&gt;&gt;;
}) {
    return (
        &lt;nav&gt;
            &lt;h2&gt;Post Scheduler&lt;/h2&gt;

            &lt;button onClick={() =&gt; setShowPostQueue(!showPostQueue)}&gt;
                {showPostQueue ? "New Post" : "Schedule Queue"}
            &lt;/button&gt;
        &lt;/nav&gt;
    );
}
</code></pre>
<p>Copy the following code snippet into the <code>PostsQueue.tsx</code> file:</p>
<pre><code class="language-typescript">"use client";
import { useEffect, useState, useCallback } from "react";
import PostElement from "./PostElement";

export default function PostsQueue() {
    const [posts, setPosts] = useState&lt;Post[]&gt;([]);
    const [loading, setLoading] = useState&lt;boolean&gt;(true);

    return (
        &lt;div className='p-4'&gt;
            &lt;h2 className='text-xl font-bold'&gt;Scheduled Posts&lt;/h2&gt;

            {loading ? (
                &lt;p className='text-sm'&gt;Loading scheduled posts...&lt;/p&gt;
            ) : (
                &lt;div className='mt-4'&gt;
                    {posts.length &gt; 0 ? (
                        posts.map((post) =&gt; &lt;PostElement key={post._id} post={post} /&gt;)
                    ) : (
                        &lt;p&gt;No scheduled posts available.&lt;/p&gt;
                    )}
                &lt;/div&gt;
            )}
        &lt;/div&gt;
    );
}
</code></pre>
<p>The <code>PostsQueue.tsx</code> component displays previously created posts and their current status, indicating whether each post has been published or scheduled for later. While the posts are loading, a loading message is displayed. Once the data is available, it renders each post using the <code>PostElement</code> component.</p>
<p>Add the following to the <code>PostElement.tsx</code> component:</p>
<pre><code class="language-typescript">export default function PostElement({ post }: { post: Post }) {
    export const formatReadableTime = (isoString: string) =&gt; {
        const date = new Date(isoString); // parses UTC automatically
        return date.toLocaleString(undefined, {
            year: "numeric",
            month: "short",
            day: "numeric",
            hour: "2-digit",
            minute: "2-digit",
            second: "2-digit",
            hour12: true, // set to false for 24h format
        });
    };

    return (
        &lt;div className='p-4 border flex items-center justify-between  space-x-4 rounded mb-2 hover:bg-gray-100 cursor-pointer'&gt;
            &lt;div&gt;
                &lt;p className='font-semibold text-sm'&gt;{post.content.slice(0, 100)}&lt;/p&gt;
                &lt;p className='text-blue-400 text-xs'&gt;
                    Scheduled for: {formatReadableTime(post.scheduledFor)}
                &lt;/p&gt;
            &lt;/div&gt;

            &lt;p className='text-sm text-red-500'&gt;{post.status}&lt;/p&gt;
        &lt;/div&gt;
    );
}
</code></pre>
<p>Finally, copy the following code snippet into the <code>NewPost.tsx</code> file:</p>
<pre><code class="language-typescript">"use client";
import { useState } from "react";

export default function NewPost() {
 const [disableBtn, setDisableBtn] = useState&lt;boolean&gt;(false);
 const [useAI, setUseAI] = useState&lt;boolean&gt;(false);
 const [content, setContent] = useState&lt;string&gt;("");
 const [prompt, setPrompt] = useState&lt;string&gt;("");
 const [date, setDate] = useState&lt;string&gt;("");

 //👇🏻 generates post content
 const handleGeneratePost = async (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; {
  e.preventDefault();
  setDisableBtn(true);
 };

 //👇🏻 create/schedule post
 const handlePostSubmit = async (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; {
  e.preventDefault();
 };

 return (
  &lt;div className='w-full p-4  h-[90vh] flex flex-col items-center justify-center border-t'&gt;
   &lt;h3 className='text-xl font-bold'&gt;New Post&lt;/h3&gt;

   {useAI ? (
    &lt;AIPromptForm
     handleGeneratePost={handleGeneratePost}
     useAI={useAI}
     setUseAI={setUseAI}
     prompt={prompt}
     setPrompt={setPrompt}
     disableBtn={disableBtn}
    /&gt;
   ) : (
    &lt;PostForm
     handlePostSubmit={handlePostSubmit}
     content={content}
     setContent={setContent}
     date={date}
     setDate={setDate}
     disableBtn={disableBtn}
     setUseAI={setUseAI}
     useAI={useAI}
    /&gt;
   )}
  &lt;/div&gt;
 );
}
</code></pre>
<p>The <strong>NewPost</strong> component conditionally renders the <strong>AIPromptForm</strong> and the <strong>PostForm</strong>. When a user chooses to generate content using AI, the AIPromptForm component is displayed to collect the prompt. Once the content is generated, the PostForm component is shown, allowing the user to edit, create, or schedule the post.</p>
<p>Add the components below inside the <code>NewPost.tsx</code> file:</p>
<pre><code class="language-typescript">export const AIPromptForm = ({
    handleGeneratePost,
    useAI,
    setUseAI,
    prompt,
    setPrompt,
    disableBtn,
}: AIFormProps) =&gt; {
    return (
        &lt;form onSubmit={handleGeneratePost}&gt;
            &lt;p onClick={() =&gt; setUseAI(!useAI)}&gt;Exit AI &lt;/p&gt;
            &lt;textarea
                rows={3}
                required
                value={prompt}
                onChange={(e) =&gt; setPrompt(e.target.value)}
                placeholder='Enter prompt...'
            /&gt;
            &lt;button type='submit' disabled={disableBtn}&gt;
                {disableBtn ? "Generating..." : "Generate Post with AI"}
            &lt;/button&gt;
        &lt;/form&gt;
    );
};

// 👇🏻 Post Form component
export const PostForm = ({
    handlePostSubmit,
    content,
    setContent,
    date,
    setDate,
    disableBtn,
    setUseAI,
    useAI,
}: FormProps) =&gt; {
    const getNowForDatetimeLocal = () =&gt; {
        const now = new Date();
        return new Date(now.getTime() - now.getTimezoneOffset() * 60000)
            .toISOString()
            .slice(0, 16);
    };

    return (
        &lt;form onSubmit={handlePostSubmit}&gt;
            &lt;p onClick={() =&gt; setUseAI(!useAI)}&gt;Generate posts with AI &lt;/p&gt;
            &lt;textarea
                value={content}
                onChange={(e) =&gt; setContent(e.target.value)}
                rows={4}
                placeholder="What's happening?"
                required
                maxLength={280}
            /&gt;
            &lt;input
                type='datetime-local'
                min={getNowForDatetimeLocal()}
                value={date}
                onChange={(e) =&gt; setDate(e.target.value)}
            /&gt;
            &lt;button disabled={disableBtn} type='submit'&gt;
                {disableBtn ? "Posting..." : "Create post"}
            &lt;/button&gt;
        &lt;/form&gt;
    );
};
</code></pre>
<p>Congratulations! You've completed the application interface.</p>
<h2 id="heading-how-to-integrate-llms-for-post-generation">How to Integrate LLMs for Post Generation</h2>
<p>LLMs such as Claude, OpenAI models, and Google Gemini can perform a wide range of language tasks, including text generation and completion, summarisation, rewriting, translation, classification, and content optimisation.</p>
<p>Here, you'll learn how to generate post content from the user's prompt using the Gemini API.</p>
<p>Before we proceed, make sure you've copied your API key from <a href="https://ai.google.dev/gemini-api/docs/api-key">Google AI Studio</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ed863a66f01b6ea4e9542a9/b40d4793-0046-45fe-8df1-32492ba1d635.png" alt="Getting your API key from Google AI Studio" style="display:block;margin:0 auto" width="2048" height="1129" loading="lazy">

<p>Create an <code>api</code> folder inside the Next.js <code>app</code> directory. This folder will contain the API routes for generating social media content and creating or scheduling posts using the Zernio API.</p>
<pre><code class="language-bash">cd app &amp;&amp; mkdir api
</code></pre>
<p>Next, create a <code>generate</code> folder inside the <strong>api</strong> directory and add a <code>route.ts</code> file. Copy the following code into the file:</p>
<pre><code class="language-typescript">// 👇🏻 In api/generate/route.ts file
import { NextRequest, NextResponse } from "next/server";
import { GoogleGenAI } from "@google/genai";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

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

    try {
        const response = await ai.models.generateContent({
            model: "gemini-3-flash-preview",
            contents: `
    You are a social media post generator, very efficient in generating engaging posts for Twitter (X). Given a topic, generate a creative and engaging post that captures attention and encourages interaction. This posts will always be within the character limit of X (Twitter) which is 280 characters, which includes any hashtags or mentions, spaces, punctuation, and emojis.

    The user will provide a topic or theme, and you will generate a post based on that input.
    Here is the instruction from the user:
    "${prompt}"`,
        });
        if (!response.text) {
            return NextResponse.json(
                {
                    message: "Encountered an error generating the post.",
                    success: false,
                },
                { status: 400 },
            );
        }

        return NextResponse.json(
            { message: response.text, success: true },
            { status: 200 },
        );
    } catch (error) {
        return NextResponse.json(
            { message: "Error generating post.", success: false },
            { status: 500 },
        );
    }
}
</code></pre>
<p>The <code>api/generate</code> endpoint accepts the user's prompt and generates post content using the <a href="https://ai.google.dev/gemini-api/docs/quickstart#javascript_1">Gemini API.</a></p>
<p>Now you can send a request to the newly created <code>/api/generate</code> endpoint from the <strong>NewPost</strong> component. Update the <strong>handleGeneratePost</strong> function as shown below:</p>
<pre><code class="language-typescript">const handleGeneratePost = async (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; {
    e.preventDefault();
    setDisableBtn(true);
    const result = await fetch("/api/generate", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({ prompt }),
    });

    const data = await result.json();
    if (data.success) {
        setUseAI(false);
        setContent(data.message);
        setPrompt("");
    }
    setDisableBtn(false);
};
</code></pre>
<p>The <code>handleGeneratePost</code> function accepts the user's prompt and returns the AI-generated content.</p>
<h2 id="heading-how-to-schedule-social-media-posts-with-zernio-api">How to Schedule Social Media Posts with Zernio API</h2>
<p>Zernio lets you schedule and publish posts through its dashboard, <a href="https://zernio.com/claude">automatically using AI agents</a>, and programmatically via its API. It also offers multiple <a href="https://docs.zernio.com/sdks">official SDKs</a> for Node.js, Python, Go, Ruby, Java, PHP, .NET, and Rust, allowing you to integrate the API into SaaS apps and software applications using your preferred programming language.</p>
<p>In this section, you’ll learn how to schedule and publish content across multiple social media platforms using the Zernio API.</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/Y7hUkXCX8xw" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h3 id="heading-step-1-sign-in-to-zernio-and-connect-your-social-media-accounts">Step 1: Sign in to Zernio and connect your social media accounts</h3>
<p><a href="https://zernio.com/signup">Log in to your Zernio account</a> and select <strong>Connections</strong> from the sidebar to connect your social media accounts. You can also connect multiple accounts for the same social media platform by creating additional profiles.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ed863a66f01b6ea4e9542a9/b41e328f-6684-47be-8e2c-a3e241e67580.png" alt="Connect social accounts in Zernio" style="display:block;margin:0 auto" width="2048" height="1091" loading="lazy">

<h3 id="heading-step-2-get-your-api-key-and-social-media-account-ids">Step 2: Get your API key and social media account IDs</h3>
<p>Select <strong>API Keys</strong> from the sidebar menu in your Zernio dashboard, create a new API key, and store it in your project’s <code>.env.local</code> file.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ed863a66f01b6ea4e9542a9/0a5897f5-88a2-4d77-8e94-1792f6665838.png" alt="Create API keys in Zernio dashboard" style="display:block;margin:0 auto" width="2048" height="1132" loading="lazy">

<p>Next, return to the <strong>Connections</strong> page and locate the social media account you connected earlier. Click the <strong>Copy</strong> icon to copy its account ID and save it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5ed863a66f01b6ea4e9542a9/783615ee-dd09-4a33-924a-c1ddac9390b1.png" alt="Copy account ID" style="display:block;margin:0 auto" width="2048" height="1137" loading="lazy">

<p>Add both the Zernio API key and account ID to your <code>.env.local</code> file as environment variables.</p>
<pre><code class="language-bash">GEMINI_API_KEY=AI******** 
ZERNIO_API_KEY=sk_******* 
TWITTER_ACCOUNT_ID= 
LINKEDIN_ACCOUNT_ID=
</code></pre>
<h3 id="heading-step-3-schedule-and-publish-posts-using-the-zernio-api">Step 3: Schedule and Publish Posts using the Zernio API</h3>
<p>Create an <code>api/post</code> endpoint to accept post content and schedule or publish posts using the Zernio API.</p>
<pre><code class="language-bash">cd api
mkdir post &amp;&amp; cd post
touch route.ts
</code></pre>
<p>Then, add the following POST method to <strong>post/route.ts</strong>:</p>
<pre><code class="language-typescript">import { NextRequest, NextResponse } from "next/server";
import utc from "dayjs/plugin/utc";
import dayjs from "dayjs";

dayjs.extend(utc);

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

    // Determine if the post should be scheduled or published immediately
    const nowUTC = publishAt ? dayjs(publishAt).utc() : null;
    const publishAtUTC = nowUTC ? nowUTC.format("YYYY-MM-DDTHH:mm") : null;

    try {
        const response = await fetch("https://zernio.com/api/v1/posts", {
            method: "POST",
            headers: {
                Authorization: `Bearer ${process.env.ZERNIO_API_KEY}`,
                "Content-Type": "application/json",
            },
            body: JSON.stringify({
                content,
                platforms: [
                    {
                       platform: "twitter",
                       accountId: process.env.TWITTER_ACCOUNT_ID!,
                    },
                    {
                      platform: "linkedin",
                      accountId: process.env.LINKEDIN_ACCOUNT_ID!,
                    },
                ],
                publishNow: !publishAt,
                scheduledFor: publishAtUTC,
            }),
        });

        const { post, message } = await response.json();

        if (post?._id) {
            return NextResponse.json({ message, success: true }, { status: 201 });
        }

        return NextResponse.json({ message: "Error occurred", success: false }, { status: 500 });
    } catch (error) {
        return NextResponse.json({ message: "Error scheduling post.", success: false }, { status: 500 });
    }
}
</code></pre>
<p>From the code snippet above:</p>
<ul>
<li><p>The <code>/api/post</code> endpoint accepts the post <strong>content</strong> and an optional <strong>publishAt</strong> timestamp from the request body.</p>
</li>
<li><p>If <strong>publishAt</strong> is not provided, publishNow is set to true, and the post is published immediately. If a time is provided, Day.js converts it to UTC and formats it for scheduling.</p>
</li>
<li><p>The endpoint then sends the post content, publishing options, and connected social account IDs to the Zernio API using the <code>ZERNIO_API_KEY</code> stored in your environment variables.</p>
</li>
<li><p>In this example, the post is configured for the connected <strong>X (Twitter)</strong> and <strong>LinkedIn</strong> accounts. You can add or remove platforms by updating the platforms array and providing the corresponding account IDs.</p>
</li>
<li><p>If Zernio successfully creates the post, the endpoint returns a 201 response with a success message. Otherwise, it returns an error response.</p>
</li>
</ul>
<p>You can also add a <strong>GET</strong> method to the <code>/api/post</code> endpoint to retrieve posts that have already been created or scheduled:</p>
<pre><code class="language-typescript">export async function GET() {
    try {
        const response = await fetch(
            "https://zernio.com/api/v1/posts",
            {
                method: "GET",
                headers: {
                    Authorization: `Bearer ${process.env.ZERNIO_API_KEY}`,
                    "Content-Type": "application/json",
                },
            },
        );

        const { posts } = await response.json();

        return NextResponse.json({ posts }, { status: 200 });
    } catch (error) {
        return NextResponse.json(
            { message: "Error fetching posts.", success: false },
            { status: 500 },
        );
    }
}
</code></pre>
<p>Next, update the <strong>handlePostSubmit</strong> function in NewPost.tsx to send a POST request to <code>/api/post</code>. This will create or schedule the post and notify the user of the result:</p>
<pre><code class="language-typescript">const handlePostSubmit = async (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; {
    e.preventDefault();
    setDisableBtn(true);

    const now = new Date();
    const selected = date ? new Date(date) : null;
    const publishAt = !selected || selected &lt;= now ? null : date;

    const result = await fetch("/api/post", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ content, publishAt }),
    });

    const { message, success } = await result.json();

    if (success) {
        setContent("");
        setDate("");
        alert("Success: " + message);
    } else {
        alert("Error: " + message);
    }

    setDisableBtn(false);
};
</code></pre>
<p>Finally, fetch all scheduled or published posts and render them in the PostsQueue component:</p>
<pre><code class="language-typescript">const fetchScheduledPosts = useCallback(async () =&gt; {
    try {
        const response = await fetch("/api/post", {
            method: "GET",
            headers: { "Content-Type": "application/json" },
        });
        const data = await response.json();
        setPosts(data.posts);
        setLoading(false);
    } catch (error) {
        console.error("Error fetching scheduled posts:", error);
        setLoading(false);
    }
}, []);

useEffect(() =&gt; {
    fetchScheduledPosts();
}, [fetchScheduledPosts]);
</code></pre>
<p>Congratulations! You’ve successfully built an AI-powered social media post scheduler using Next.js, Gemini API, and Zernio API.</p>
<p>The source code for this tutorial is available on <a href="https://github.com/dha-stix/ai-post-scheduler">GitHub</a>.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>In this tutorial, you’ve learnt how to build an AI-powered social media post scheduler with Next.js, using the Gemini API to generate content and the Zernio API to schedule and publish posts across multiple social media platforms.</p>
<p>By combining Zernio with generative AI models such as Gemini, AI assistants and agents such as Claude and OpenClaw, or automation tools <a href="https://zernio.com/blog/n-8-n-post-social-media">such as n8n and Zapier</a>, you can build automated workflows that keep your audience engaged with minimal manual effort.</p>
<p>The <a href="https://ai.google.dev/gemini-api/docs/quickstart">Gemini API</a> also makes it easy to integrate AI-powered text, image, or code generation directly into your applications, opening up a wide range of creative possibilities.</p>
<p>Thank you for reading! 🎉</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an AI-Powered RAG Search Application with Next.js, Supabase, and OpenAI ]]>
                </title>
                <description>
                    <![CDATA[ In this tutorial, you'll learn how to build a complete RAG (Retrieval-Augmented Generation) search application from scratch. Your application will allow users to upload documents, store them securely, and search through them using AI-powered semantic... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-ai-powered-rag-search-application-with-nextjs-supabase-and-openai/</link>
                <guid isPermaLink="false">6978f421ead51482f82901bf</guid>
                
                    <category>
                        <![CDATA[ RAG  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ supabase ]]>
                    </category>
                
                    <category>
                        <![CDATA[ openai ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Mayur Vekariya ]]>
                </dc:creator>
                <pubDate>Tue, 27 Jan 2026 17:21:37 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769534479648/a3f19714-a00b-4444-9289-753902282ac6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this tutorial, you'll learn how to build a complete RAG (Retrieval-Augmented Generation) search application from scratch. Your application will allow users to upload documents, store them securely, and search through them using AI-powered semantic search.</p>
<p>By the end of this guide, you'll have a fully functional application that can:</p>
<ul>
<li><p>Upload and process PDF, DOCX, and TXT files</p>
</li>
<li><p>Store documents in Supabase Storage</p>
</li>
<li><p>Generate embeddings using OpenAI</p>
</li>
<li><p>Perform semantic search across document chunks</p>
</li>
<li><p>Provide AI-generated answers based on document content</p>
</li>
<li><p>View and manage uploaded documents</p>
</li>
</ul>
<p>This is a production-ready solution that you can deploy and use immediately.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-youll-learn">What You'll Learn</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-understanding-the-technologies">Understanding the Technologies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-project-overview">Project Overview</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-1-create-your-nextjs-project">Step 1: Create Your Next.js Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-install-required-dependencies">Step 2: Install Required Dependencies</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-set-up-your-supabase-project">Step 3: Set Up Your Supabase Project</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-configure-environment-variables">Step 4: Configure Environment Variables</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-create-the-upload-api-route">Step 5: Create the Upload API Route</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-6-create-the-rag-search-api-route">Step 6: Create the RAG Search API Route</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-7-create-the-documents-api-route">Step 7: Create the Documents API Route</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-8-create-the-upload-modal-component">Step 8: Create the Upload Modal Component</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-9-create-the-pdf-viewer-modal-component">Step 9: Create the PDF Viewer Modal Component</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-10-create-the-navigation-component">Step 10: Create the Navigation Component</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-11-create-the-home-page-search-interface">Step 11: Create the Home Page (Search Interface)</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-12-create-the-documents-page">Step 12: Create the Documents Page</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-13-test-your-application">Step 13: Test Your Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-14-deploy-your-application">Step 14: Deploy Your Application</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-rag-search-works">How RAG Search Works</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-troubleshooting-common-issues">Troubleshooting Common Issues</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-next-steps">Next Steps</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn"><strong>What You'll Learn</strong></h2>
<p>In this handbook, you'll learn how to:</p>
<ul>
<li><p>Set up a Next.js application with TypeScript</p>
</li>
<li><p>Configure Supabase for database and file storage</p>
</li>
<li><p>Integrate OpenAI embeddings and chat completions</p>
</li>
<li><p>Implement document text extraction and chunking</p>
</li>
<li><p>Build a vector search system using PostgreSQL</p>
</li>
<li><p>Create a modern UI with React components</p>
</li>
<li><p>Handle file uploads and storage</p>
</li>
<li><p>Implement RAG (Retrieval-Augmented Generation) search</p>
</li>
</ul>
<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>Before you begin, make sure you have:</p>
<ul>
<li><p>Node.js 18 or higher installed on your computer</p>
</li>
<li><p>A Supabase account (free tier works fine)</p>
</li>
<li><p>An OpenAI API key</p>
</li>
<li><p>Basic knowledge of React and TypeScript</p>
</li>
<li><p>Familiarity with Next.js (helpful but not required)</p>
</li>
</ul>
<h2 id="heading-understanding-the-technologies"><strong>Understanding the Technologies</strong></h2>
<p>Before we dive into building the application, you should understand the key technologies and concepts you'll be working with:</p>
<h3 id="heading-what-is-rag-retrieval-augmented-generation"><strong>What is RAG (Retrieval-Augmented Generation)?</strong></h3>
<p>RAG is an AI pattern that combines information retrieval with text generation. Instead of relying solely on an AI model's training data, RAG retrieves relevant information from your own documents. It then uses that information as context to generate accurate, up-to-date answers. This approach gives you:</p>
<ul>
<li><p><strong>Accuracy</strong>: Answers are based on your actual documents, not just the AI's training data</p>
</li>
<li><p><strong>Transparency</strong>: You can see which document sections were used to generate the answer</p>
</li>
<li><p><strong>Efficiency</strong>: Only relevant document chunks are used, reducing token costs</p>
</li>
</ul>
<h3 id="heading-what-are-embeddings-and-vector-database"><strong>What are Embeddings and Vector Database?</strong></h3>
<p>Embeddings are numerical representations of text that capture semantic meaning. When you convert text to an embedding, similar meanings are represented by similar numbers. For example, "dog" and "puppy" would have similar embeddings. Meanwhile, "dog" and "airplane" would have very different ones.</p>
<p>OpenAI's embedding models convert text into vectors. These are arrays of numbers that can be compared mathematically. This allows you to find documents that are semantically similar to a search query. You can find matches even if they don't contain the exact same words.</p>
<p>A vector database is a specialized database designed to store and search through embeddings efficiently. Instead of searching for exact text matches, vector databases use mathematical operations. They use operations like <a target="_blank" href="https://www.freecodecamp.org/news/how-does-cosine-similarity-work/">cosine similarity</a> to find the most semantically similar content.</p>
<p>In this tutorial, you'll use Supabase's PostgreSQL database with the <code>pgvector</code> extension. This extension adds vector storage and similarity search capabilities to PostgreSQL. This lets you store embeddings alongside your regular database data. You can also perform fast similarity searches.</p>
<h3 id="heading-what-is-text-chunking"><strong>What is Text Chunking?</strong></h3>
<p>Text chunking is the process of breaking large documents into smaller, manageable pieces. This is necessary for several reasons.</p>
<p>First, AI models have token limits. These are maximum input sizes. Second, smaller chunks allow for more precise retrieval. Third, overlapping chunks ensure context isn't lost at boundaries.</p>
<p>You'll use LangChain's <code>RecursiveCharacterTextSplitter</code>. This tool intelligently splits text while trying to preserve sentence and paragraph boundaries.</p>
<h3 id="heading-what-is-supabase"><strong>What is Supabase?</strong></h3>
<p>Supabase is an open-source Firebase alternative. It provides several key features.</p>
<p>You get a PostgreSQL database, which is a powerful, open-source relational database. You also get storage, which is file storage similar to AWS S3. There are real-time features that provide real-time subscriptions to database changes. Finally, there's built-in user authentication.</p>
<p>For this project, you'll use Supabase's database to store document chunks and embeddings. You'll also use Supabase Storage to store the original uploaded files.</p>
<h3 id="heading-what-is-tailwind-css"><strong>What is Tailwind CSS?</strong></h3>
<p>Tailwind CSS is a utility-first CSS framework that lets you style your application by applying pre-built utility classes directly in your HTML/JSX. Instead of writing custom CSS, you use classes like <code>bg-blue-600</code>, <code>text-white</code>, and <code>rounded-lg</code> to style elements.</p>
<p>You'll use Tailwind CSS in this project because it speeds up development by providing ready-made styling utilities. It also ensures consistent design across the application. Plus, it makes it easy to create responsive, modern UIs. Finally, it works seamlessly with Next.js.</p>
<p>Now that you understand the core concepts and tools we’ll be using, let's start building the application.</p>
<h2 id="heading-project-overview"><strong>Project Overview</strong></h2>
<p>Your RAG search application will consist of:</p>
<ol>
<li><p><strong>Frontend</strong>: Next.js application with React components for uploading documents and searching</p>
</li>
<li><p><strong>Backend API Routes</strong>: Next.js API routes for handling uploads, searches, and document management</p>
</li>
<li><p><strong>Database</strong>: Supabase PostgreSQL with vector extension for storing embeddings</p>
</li>
<li><p><strong>Storage</strong>: Supabase Storage for storing original files</p>
</li>
<li><p><strong>AI Integration</strong>: OpenAI for generating embeddings and chat completions</p>
</li>
</ol>
<p>The application will have two main pages:</p>
<ul>
<li><p><strong>Search Page</strong>: Where users can ask questions about their uploaded documents and get AI-generated answers</p>
</li>
<li><p><strong>Documents Page</strong>: Where users can view all uploaded documents, upload new ones, preview files, and manage their document library</p>
</li>
</ul>
<p>Let's start building!</p>
<p>If you ever get stuck on the source code, you can view it on GitHub here:</p>
<p><a target="_blank" href="https://github.com/mayur9210/rag-search-app">https://github.com/mayur9210/rag-search-app</a></p>
<h2 id="heading-step-1-create-your-nextjs-project">Step 1: Create Your Next.js Project</h2>
<p>Start by creating a new Next.js project with TypeScript. Open your terminal and run:</p>
<pre><code class="lang-bash">npx create-next-app@latest rag-search-app --typescript --tailwind --app
</code></pre>
<p>When prompted, choose the following options:</p>
<ul>
<li><p>TypeScript: Yes</p>
</li>
<li><p>ESLint: Yes</p>
</li>
<li><p>Tailwind CSS: Yes</p>
</li>
<li><p>App Router: Yes (default)</p>
</li>
<li><p>Customize import alias: No</p>
</li>
</ul>
<p>Navigate into your project directory:</p>
<pre><code class="lang-bash"><span class="hljs-built_in">cd</span> rag-search-app
</code></pre>
<p>Now that your project is set up, you'll need to install the additional packages required for document processing, AI integration, and database operations.</p>
<h2 id="heading-step-2-install-required-dependencies">Step 2: Install Required Dependencies</h2>
<p>You'll need several packages for this project. You can install them using npm:</p>
<pre><code class="lang-bash">npm install @supabase/supabase-js @langchain/openai @langchain/textsplitters langchain openai mammoth pdf2json
</code></pre>
<p>Here's what each package does:</p>
<ul>
<li><p><code>@supabase/supabase-js</code>: Client library for interacting with Supabase (database and storage)</p>
</li>
<li><p><code>@langchain/openai</code>: LangChain integration for OpenAI (helps with text processing)</p>
</li>
<li><p><code>@langchain/textsplitters</code>: Text splitting utilities for chunking documents into smaller pieces</p>
</li>
<li><p><code>langchain</code>: Core LangChain library (provides AI workflow tools)</p>
</li>
<li><p><code>openai</code>: Official OpenAI SDK (for generating embeddings and chat completions)</p>
</li>
<li><p><code>mammoth</code>: Converts DOCX files to plain text</p>
</li>
<li><p><code>pdf2json</code>: Extracts text from PDF files</p>
</li>
</ul>
<p>Install the TypeScript types for pdf2json:</p>
<pre><code class="lang-bash">npm install --save-dev @types/pdf-parse
</code></pre>
<p>With all dependencies installed, you're ready to set up your Supabase project, which will handle your database and file storage needs.</p>
<h2 id="heading-step-3-set-up-your-supabase-project">Step 3: Set Up Your Supabase Project</h2>
<h3 id="heading-create-a-supabase-project">Create a Supabase Project</h3>
<p>First, you’ll need to create a new Supabase project, which you can do by following these steps:</p>
<ol>
<li><p>Go to <a target="_blank" href="https://supabase.com/"><strong>supabase.com</strong></a> and sign in or create an account</p>
</li>
<li><p>Click "New Project"</p>
</li>
<li><p>Fill in your project details:</p>
<ul>
<li><p>Name: <code>rag-search-app</code> (or any name you prefer)</p>
</li>
<li><p>Database Password: Choose a strong password (save this – you'll need it)</p>
</li>
<li><p>Region: Select the region closest to you</p>
</li>
</ul>
</li>
<li><p>Click "Create new project" and wait for it to be ready (this takes a few minutes)</p>
</li>
</ol>
<h3 id="heading-get-your-supabase-credentials">Get Your Supabase Credentials</h3>
<p>Once your project is ready, go to <strong>Settings</strong> and then <strong>API</strong>.</p>
<p>Copy the following values:</p>
<ul>
<li><p><strong>Project URL</strong> (this is your <code>NEXT_PUBLIC_SUPABASE_URL</code>)</p>
</li>
<li><p><strong>anon public key</strong> (this is your <code>NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code>)</p>
</li>
<li><p><strong>service_role key</strong> (this is your <code>SUPABASE_SERVICE_ROLE_KEY</code>)</p>
</li>
</ul>
<p><strong>Important</strong>: Keep your service role key secret. Never expose it in client-side code. It bypasses Row-Level Security (RLS) policies, which is necessary for server-side file uploads but should never be used in browser code.</p>
<h3 id="heading-set-up-the-database-schema"><strong>Set Up the Database Schema</strong></h3>
<p>Now you'll set up the database structure to store your documents and embeddings. Go to <strong>SQL Editor</strong> in your Supabase dashboard and run the following SQL:</p>
<pre><code class="lang-pgsql"><span class="hljs-comment">-- Enable the vector extension for embeddings</span>
<span class="hljs-comment">-- This extension allows PostgreSQL to store and search vector data efficiently</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">EXTENSION</span> <span class="hljs-keyword">IF</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">EXISTS</span> vector;

<span class="hljs-comment">-- Create the documents table</span>
<span class="hljs-comment">-- This table stores document chunks, their metadata, and embeddings</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">TABLE</span> documents (
  id <span class="hljs-type">BIGSERIAL</span> <span class="hljs-keyword">PRIMARY KEY</span>,
  content <span class="hljs-type">TEXT</span> <span class="hljs-keyword">NOT</span> <span class="hljs-keyword">NULL</span>,
  metadata <span class="hljs-type">JSONB</span>,
  embedding vector(<span class="hljs-number">1536</span>)  <span class="hljs-comment">-- OpenAI's text-embedding-3-small produces 1536-dimensional vectors</span>
  file_path <span class="hljs-type">text</span> <span class="hljs-keyword">null</span>,
  file_url <span class="hljs-type">text</span> <span class="hljs-keyword">null</span>,
);

<span class="hljs-comment">-- Create an index on the embedding column for faster similarity search</span>
<span class="hljs-comment">-- The ivfflat index speeds up vector similarity queries</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">INDEX</span> <span class="hljs-keyword">ON</span> documents <span class="hljs-keyword">USING</span> ivfflat (embedding vector_cosine_ops);

<span class="hljs-comment">-- Create a function for matching documents based on similarity</span>
<span class="hljs-comment">-- This function finds the most similar document chunks to a query embedding</span>
<span class="hljs-keyword">CREATE</span> <span class="hljs-keyword">OR REPLACE</span> <span class="hljs-keyword">FUNCTION</span> match_documents(
  query_embedding vector(<span class="hljs-number">1536</span>),
  match_threshold <span class="hljs-type">float</span>,
  match_count <span class="hljs-type">int</span>
)
<span class="hljs-keyword">RETURNS</span> <span class="hljs-keyword">TABLE</span> (
  id <span class="hljs-type">bigint</span>,
  content <span class="hljs-type">text</span>,
  metadata <span class="hljs-type">jsonb</span>,
  similarity <span class="hljs-type">float</span>
)
<span class="hljs-keyword">LANGUAGE</span> plpgsql
<span class="hljs-keyword">AS</span> $$<span class="pgsql">
<span class="hljs-keyword">BEGIN</span>
  <span class="hljs-keyword">RETURN QUERY</span>
  <span class="hljs-keyword">SELECT</span>
    documents.id,
    documents.content,
    documents.metadata,
    <span class="hljs-number">1</span> - (documents.embedding &lt;=&gt; query_embedding) <span class="hljs-keyword">AS</span> similarity
  <span class="hljs-keyword">FROM</span> documents
  <span class="hljs-keyword">WHERE</span> <span class="hljs-number">1</span> - (documents.embedding &lt;=&gt; query_embedding) &gt; match_threshold
  <span class="hljs-keyword">ORDER</span> <span class="hljs-keyword">BY</span> documents.embedding &lt;=&gt; query_embedding
  <span class="hljs-keyword">LIMIT</span> match_count;
<span class="hljs-keyword">END</span>;
$$</span>;
</code></pre>
<p>This SQL does the following:</p>
<ul>
<li><p><strong>Enables the vector extension</strong>: This adds vector storage and similarity search capabilities to PostgreSQL</p>
</li>
<li><p><strong>Creates the documents table</strong>: Stores document chunks, metadata (file name, type, and so on), and their embeddings</p>
</li>
<li><p><strong>Creates an index</strong>: Speeds up similarity searches on the embedding column</p>
</li>
<li><p><strong>Creates a match function</strong>: Finds the most similar document chunks to a query embedding using cosine similarity</p>
</li>
</ul>
<p>The <code>&lt;=&gt;</code> operator calculates cosine distance between vectors. A smaller distance means more similar content.</p>
<h3 id="heading-set-up-supabase-storage"><strong>Set Up Supabase Storage</strong></h3>
<p>You’ll need a storage bucket to store uploaded files. This is separate from the database and holds the original PDF, DOCX, and TXT files.</p>
<p>To set up your storage bucket:</p>
<ol>
<li><p>Go to <strong>Storage</strong> in your Supabase dashboard</p>
</li>
<li><p>Click <strong>New bucket</strong></p>
</li>
<li><p>Name it <code>documents</code></p>
</li>
<li><p>Set it to <strong>Public</strong> (this allows file downloads)</p>
</li>
<li><p>Click <strong>Create bucket</strong></p>
</li>
</ol>
<p>If you prefer a private bucket, you can use the service role key for server-side operations, which bypasses Row-Level Security policies. For this tutorial, a public bucket is simpler and works well.</p>
<p>Now that your Supabase project is configured, you'll set up your environment variables to connect your Next.js application to Supabase and OpenAI.</p>
<h2 id="heading-step-4-configure-environment-variables">Step 4: Configure Environment Variables</h2>
<p>Create a <code>.env.local</code> file in your project root:</p>
<pre><code class="lang-bash">NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
OPENAI_API_KEY=your_openai_api_key
</code></pre>
<p>Replace the placeholder values with your actual credentials:</p>
<ul>
<li><p>Get Supabase values from <strong>Settings</strong> → <strong>API</strong> in your Supabase dashboard</p>
</li>
<li><p>Get your OpenAI API key from <a target="_blank" href="https://platform.openai.com/api-keys"><strong>platform.openai.com/api-keys</strong></a></p>
</li>
</ul>
<p><strong>Security Note</strong>: Never commit <code>.env.local</code> to version control. It's already in <code>.gitignore</code> by default, but double-check to ensure your secrets stay secure.</p>
<p>With your environment configured, you're ready to start building the API routes that will handle file uploads, searches, and document management.</p>
<h2 id="heading-step-5-create-the-upload-api-route">Step 5: Create the Upload API Route</h2>
<p>Now you'll create the API route that handles file uploads. This route will process uploaded files, extract their text, split them into chunks, generate embeddings, and store everything in your database and storage.</p>
<p>Create <code>src/app/api/upload/route.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@supabase/supabase-js'</span>;
<span class="hljs-keyword">import</span> OpenAI <span class="hljs-keyword">from</span> <span class="hljs-string">'openai'</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">import</span> { RecursiveCharacterTextSplitter } <span class="hljs-keyword">from</span> <span class="hljs-string">'@langchain/textsplitters'</span>;
<span class="hljs-keyword">import</span> mammoth <span class="hljs-keyword">from</span> <span class="hljs-string">'mammoth'</span>;

<span class="hljs-keyword">const</span> url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
<span class="hljs-keyword">const</span> anonKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!;
<span class="hljs-keyword">const</span> serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
<span class="hljs-keyword">const</span> supabaseStorage = createClient(url, serviceKey || anonKey);
<span class="hljs-keyword">const</span> supabase = createClient(url, anonKey);
<span class="hljs-keyword">const</span> openai = <span class="hljs-keyword">new</span> OpenAI();

<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">safeDecodeURIComponent</span>(<span class="hljs-params">str: <span class="hljs-built_in">string</span></span>): <span class="hljs-title">string</span> </span>{
  <span class="hljs-keyword">try</span> { 
    <span class="hljs-keyword">return</span> <span class="hljs-built_in">decodeURIComponent</span>(str); 
  } <span class="hljs-keyword">catch</span> { 
    <span class="hljs-keyword">try</span> { 
      <span class="hljs-keyword">return</span> <span class="hljs-built_in">decodeURIComponent</span>(str.replace(<span class="hljs-regexp">/%/g</span>, <span class="hljs-string">'%25'</span>)); 
    } <span class="hljs-keyword">catch</span> { 
      <span class="hljs-keyword">return</span> str; 
    } 
  }
}

<span class="hljs-keyword">async</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">extractTextFromFile</span>(<span class="hljs-params">file: File</span>): <span class="hljs-title">Promise</span>&lt;<span class="hljs-title">string</span>&gt; </span>{
  <span class="hljs-keyword">const</span> buffer = Buffer.from(<span class="hljs-keyword">await</span> file.arrayBuffer());
  <span class="hljs-keyword">const</span> fileName = file.name.toLowerCase();

  <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">'.pdf'</span>)) {
    <span class="hljs-keyword">const</span> PDFParser = (<span class="hljs-keyword">await</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'pdf2json'</span>)).default;
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Promise</span>(<span class="hljs-function">(<span class="hljs-params">resolve, reject</span>) =&gt;</span> {
      <span class="hljs-keyword">const</span> pdfParser = <span class="hljs-keyword">new</span> (PDFParser <span class="hljs-keyword">as</span> <span class="hljs-built_in">any</span>)(<span class="hljs-literal">null</span>, <span class="hljs-literal">true</span>);
      pdfParser.on(<span class="hljs-string">'pdfParser_dataError'</span>, <span class="hljs-function">(<span class="hljs-params">err: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
        reject(<span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`PDF parsing error: <span class="hljs-subst">${err.parserError}</span>`</span>))
      );
      pdfParser.on(<span class="hljs-string">'pdfParser_dataReady'</span>, <span class="hljs-function">(<span class="hljs-params">pdfData: <span class="hljs-built_in">any</span></span>) =&gt;</span> {
        <span class="hljs-keyword">try</span> {
          <span class="hljs-keyword">let</span> fullText = <span class="hljs-string">''</span>;
          pdfData.Pages?.forEach(<span class="hljs-function">(<span class="hljs-params">page: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
            page.Texts?.forEach(<span class="hljs-function">(<span class="hljs-params">text: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
              text.R?.forEach(<span class="hljs-function">(<span class="hljs-params">r: <span class="hljs-built_in">any</span></span>) =&gt;</span> 
                r.T &amp;&amp; (fullText += safeDecodeURIComponent(r.T) + <span class="hljs-string">' '</span>)
              )
            )
          );
          resolve(fullText.trim());
        } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
          reject(<span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">`Error extracting text: <span class="hljs-subst">${error.message}</span>`</span>));
        }
      });
      pdfParser.parseBuffer(buffer);
    });
  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">'.docx'</span>)) {
    <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> mammoth.extractRawText({ buffer });
    <span class="hljs-keyword">return</span> result.value;
  } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (fileName.endsWith(<span class="hljs-string">'.txt'</span>)) {
    <span class="hljs-keyword">return</span> buffer.toString(<span class="hljs-string">'utf-8'</span>);
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(<span class="hljs-string">'Unsupported file type. Please upload PDF, DOCX, or TXT files.'</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">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> file = (<span class="hljs-keyword">await</span> req.formData()).get(<span class="hljs-string">'file'</span>) <span class="hljs-keyword">as</span> File;
    <span class="hljs-keyword">if</span> (!file) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'No file provided'</span> }, { status: <span class="hljs-number">400</span> });
    }

    <span class="hljs-keyword">const</span> documentId = crypto.randomUUID();
    <span class="hljs-keyword">const</span> uploadDate = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString();
    <span class="hljs-keyword">const</span> filePath = <span class="hljs-string">`<span class="hljs-subst">${documentId}</span>.<span class="hljs-subst">${file.name.split(<span class="hljs-string">'.'</span>).pop() || <span class="hljs-string">'bin'</span>}</span>`</span>;

    <span class="hljs-comment">// Upload file to Supabase Storage</span>
    <span class="hljs-keyword">const</span> fileBuffer = Buffer.from(<span class="hljs-keyword">await</span> file.arrayBuffer());
    <span class="hljs-keyword">const</span> { error: storageError } = <span class="hljs-keyword">await</span> supabaseStorage.storage
      .from(<span class="hljs-string">'documents'</span>)
      .upload(filePath, fileBuffer, {
        contentType: file.type || <span class="hljs-string">'application/octet-stream'</span>,
        upsert: <span class="hljs-literal">false</span>,
      });

    <span class="hljs-keyword">if</span> (storageError) {
      <span class="hljs-keyword">const</span> msg = storageError.message || <span class="hljs-string">'Unknown storage error'</span>;
      <span class="hljs-keyword">if</span> (msg.includes(<span class="hljs-string">'row-level security'</span>) || msg.includes(<span class="hljs-string">'RLS'</span>)) {
        <span class="hljs-keyword">return</span> NextResponse.json({ 
          success: <span class="hljs-literal">false</span>, 
          error: <span class="hljs-string">`Storage RLS error: <span class="hljs-subst">${msg}</span>. Ensure SUPABASE_SERVICE_ROLE_KEY is set.`</span> 
        }, { status: <span class="hljs-number">500</span> });
      }
      <span class="hljs-keyword">return</span> NextResponse.json({ 
        success: <span class="hljs-literal">false</span>, 
        error: <span class="hljs-string">`Failed to store file: <span class="hljs-subst">${msg}</span>`</span> 
      }, { status: <span class="hljs-number">500</span> });
    }

    <span class="hljs-comment">// Get public URL for the file</span>
    <span class="hljs-keyword">const</span> { data: urlData } = supabaseStorage.storage
      .from(<span class="hljs-string">'documents'</span>)
      .getPublicUrl(filePath);

    <span class="hljs-comment">// Extract text from file</span>
    <span class="hljs-keyword">const</span> text = <span class="hljs-keyword">await</span> extractTextFromFile(file);
    <span class="hljs-keyword">if</span> (!text || text.trim().length === <span class="hljs-number">0</span>) {
      <span class="hljs-keyword">return</span> NextResponse.json({ 
        error: <span class="hljs-string">'Could not extract text from file'</span> 
      }, { status: <span class="hljs-number">400</span> });
    }

    <span class="hljs-comment">// Split text into chunks</span>
    <span class="hljs-comment">// Chunk size of 800 characters with 100-character overlap ensures</span>
    <span class="hljs-comment">// we don't lose context at chunk boundaries</span>
    <span class="hljs-keyword">const</span> textSplitter = <span class="hljs-keyword">new</span> RecursiveCharacterTextSplitter({
      chunkSize: <span class="hljs-number">800</span>,
      chunkOverlap: <span class="hljs-number">100</span>,
    });
    <span class="hljs-keyword">const</span> chunks = <span class="hljs-keyword">await</span> textSplitter.splitText(text);

    <span class="hljs-comment">// Process each chunk: generate embedding and store in database</span>
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">let</span> i = <span class="hljs-number">0</span>; i &lt; chunks.length; i++) {
      <span class="hljs-keyword">const</span> chunk = chunks[i];

      <span class="hljs-comment">// Generate embedding using OpenAI</span>
      <span class="hljs-comment">// This converts the text chunk into a 1536-dimensional vector</span>
      <span class="hljs-keyword">const</span> emb = <span class="hljs-keyword">await</span> openai.embeddings.create({
        model: <span class="hljs-string">'text-embedding-3-small'</span>,
        input: chunk,
      });

      <span class="hljs-comment">// Store chunk with embedding in database</span>
      <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase.from(<span class="hljs-string">'documents'</span>).insert({
        content: chunk,
        metadata: { 
          source: file.name,
          document_id: documentId,
          file_name: file.name,
          file_type: file.type || file.name.split(<span class="hljs-string">'.'</span>).pop(),
          file_size: file.size,
          upload_date: uploadDate,
          chunk_index: i,
          total_chunks: chunks.length,
          file_path: filePath,
          file_url: urlData.publicUrl,
        },
        embedding: <span class="hljs-built_in">JSON</span>.stringify(emb.data[<span class="hljs-number">0</span>].embedding),
      });

      <span class="hljs-keyword">if</span> (error) {
        <span class="hljs-keyword">return</span> NextResponse.json({ 
          success: <span class="hljs-literal">false</span>, 
          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>, 
      documentId, 
      fileName: file.name, 
      chunks: chunks.length, 
      textLength: text.length, 
      fileUrl: urlData.publicUrl 
    });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ 
      success: <span class="hljs-literal">false</span>, 
      error: error.message || <span class="hljs-string">'Failed to process file'</span> 
    }, { status: <span class="hljs-number">500</span> });
  }
}
</code></pre>
<p>This route handles the complete upload workflow:</p>
<ol>
<li><p>Receives the file from the client via FormData</p>
</li>
<li><p>Generates a unique document ID using <code>crypto.randomUUID()</code></p>
</li>
<li><p>Uploads the file to Supabase Storage for safekeeping</p>
</li>
<li><p>Extracts text based on file type (PDF, DOCX, or TXT)</p>
</li>
<li><p>Splits the text into chunks of 800 characters with 100-character overlap</p>
</li>
<li><p>Generates embeddings for each chunk using OpenAI's embedding model</p>
</li>
<li><p>Stores each chunk with its embedding and metadata in the database</p>
</li>
</ol>
<p>The overlap between chunks ensures that if a sentence or concept spans a chunk boundary, it won't be lost. Now that you can upload and process documents, let's create the search functionality.</p>
<h2 id="heading-step-6-create-the-rag-search-api-route">Step 6: Create the RAG Search API Route</h2>
<p>This route implements the core RAG functionality: it takes a user's query, finds the most relevant document chunks, and uses them to generate an accurate answer.</p>
<p>Create <code>src/app/api/search/route.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@supabase/supabase-js'</span>;
<span class="hljs-keyword">import</span> OpenAI <span class="hljs-keyword">from</span> <span class="hljs-string">'openai'</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">const</span> supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!
);
<span class="hljs-keyword">const</span> openai = <span class="hljs-keyword">new</span> OpenAI();

<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">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> { query } = <span class="hljs-keyword">await</span> req.json();

    <span class="hljs-comment">// Generate embedding for the user's query</span>
    <span class="hljs-comment">// This converts the search query into the same vector space as document chunks</span>
    <span class="hljs-keyword">const</span> emb = <span class="hljs-keyword">await</span> openai.embeddings.create({ 
      model: <span class="hljs-string">'text-embedding-3-small'</span>, 
      input: query 
    });

    <span class="hljs-comment">// Find similar documents using vector similarity search</span>
    <span class="hljs-comment">// The match_documents function finds the 5 most similar chunks</span>
    <span class="hljs-keyword">const</span> { data: results, error } = <span class="hljs-keyword">await</span> supabase.rpc(<span class="hljs-string">'match_documents'</span>, {
      query_embedding: <span class="hljs-built_in">JSON</span>.stringify(emb.data[<span class="hljs-number">0</span>].embedding),
      match_threshold: <span class="hljs-number">0.0</span>,  <span class="hljs-comment">// Accept any similarity (you can increase this for stricter matching)</span>
      match_count: <span class="hljs-number">5</span>,        <span class="hljs-comment">// Return top 5 most similar chunks</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-comment">// Combine retrieved chunks into context</span>
    <span class="hljs-comment">// These chunks will be used as context for the AI to generate an answer</span>
    <span class="hljs-keyword">const</span> context = results?.map(<span class="hljs-function">(<span class="hljs-params">r: <span class="hljs-built_in">any</span></span>) =&gt;</span> r.content).join(<span class="hljs-string">'\n---\n'</span>) || <span class="hljs-string">''</span>;

    <span class="hljs-comment">// Generate answer using OpenAI with retrieved context</span>
    <span class="hljs-comment">// This is the "Generation" part of RAG</span>
    <span class="hljs-keyword">const</span> completion = <span class="hljs-keyword">await</span> openai.chat.completions.create({
      model: <span class="hljs-string">'gpt-4o-mini'</span>,
      messages: [
        { 
          role: <span class="hljs-string">'system'</span>, 
          content: <span class="hljs-string">'You are a helpful assistant. Use the provided context to answer questions. If the answer is not in the context, say you do not know.'</span> 
        },
        { 
          role: <span class="hljs-string">'user'</span>, 
          content: <span class="hljs-string">`Context: <span class="hljs-subst">${context}</span>\n\nQuestion: <span class="hljs-subst">${query}</span>`</span> 
        }
      ],
    });

    <span class="hljs-keyword">return</span> NextResponse.json({ 
      answer: completion.choices[<span class="hljs-number">0</span>].message.content, 
      sources: results 
    });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
  }
}
</code></pre>
<p>This route implements the RAG pattern. Here's how the complete RAG workflow works:</p>
<ol>
<li><p><strong>Converts the query to an embedding</strong>: The user's question is transformed into the same vector space as your document chunks. This uses the same embedding model (<code>text-embedding-3-small</code>) that processed the documents, ensuring they're in the same "vector space."</p>
</li>
<li><p><strong>Searches for similar chunks</strong>: Uses the <code>match_documents</code> function to find the 5 most semantically similar document chunks. This uses cosine similarity on the embeddings. Cosine similarity measures the angle between vectors - smaller angles mean more similar content, even if the exact words differ.</p>
</li>
<li><p><strong>Uses chunks as context</strong>: The retrieved chunks are passed to GPT-4o-mini as context. These chunks contain the most relevant information from your documents.</p>
</li>
<li><p><strong>Generates an answer</strong>: The AI model generates an answer based on the provided context. The system prompt instructs the AI to only answer based on the provided context, ensuring accuracy and preventing hallucinations.</p>
</li>
<li><p><strong>Returns results</strong>: Both the answer and source chunks are returned so users can verify the information.</p>
</li>
</ol>
<p>This RAG approach gives you several benefits. First, you get accuracy because answers are based on your actual documents, not just the AI's training data. Second, you get transparency because you can see which document chunks were used to generate each answer. Third, you get efficiency because only relevant chunks are used, which reduces token usage and costs. Finally, you get up-to-date information because you can update your knowledge base by uploading new documents without retraining the AI.</p>
<p>Now let's create the API route for managing documents.</p>
<h2 id="heading-step-7-create-the-documents-api-route">Step 7: Create the Documents API Route</h2>
<p>This route handles listing, viewing, downloading, and deleting documents. It serves multiple purposes depending on the query parameters.</p>
<p>Create <code>src/app/api/documents/route.ts</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { createClient } <span class="hljs-keyword">from</span> <span class="hljs-string">'@supabase/supabase-js'</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">const</span> url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
<span class="hljs-keyword">const</span> anonKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY!;
<span class="hljs-keyword">const</span> serviceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || anonKey;
<span class="hljs-keyword">const</span> supabase = createClient(url, anonKey);
<span class="hljs-keyword">const</span> supabaseStorage = createClient(url, serviceKey);

<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">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> reqUrl = <span class="hljs-keyword">new</span> URL(req.url);
    <span class="hljs-keyword">const</span> id = reqUrl.searchParams.get(<span class="hljs-string">'id'</span>);
    <span class="hljs-keyword">const</span> file = reqUrl.searchParams.get(<span class="hljs-string">'file'</span>) === <span class="hljs-string">'true'</span>;
    <span class="hljs-keyword">const</span> view = reqUrl.searchParams.get(<span class="hljs-string">'view'</span>) === <span class="hljs-string">'true'</span>;

    <span class="hljs-comment">// Handle file download/view</span>
    <span class="hljs-keyword">if</span> (id &amp;&amp; file) {
      <span class="hljs-keyword">const</span> { data: documents } = <span class="hljs-keyword">await</span> supabase
        .from(<span class="hljs-string">'documents'</span>)
        .select(<span class="hljs-string">'metadata'</span>)
        .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id)
        .limit(<span class="hljs-number">1</span>);

      <span class="hljs-keyword">if</span> (!documents || documents.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'Document not found'</span> }, { status: <span class="hljs-number">404</span> });
      }

      <span class="hljs-keyword">const</span> meta = documents[<span class="hljs-number">0</span>].metadata;
      <span class="hljs-keyword">const</span> fileName = meta?.file_name || <span class="hljs-string">'document'</span>;
      <span class="hljs-keyword">const</span> fileType = meta?.file_type || <span class="hljs-string">'application/octet-stream'</span>;
      <span class="hljs-keyword">const</span> filePath = meta?.file_path || <span class="hljs-string">`<span class="hljs-subst">${id}</span>.<span class="hljs-subst">${fileName.split(<span class="hljs-string">'.'</span>).pop() || <span class="hljs-string">'pdf'</span>}</span>`</span>;

      <span class="hljs-keyword">const</span> { data: fileData, error: downloadError } = <span class="hljs-keyword">await</span> supabaseStorage.storage
        .from(<span class="hljs-string">'documents'</span>)
        .download(filePath);

      <span class="hljs-keyword">if</span> (downloadError || !fileData) {
        <span class="hljs-keyword">return</span> NextResponse.json({ 
          error: downloadError?.message || <span class="hljs-string">'File not stored'</span> 
        }, { status: <span class="hljs-number">404</span> });
      }

      <span class="hljs-keyword">const</span> buffer = Buffer.from(<span class="hljs-keyword">await</span> fileData.arrayBuffer());
      <span class="hljs-keyword">if</span> (buffer.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'File is empty'</span> }, { status: <span class="hljs-number">500</span> });
      }

      <span class="hljs-keyword">const</span> isPDF = fileType === <span class="hljs-string">'application/pdf'</span> || fileName.toLowerCase().endsWith(<span class="hljs-string">'.pdf'</span>);
      <span class="hljs-keyword">return</span> <span class="hljs-keyword">new</span> NextResponse(<span class="hljs-keyword">new</span> <span class="hljs-built_in">Uint8Array</span>(buffer), {
        headers: {
          <span class="hljs-string">'Content-Type'</span>: fileType,
          <span class="hljs-string">'Content-Disposition'</span>: (view &amp;&amp; isPDF) 
            ? <span class="hljs-string">`inline; filename="<span class="hljs-subst">${fileName}</span>"`</span> 
            : <span class="hljs-string">`attachment; filename="<span class="hljs-subst">${fileName}</span>"`</span>,
          <span class="hljs-string">'Content-Length'</span>: buffer.length.toString(),
          ...(view &amp;&amp; isPDF ? { <span class="hljs-string">'X-Content-Type-Options'</span>: <span class="hljs-string">'nosniff'</span> } : {}),
        },
      });
    }

    <span class="hljs-comment">// Get single document with text content</span>
    <span class="hljs-keyword">if</span> (id) {
      <span class="hljs-keyword">const</span> { data: chunks, error } = <span class="hljs-keyword">await</span> supabase
        .from(<span class="hljs-string">'documents'</span>)
        .select(<span class="hljs-string">'content, metadata'</span>)
        .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id)
        .order(<span class="hljs-string">'metadata-&gt;&gt;chunk_index'</span>, { ascending: <span class="hljs-literal">true</span> });

      <span class="hljs-keyword">if</span> (error || !chunks || chunks.length === <span class="hljs-number">0</span>) {
        <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'Document not found'</span> }, { status: <span class="hljs-number">404</span> });
      }

      <span class="hljs-keyword">const</span> m = chunks[<span class="hljs-number">0</span>].metadata || {};
      <span class="hljs-keyword">return</span> NextResponse.json({
        id,
        file_name: m.file_name || <span class="hljs-string">'Unknown'</span>,
        file_type: m.file_type || <span class="hljs-string">'unknown'</span>,
        file_size: m.file_size || <span class="hljs-number">0</span>,
        upload_date: m.upload_date || <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString(),
        total_chunks: chunks.length,
        fullText: chunks.map(<span class="hljs-function">(<span class="hljs-params">c: <span class="hljs-built_in">any</span></span>) =&gt;</span> c.content).join(<span class="hljs-string">'\n\n'</span>),
        file_url: m.file_url,
        file_path: m.file_path
      });
    }

    <span class="hljs-comment">// List all documents</span>
    <span class="hljs-keyword">const</span> { data: documents, error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'documents'</span>)
      .select(<span class="hljs-string">'metadata'</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-comment">// Deduplicate documents by document_id</span>
    <span class="hljs-comment">// Since each document is split into multiple chunks, we need to group them</span>
    <span class="hljs-keyword">const</span> map = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Map</span>();
    documents?.forEach(<span class="hljs-function">(<span class="hljs-params">doc: <span class="hljs-built_in">any</span></span>) =&gt;</span> {
      <span class="hljs-keyword">const</span> m = doc.metadata;
      <span class="hljs-keyword">if</span> (m?.document_id &amp;&amp; !map.has(m.document_id)) {
        map.set(m.document_id, {
          id: m.document_id,
          file_name: m.file_name || <span class="hljs-string">'Unknown'</span>,
          file_type: m.file_type || <span class="hljs-string">'unknown'</span>,
          file_size: m.file_size || <span class="hljs-number">0</span>,
          upload_date: m.upload_date || <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>().toISOString(),
          total_chunks: m.total_chunks || <span class="hljs-number">0</span>,
          file_url: m.file_url,
          file_path: m.file_path,
        });
      }
    });

    <span class="hljs-keyword">return</span> NextResponse.json({ documents: <span class="hljs-built_in">Array</span>.from(map.values()) });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { 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">req: Request</span>) </span>{
  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> id = <span class="hljs-keyword">new</span> URL(req.url).searchParams.get(<span class="hljs-string">'id'</span>);
    <span class="hljs-keyword">if</span> (!id) {
      <span class="hljs-keyword">return</span> NextResponse.json({ error: <span class="hljs-string">'Document ID required'</span> }, { status: <span class="hljs-number">400</span> });
    }

    <span class="hljs-comment">// Get file path from metadata</span>
    <span class="hljs-keyword">const</span> { data: docs } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'documents'</span>)
      .select(<span class="hljs-string">'metadata'</span>)
      .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id)
      .limit(<span class="hljs-number">1</span>);

    <span class="hljs-keyword">const</span> filePath = docs?.[<span class="hljs-number">0</span>]?.metadata?.file_path;

    <span class="hljs-comment">// Delete file from storage</span>
    <span class="hljs-keyword">if</span> (filePath) {
      <span class="hljs-keyword">await</span> supabaseStorage.storage.from(<span class="hljs-string">'documents'</span>).remove([filePath]);
    }

    <span class="hljs-comment">// Delete all chunks from database</span>
    <span class="hljs-keyword">const</span> { error } = <span class="hljs-keyword">await</span> supabase
      .from(<span class="hljs-string">'documents'</span>)
      .delete()
      .eq(<span class="hljs-string">'metadata-&gt;&gt;document_id'</span>, id);

    <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>, fileDeleted: !!filePath });
  } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
    <span class="hljs-keyword">return</span> NextResponse.json({ error: error.message }, { status: <span class="hljs-number">500</span> });
  }
}
</code></pre>
<p>This route handles:</p>
<ul>
<li><p><strong>GET without ID</strong>: Lists all documents (deduplicated since each document has multiple chunks)</p>
</li>
<li><p><strong>GET with ID</strong>: Returns document details and full text (all chunks combined)</p>
</li>
<li><p><strong>GET with ID and file=true</strong>: Downloads the original file from storage</p>
</li>
<li><p><strong>DELETE with ID</strong>: Deletes the document and its file from both storage and database</p>
</li>
</ul>
<p>Now that your API routes are complete, let's build the user interface components, starting with the upload modal.</p>
<h2 id="heading-step-8-create-the-upload-modal-component">Step 8: Create the Upload Modal Component</h2>
<p>The upload modal provides a user-friendly interface for selecting and uploading documents. It handles file selection, upload progress, and displays success or error messages.</p>
<p>Create <code>src/app/components/UploadModal.tsx</code>:</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">interface</span> UploadModalProps {
  isOpen: <span class="hljs-built_in">boolean</span>;
  onClose: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
  onUploadSuccess?: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</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">UploadModal</span>(<span class="hljs-params">{ isOpen, onClose, onUploadSuccess }: UploadModalProps</span>) </span>{
  <span class="hljs-keyword">const</span> [file, setFile] = useState&lt;File | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [uploading, setUploading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [message, setMessage] = useState&lt;{ <span class="hljs-keyword">type</span>: <span class="hljs-string">'success'</span> | <span class="hljs-string">'error'</span>; text: <span class="hljs-built_in">string</span> } | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-built_in">document</span>.body.style.overflow = isOpen ? <span class="hljs-string">'hidden'</span> : <span class="hljs-string">'unset'</span>;
    <span class="hljs-keyword">if</span> (!isOpen) { 
      setFile(<span class="hljs-literal">null</span>); 
      setMessage(<span class="hljs-literal">null</span>); 
    }
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> { 
      <span class="hljs-built_in">document</span>.body.style.overflow = <span class="hljs-string">'unset'</span>; 
    };
  }, [isOpen]);

  <span class="hljs-keyword">const</span> handleFileChange = <span class="hljs-function">(<span class="hljs-params">e: React.ChangeEvent&lt;HTMLInputElement&gt;</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (e.target.files &amp;&amp; e.target.files[<span class="hljs-number">0</span>]) {
      setFile(e.target.files[<span class="hljs-number">0</span>]);
      setMessage(<span class="hljs-literal">null</span>);
    }
  };

  <span class="hljs-keyword">const</span> handleUpload = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!file) {
      setMessage({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'error'</span>, text: <span class="hljs-string">'Please select a file'</span> });
      <span class="hljs-keyword">return</span>;
    }

    setUploading(<span class="hljs-literal">true</span>);
    setMessage(<span class="hljs-literal">null</span>);

    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> formData = <span class="hljs-keyword">new</span> FormData();
      formData.append(<span class="hljs-string">'file'</span>, file);

      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/upload'</span>, {
        method: <span class="hljs-string">'POST'</span>,
        body: formData,
      });

      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();

      <span class="hljs-keyword">if</span> (data.success) {
        setMessage({
          <span class="hljs-keyword">type</span>: <span class="hljs-string">'success'</span>,
          text: <span class="hljs-string">`File "<span class="hljs-subst">${data.fileName}</span>" uploaded successfully! Processed <span class="hljs-subst">${data.chunks}</span> chunks.`</span>,
        });
        setFile(<span class="hljs-literal">null</span>);
        (<span class="hljs-built_in">document</span>.getElementById(<span class="hljs-string">'upload-file-input'</span>) <span class="hljs-keyword">as</span> HTMLInputElement)?.setAttribute(<span class="hljs-string">'value'</span>, <span class="hljs-string">''</span>);
        <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> { 
          onUploadSuccess?.(); 
          onClose(); 
        }, <span class="hljs-number">1500</span>);
      } <span class="hljs-keyword">else</span> {
        setMessage({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'error'</span>, text: data.error || <span class="hljs-string">'Upload failed'</span> });
      }
    } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
      setMessage({ <span class="hljs-keyword">type</span>: <span class="hljs-string">'error'</span>, text: error.message || <span class="hljs-string">'Upload failed'</span> });
    } <span class="hljs-keyword">finally</span> {
      setUploading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">if</span> (!isOpen) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

  <span class="hljs-keyword">return</span> (
    &lt;div
      className=<span class="hljs-string">"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"</span>
      onClick={onClose}
    &gt;
      &lt;div
        className=<span class="hljs-string">"relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"</span>
        onClick={<span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> e.stopPropagation()}
      &gt;
        &lt;div className=<span class="hljs-string">"flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-800"</span>&gt;
          &lt;h2 className=<span class="hljs-string">"text-2xl font-semibold text-gray-900 dark:text-gray-100"</span>&gt;
            Upload Document
          &lt;/h2&gt;
          &lt;button
            onClick={onClose}
            className=<span class="hljs-string">"p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"</span>
            aria-label=<span class="hljs-string">"Close"</span>
          &gt;
            &lt;svg className=<span class="hljs-string">"w-6 h-6"</span> fill=<span class="hljs-string">"none"</span> stroke=<span class="hljs-string">"currentColor"</span> viewBox=<span class="hljs-string">"0 0 24 24"</span>&gt;
              &lt;path strokeLinecap=<span class="hljs-string">"round"</span> strokeLinejoin=<span class="hljs-string">"round"</span> strokeWidth={<span class="hljs-number">2</span>} d=<span class="hljs-string">"M6 18L18 6M6 6l12 12"</span> /&gt;
            &lt;/svg&gt;
          &lt;/button&gt;
        &lt;/div&gt;

        &lt;div className=<span class="hljs-string">"p-6"</span>&gt;
          &lt;div className=<span class="hljs-string">"mb-6"</span>&gt;
            &lt;label htmlFor=<span class="hljs-string">"upload-file-input"</span> className=<span class="hljs-string">"block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"</span>&gt;
              Select a file (PDF, DOCX, or TXT)
            &lt;/label&gt;
            &lt;input
              id=<span class="hljs-string">"upload-file-input"</span>
              <span class="hljs-keyword">type</span>=<span class="hljs-string">"file"</span>
              accept=<span class="hljs-string">".pdf,.docx,.txt"</span>
              onChange={handleFileChange}
              className=<span class="hljs-string">"block w-full text-sm text-gray-500
                file:mr-4 file:py-2 file:px-4
                file:rounded-lg file:border-0
                file:text-sm file:font-semibold
                file:bg-blue-50 file:text-blue-700
                hover:file:bg-blue-100
                dark:file:bg-blue-900 dark:file:text-blue-300
                dark:hover:file:bg-blue-800"</span>
            /&gt;
          &lt;/div&gt;

          {file &amp;&amp; (
            &lt;div className=<span class="hljs-string">"mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg text-sm text-gray-600 dark:text-gray-400 space-y-1"</span>&gt;
              &lt;p&gt;&lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Selected:&lt;<span class="hljs-regexp">/span&gt; {file.name}&lt;/</span>p&gt;
              &lt;p&gt;&lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Size:&lt;<span class="hljs-regexp">/span&gt; {(file.size /</span> <span class="hljs-number">1024</span>).toFixed(<span class="hljs-number">2</span>)} KB&lt;/p&gt;
              &lt;p&gt;&lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Type:&lt;<span class="hljs-regexp">/span&gt; {file.type || file.name.split('.').pop()}&lt;/</span>p&gt;
            &lt;/div&gt;
          )}

          &lt;button
            onClick={handleUpload}
            disabled={!file || uploading}
            className=<span class="hljs-string">"w-full bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"</span>
          &gt;
            {uploading ? <span class="hljs-string">'Uploading and Processing...'</span> : <span class="hljs-string">'Upload Document'</span>}
          &lt;/button&gt;

          {message &amp;&amp; (
            &lt;div
              className={<span class="hljs-string">`mt-6 p-4 rounded-lg <span class="hljs-subst">${
                message.<span class="hljs-keyword">type</span> === <span class="hljs-string">'success'</span>
                  ? <span class="hljs-string">'bg-green-50 text-green-800 dark:bg-green-900 dark:text-green-200'</span>
                  : <span class="hljs-string">'bg-red-50 text-red-800 dark:bg-red-900 dark:text-red-200'</span>
              }</span>`</span>}
            &gt;
              {message.text}
            &lt;/div&gt;
          )}

          &lt;div className=<span class="hljs-string">"mt-8 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm"</span>&gt;
            &lt;p className=<span class="hljs-string">"font-medium text-blue-900 dark:text-blue-200 mb-2"</span>&gt;Supported: PDF, DOCX, TXT&lt;/p&gt;
            &lt;p className=<span class="hljs-string">"text-blue-700 dark:text-blue-400"</span>&gt;Files will be processed and embedded <span class="hljs-keyword">for</span> RAG search.&lt;/p&gt;
          &lt;/div&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component provides a clean interface for file uploads with proper error handling and user feedback. Next, let's create the PDF viewer component for previewing documents.</p>
<h2 id="heading-step-9-create-the-pdf-viewer-modal-component">Step 9: Create the PDF Viewer Modal Component</h2>
<p>The PDF viewer modal allows users to preview PDFs and view extracted text from any document. It's particularly useful for verifying that documents were processed correctly.</p>
<p>Create <code>src/app/components/PDFViewerModal.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> { useEffect, useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>;

<span class="hljs-keyword">interface</span> PDFViewerModalProps {
  isOpen: <span class="hljs-built_in">boolean</span>;
  onClose: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>;
  fileUrl: <span class="hljs-built_in">string</span>;
  fileName: <span class="hljs-built_in">string</span>;
  documentId?: <span class="hljs-built_in">string</span>;
  isPDF?: <span class="hljs-built_in">boolean</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">PDFViewerModal</span>(<span class="hljs-params">{ 
  isOpen, 
  onClose, 
  fileUrl, 
  fileName, 
  documentId, 
  isPDF = <span class="hljs-literal">true</span> 
}: PDFViewerModalProps</span>) </span>{
  <span class="hljs-keyword">const</span> [error, setError] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">true</span>);
  <span class="hljs-keyword">const</span> [activeTab, setActiveTab] = useState&lt;<span class="hljs-string">'preview'</span> | <span class="hljs-string">'content'</span>&gt;(<span class="hljs-string">'preview'</span>);
  <span class="hljs-keyword">const</span> [text, setText] = useState&lt;<span class="hljs-built_in">string</span>&gt;(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">const</span> [textLoading, setTextLoading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [textError, setTextError] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-built_in">document</span>.body.style.overflow = isOpen ? <span class="hljs-string">'hidden'</span> : <span class="hljs-string">'unset'</span>;
    <span class="hljs-keyword">if</span> (isOpen) { 
      setError(<span class="hljs-literal">null</span>); 
      setLoading(<span class="hljs-literal">true</span>); 
      setActiveTab(isPDF ? <span class="hljs-string">'preview'</span> : <span class="hljs-string">'content'</span>); 
      setText(<span class="hljs-string">''</span>); 
      setTextError(<span class="hljs-literal">null</span>); 
    }
    <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> { 
      <span class="hljs-built_in">document</span>.body.style.overflow = <span class="hljs-string">'unset'</span>; 
    };
  }, [isOpen, isPDF]);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (isOpen &amp;&amp; documentId &amp;&amp; activeTab === <span class="hljs-string">'content'</span> &amp;&amp; !text &amp;&amp; !textLoading &amp;&amp; !textError) {
      fetchDocumentText();
    }
  }, [isOpen, documentId, activeTab, text, textLoading, textError]);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    <span class="hljs-keyword">if</span> (isOpen &amp;&amp; fileUrl &amp;&amp; isPDF) {
      fetch(fileUrl, { method: <span class="hljs-string">'GET'</span>, headers: { <span class="hljs-string">'Accept'</span>: <span class="hljs-string">'application/json'</span> } })
        .then(<span class="hljs-keyword">async</span> res =&gt; {
          <span class="hljs-keyword">if</span> (res.headers.get(<span class="hljs-string">'content-type'</span>)?.includes(<span class="hljs-string">'application/json'</span>)) {
            <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
            <span class="hljs-keyword">throw</span> <span class="hljs-keyword">new</span> <span class="hljs-built_in">Error</span>(data.error || <span class="hljs-string">'File not available'</span>);
          }
          <span class="hljs-keyword">if</span> (!res.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 load: <span class="hljs-subst">${res.status}</span>`</span>);
          setLoading(<span class="hljs-literal">false</span>);
        })
        .catch(<span class="hljs-function"><span class="hljs-params">err</span> =&gt;</span> {
          setError(err.message || <span class="hljs-string">'Failed to load PDF'</span>);
          setLoading(<span class="hljs-literal">false</span>);
        });
    } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (isOpen &amp;&amp; !isPDF) {
      setLoading(<span class="hljs-literal">false</span>);
    }
  }, [isOpen, fileUrl, isPDF]);

  <span class="hljs-keyword">const</span> fetchDocumentText = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!documentId) <span class="hljs-keyword">return</span>;
    setTextLoading(<span class="hljs-literal">true</span>); 
    setTextError(<span class="hljs-literal">null</span>);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${documentId}</span>`</span>);
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        setTextError(data.error);
      } <span class="hljs-keyword">else</span> {
        setText(data.fullText || <span class="hljs-string">'No text content available'</span>);
      }
    } <span class="hljs-keyword">catch</span> (err) {
      setTextError(err <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? err.message : <span class="hljs-string">'Failed to fetch document text'</span>);
    } <span class="hljs-keyword">finally</span> {
      setTextLoading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">if</span> (!isOpen) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>;

  <span class="hljs-keyword">return</span> (
    &lt;div
      className=<span class="hljs-string">"fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-75 p-4"</span>
      onClick={onClose}
    &gt;
      &lt;div
        className=<span class="hljs-string">"relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-6xl h-[90vh] flex flex-col"</span>
        onClick={<span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> e.stopPropagation()}
      &gt;
        &lt;div className=<span class="hljs-string">"flex flex-col border-b border-gray-200 dark:border-gray-800"</span>&gt;
          &lt;div className=<span class="hljs-string">"flex items-center justify-between p-4"</span>&gt;
            &lt;h2 className=<span class="hljs-string">"text-xl font-semibold text-gray-900 dark:text-gray-100 truncate flex-1 mr-4"</span>&gt;
              {fileName}
            &lt;/h2&gt;
            &lt;div className=<span class="hljs-string">"flex items-center gap-2"</span>&gt;
              &lt;button
                onClick={onClose}
                className=<span class="hljs-string">"p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"</span>
                aria-label=<span class="hljs-string">"Close"</span>
              &gt;
                &lt;svg className=<span class="hljs-string">"w-6 h-6"</span> fill=<span class="hljs-string">"none"</span> stroke=<span class="hljs-string">"currentColor"</span> viewBox=<span class="hljs-string">"0 0 24 24"</span>&gt;
                  &lt;path strokeLinecap=<span class="hljs-string">"round"</span> strokeLinejoin=<span class="hljs-string">"round"</span> strokeWidth={<span class="hljs-number">2</span>} d=<span class="hljs-string">"M6 18L18 6M6 6l12 12"</span> /&gt;
                &lt;/svg&gt;
              &lt;/button&gt;
            &lt;/div&gt;
          &lt;/div&gt;

          {isPDF &amp;&amp; (
            &lt;div className=<span class="hljs-string">"flex border-t border-gray-200 dark:border-gray-800"</span>&gt;
              {([<span class="hljs-string">'preview'</span>, <span class="hljs-string">'content'</span>] <span class="hljs-keyword">as</span> <span class="hljs-keyword">const</span>).map(<span class="hljs-function"><span class="hljs-params">tab</span> =&gt;</span> (
                &lt;button 
                  key={tab} 
                  onClick={<span class="hljs-function">() =&gt;</span> setActiveTab(tab)} 
                  className={<span class="hljs-string">`flex-1 px-4 py-3 text-sm font-medium transition-colors <span class="hljs-subst">${
                    activeTab === tab 
                      ? <span class="hljs-string">'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400 bg-blue-50 dark:bg-blue-900/20'</span> 
                      : <span class="hljs-string">'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'</span>
                  }</span>`</span>}
                &gt;
                  {tab.charAt(<span class="hljs-number">0</span>).toUpperCase() + tab.slice(<span class="hljs-number">1</span>)}
                &lt;/button&gt;
              ))}
            &lt;/div&gt;
          )}
        &lt;/div&gt;

        &lt;div className=<span class="hljs-string">"flex-1 overflow-hidden"</span>&gt;
          {isPDF &amp;&amp; activeTab === <span class="hljs-string">'preview'</span> &amp;&amp; (
            &lt;div className=<span class="hljs-string">"h-full overflow-hidden"</span>&gt;
              {error ? (
                &lt;div className=<span class="hljs-string">"flex flex-col items-center justify-center h-full p-8"</span>&gt;
                  &lt;div className=<span class="hljs-string">"bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-6 max-w-md"</span>&gt;
                    &lt;h3 className=<span class="hljs-string">"text-lg font-semibold text-yellow-800 dark:text-yellow-200 mb-2"</span>&gt;
                      PDF File Not Available
                    &lt;/h3&gt;
                    &lt;p className=<span class="hljs-string">"text-yellow-700 dark:text-yellow-300 mb-4"</span>&gt;{error}&lt;/p&gt;
                    {documentId &amp;&amp; (
                      &lt;button 
                        onClick={<span class="hljs-function">() =&gt;</span> setActiveTab(<span class="hljs-string">'content'</span>)} 
                        className=<span class="hljs-string">"px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"</span>
                      &gt;
                        View Extracted Text Instead
                      &lt;/button&gt;
                    )}
                  &lt;/div&gt;
                &lt;/div&gt;
              ) : loading ? (
                &lt;div className=<span class="hljs-string">"flex items-center justify-center h-full"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400"</span>&gt;Loading PDF...&lt;/p&gt;
                &lt;/div&gt;
              ) : (
                &lt;iframe
                  src={<span class="hljs-string">`<span class="hljs-subst">${fileUrl}</span><span class="hljs-subst">${fileUrl.includes(<span class="hljs-string">'?'</span>) ? <span class="hljs-string">'&amp;'</span> : <span class="hljs-string">'?'</span>}</span>view=true#toolbar=1&amp;navpanes=0&amp;scrollbar=1`</span>}
                  className=<span class="hljs-string">"w-full h-full border-0"</span>
                  title={fileName}
                  allow=<span class="hljs-string">"fullscreen"</span>
                  onError={<span class="hljs-function">() =&gt;</span> setError(<span class="hljs-string">'Failed to load PDF'</span>)}
                /&gt;
              )}
            &lt;/div&gt;
          )}

          {(!isPDF || activeTab === <span class="hljs-string">'content'</span>) &amp;&amp; (
            &lt;div className=<span class="hljs-string">"h-full overflow-auto p-6"</span>&gt;
              {textLoading ? (
                &lt;div className=<span class="hljs-string">"flex items-center justify-center h-full"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400"</span>&gt;Loading...&lt;/p&gt;
                &lt;/div&gt;
              ) : textError ? (
                &lt;div className=<span class="hljs-string">"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-red-800 dark:text-red-200"</span>&gt;<span class="hljs-built_in">Error</span>: {textError}&lt;/p&gt;
                &lt;/div&gt;
              ) : (
                &lt;div className=<span class="hljs-string">"space-y-4"</span>&gt;
                  &lt;p className=<span class="hljs-string">"text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                    Formatting may be inconsistent <span class="hljs-keyword">from</span> source.
                  &lt;/p&gt;
                  &lt;pre className=<span class="hljs-string">"whitespace-pre-wrap text-sm text-gray-800 dark:text-gray-200 font-mono bg-gray-50 dark:bg-gray-800 p-4 rounded-lg"</span>&gt;
                    {text || <span class="hljs-string">'No text content available'</span>}
                  &lt;/pre&gt;
                &lt;/div&gt;
              )}
            &lt;/div&gt;
          )}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This component provides a full-screen modal for viewing PDFs and extracted text, with tabs to switch between preview and text content. Now let's create a simple navigation component to tie everything together.</p>
<h2 id="heading-step-10-create-the-navigation-component">Step 10: Create the Navigation Component</h2>
<p>The navigation component provides easy access to the Search and Documents pages. It highlights the current page and provides a clean, consistent navigation experience.</p>
<p>Create <code>src/app/components/Navigation.tsx</code>:</p>
<pre><code class="lang-typescript"><span class="hljs-string">'use client'</span>;
<span class="hljs-keyword">import</span> Link <span class="hljs-keyword">from</span> <span class="hljs-string">'next/link'</span>;
<span class="hljs-keyword">import</span> { usePathname } <span class="hljs-keyword">from</span> <span class="hljs-string">'next/navigation'</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">Navigation</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> pathname = usePathname();

  <span class="hljs-keyword">const</span> navItems = [
    { href: <span class="hljs-string">'/'</span>, label: <span class="hljs-string">'Search'</span> },
    { href: <span class="hljs-string">'/documents'</span>, label: <span class="hljs-string">'Documents'</span> },
  ];

  <span class="hljs-keyword">return</span> (
    &lt;nav className=<span class="hljs-string">"border-b border-gray-200 dark:border-gray-800 mb-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">"flex space-x-8"</span>&gt;
          {navItems.map(<span class="hljs-function">(<span class="hljs-params">item</span>) =&gt;</span> (
            &lt;Link
              key={item.href}
              href={item.href}
              className={<span class="hljs-string">`py-4 px-1 border-b-2 font-medium text-sm <span class="hljs-subst">${
                pathname === item.href
                  ? <span class="hljs-string">'border-blue-500 text-blue-600 dark:text-blue-400'</span>
                  : <span class="hljs-string">'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'</span>
              }</span>`</span>}
            &gt;
              {item.label}
            &lt;/Link&gt;
          ))}
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/nav&gt;
  );
}
</code></pre>
<p>With navigation in place, let's create the main search page where users can query their documents.</p>
<h2 id="heading-step-11-create-the-home-page-search-interface">Step 11: Create the Home Page (Search Interface)</h2>
<p>The search page is the main interface where users ask questions about their uploaded documents. It displays the AI-generated answers along with source citations, allowing users to verify the information.</p>
<p>Update <code>src/app/page.tsx</code>:</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> Navigation <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/Navigation'</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">Home</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [query, setQuery] = useState(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">const</span> [answer, setAnswer] = useState(<span class="hljs-string">''</span>);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [sources, setSources] = useState&lt;<span class="hljs-built_in">any</span>[]&gt;([]);

  <span class="hljs-keyword">const</span> handleSearch = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">if</span> (!query.trim()) <span class="hljs-keyword">return</span>;
    setLoading(<span class="hljs-literal">true</span>); 
    setAnswer(<span class="hljs-string">''</span>); 
    setSources([]);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/search'</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({ query }) 
      });
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        setAnswer(<span class="hljs-string">`Error: <span class="hljs-subst">${data.error}</span>`</span>);
      } <span class="hljs-keyword">else</span> { 
        setAnswer(data.answer || <span class="hljs-string">'No answer generated'</span>); 
        setSources(data.sources || []); 
      }
    } <span class="hljs-keyword">catch</span> (error: <span class="hljs-built_in">any</span>) {
      setAnswer(<span class="hljs-string">`Error: <span class="hljs-subst">${error.message}</span>`</span>);
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">const</span> handleKeyPress = <span class="hljs-function">(<span class="hljs-params">e: React.KeyboardEvent</span>) =&gt;</span> {
    <span class="hljs-keyword">if</span> (e.key === <span class="hljs-string">'Enter'</span> &amp;&amp; (e.metaKey || e.ctrlKey)) {
      handleSearch();
    }
  };

  <span class="hljs-keyword">return</span> (
    &lt;div className=<span class="hljs-string">"min-h-screen"</span>&gt;
      &lt;Navigation /&gt;
      &lt;main className=<span class="hljs-string">"max-w-4xl mx-auto p-8"</span>&gt;
        &lt;h1 className=<span class="hljs-string">"text-3xl font-bold mb-6"</span>&gt;RAG Search&lt;/h1&gt;

        &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 shadow-sm mb-6"</span>&gt;
          &lt;textarea 
            className=<span class="hljs-string">"w-full p-4 border border-gray-300 dark:border-gray-700 rounded-lg shadow-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 resize-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"</span>
            placeholder=<span class="hljs-string">"Ask a question about your uploaded documents..."</span>
            value={query}
            onChange={<span class="hljs-function">(<span class="hljs-params">e</span>) =&gt;</span> setQuery(e.target.value)}
            onKeyDown={handleKeyPress}
            rows={<span class="hljs-number">4</span>}
          /&gt;
          &lt;button 
            onClick={handleSearch}
            className=<span class="hljs-string">"mt-4 bg-blue-600 text-white px-8 py-3 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"</span>
            disabled={loading || !query.trim()}
          &gt;
            {loading ? <span class="hljs-string">'Searching...'</span> : <span class="hljs-string">'Search'</span>}
          &lt;/button&gt;
          &lt;p className=<span class="hljs-string">"mt-2 text-sm text-gray-500 dark:text-gray-400"</span>&gt;
            Press Cmd/Ctrl + Enter to search
          &lt;/p&gt;
        &lt;/div&gt;

        {answer &amp;&amp; (
          &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 shadow-sm mb-6"</span>&gt;
            &lt;h2 className=<span class="hljs-string">"text-xl font-semibold mb-3"</span>&gt;Answer:&lt;/h2&gt;
            &lt;p className=<span class="hljs-string">"text-gray-800 dark:text-gray-200 leading-relaxed whitespace-pre-wrap"</span>&gt;
              {answer}
            &lt;/p&gt;
          &lt;/div&gt;
        )}

        {sources &amp;&amp; sources.length &gt; <span class="hljs-number">0</span> &amp;&amp; (
          &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg p-6 shadow-sm"</span>&gt;
            &lt;h2 className=<span class="hljs-string">"text-xl font-semibold mb-3"</span>&gt;Sources ({sources.length}):&lt;/h2&gt;
            &lt;div className=<span class="hljs-string">"space-y-3"</span>&gt;
              {sources.map(<span class="hljs-function">(<span class="hljs-params">source, index</span>) =&gt;</span> (
                &lt;div
                  key={index}
                  className=<span class="hljs-string">"p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"</span>
                &gt;
                  &lt;p className=<span class="hljs-string">"text-sm text-gray-600 dark:text-gray-400 mb-1"</span>&gt;
                    &lt;span className=<span class="hljs-string">"font-medium"</span>&gt;Source:&lt;/span&gt;{<span class="hljs-string">' '</span>}
                    {source.metadata?.source || source.metadata?.file_name || <span class="hljs-string">'Unknown'</span>}
                  &lt;/p&gt;
                  &lt;p className=<span class="hljs-string">"text-sm text-gray-800 dark:text-gray-200 line-clamp-3"</span>&gt;
                    {source.content}
                  &lt;/p&gt;
                &lt;/div&gt;
              ))}
            &lt;/div&gt;
          &lt;/div&gt;
        )}
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This page provides a clean search interface with a textarea for queries, a search button, and sections to display answers and source citations. The sources section helps users verify where the information came from, which is crucial for trust and accuracy. Now let's create the documents management page.</p>
<h2 id="heading-step-12-create-the-documents-page">Step 12: Create the Documents Page</h2>
<p>The documents page serves as your document library. It displays all uploaded documents in a table format, shows metadata like file size and chunk count, and provides actions to preview, download, or delete documents. This page is essential for managing your document collection and verifying uploads.</p>
<p>Create <code>src/app/documents/page.tsx</code>:</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> Navigation <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/Navigation'</span>;
<span class="hljs-keyword">import</span> PDFViewerModal <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/PDFViewerModal'</span>;
<span class="hljs-keyword">import</span> UploadModal <span class="hljs-keyword">from</span> <span class="hljs-string">'../components/UploadModal'</span>;

<span class="hljs-keyword">interface</span> Document {
  id: <span class="hljs-built_in">string</span>;
  file_name: <span class="hljs-built_in">string</span>;
  file_type: <span class="hljs-built_in">string</span>;
  file_size: <span class="hljs-built_in">number</span>;
  upload_date: <span class="hljs-built_in">string</span>;
  total_chunks: <span class="hljs-built_in">number</span>;
  file_url?: <span class="hljs-built_in">string</span>;
  file_path?: <span class="hljs-built_in">string</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">DocumentsPage</span>(<span class="hljs-params"></span>) </span>{
  <span class="hljs-keyword">const</span> [documents, setDocuments] = useState&lt;Document[]&gt;([]);
  <span class="hljs-keyword">const</span> [loading, setLoading] = useState(<span class="hljs-literal">true</span>);
  <span class="hljs-keyword">const</span> [error, setError] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [showPDFModal, setShowPDFModal] = useState(<span class="hljs-literal">false</span>);
  <span class="hljs-keyword">const</span> [selectedPDF, setSelectedPDF] = useState&lt;{ url: <span class="hljs-built_in">string</span>; name: <span class="hljs-built_in">string</span>; id?: <span class="hljs-built_in">string</span>; isPDF?: <span class="hljs-built_in">boolean</span> } | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [deletingId, setDeletingId] = useState&lt;<span class="hljs-built_in">string</span> | <span class="hljs-literal">null</span>&gt;(<span class="hljs-literal">null</span>);
  <span class="hljs-keyword">const</span> [showUploadModal, setShowUploadModal] = useState(<span class="hljs-literal">false</span>);

  useEffect(<span class="hljs-function">() =&gt;</span> {
    fetchDocuments();
  }, []);

  <span class="hljs-keyword">const</span> fetchDocuments = <span class="hljs-keyword">async</span> () =&gt; {
    <span class="hljs-keyword">try</span> {
      setLoading(<span class="hljs-literal">true</span>);
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">'/api/documents'</span>);
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        setError(data.error);
      } <span class="hljs-keyword">else</span> {
        setDocuments(data.documents || []);
      }
    } <span class="hljs-keyword">catch</span> (err) {
      setError(err <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? err.message : <span class="hljs-string">'Failed to fetch documents'</span>);
    } <span class="hljs-keyword">finally</span> {
      setLoading(<span class="hljs-literal">false</span>);
    }
  };

  <span class="hljs-keyword">const</span> formatDate = <span class="hljs-function">(<span class="hljs-params">s: <span class="hljs-built_in">string</span></span>) =&gt;</span> {
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> d = <span class="hljs-keyword">new</span> <span class="hljs-built_in">Date</span>(s);
      <span class="hljs-keyword">return</span> <span class="hljs-built_in">isNaN</span>(d.getTime()) 
        ? s 
        : d.toLocaleString(<span class="hljs-string">'en-US'</span>, { 
            year: <span class="hljs-string">'numeric'</span>, 
            month: <span class="hljs-string">'short'</span>, 
            day: <span class="hljs-string">'numeric'</span>, 
            hour: <span class="hljs-string">'2-digit'</span>, 
            minute: <span class="hljs-string">'2-digit'</span>, 
            hour12: <span class="hljs-literal">true</span> 
          });
    } <span class="hljs-keyword">catch</span> { 
      <span class="hljs-keyword">return</span> s; 
    }
  };

  <span class="hljs-keyword">const</span> formatFileSize = <span class="hljs-function">(<span class="hljs-params">b: <span class="hljs-built_in">number</span></span>) =&gt;</span> 
    b &lt; <span class="hljs-number">1024</span> 
      ? <span class="hljs-string">`<span class="hljs-subst">${b}</span> B`</span> 
      : b &lt; <span class="hljs-number">1024</span> * <span class="hljs-number">1024</span> 
        ? <span class="hljs-string">`<span class="hljs-subst">${(b / <span class="hljs-number">1024</span>).toFixed(<span class="hljs-number">2</span>)}</span> KB`</span> 
        : <span class="hljs-string">`<span class="hljs-subst">${(b / (<span class="hljs-number">1024</span> * <span class="hljs-number">1024</span>)).toFixed(<span class="hljs-number">2</span>)}</span> MB`</span>;

  <span class="hljs-keyword">const</span> handleDelete = <span class="hljs-keyword">async</span> (id: <span class="hljs-built_in">string</span>, name: <span class="hljs-built_in">string</span>) =&gt; {
    <span class="hljs-keyword">if</span> (!confirm(<span class="hljs-string">`Delete "<span class="hljs-subst">${name}</span>"? This will permanently delete the document, embeddings, and file.`</span>)) {
      <span class="hljs-keyword">return</span>;
    }
    setDeletingId(id);
    <span class="hljs-keyword">try</span> {
      <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> fetch(<span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${id}</span>`</span>, { method: <span class="hljs-string">'DELETE'</span> });
      <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> res.json();
      <span class="hljs-keyword">if</span> (data.error) {
        alert(<span class="hljs-string">`Error: <span class="hljs-subst">${data.error}</span>`</span>);
      } <span class="hljs-keyword">else</span> {
        setDocuments(documents.filter(<span class="hljs-function"><span class="hljs-params">doc</span> =&gt;</span> doc.id !== id));
      }
    } <span class="hljs-keyword">catch</span> (err) {
      alert(err <span class="hljs-keyword">instanceof</span> <span class="hljs-built_in">Error</span> ? err.message : <span class="hljs-string">'Failed to delete'</span>);
    } <span class="hljs-keyword">finally</span> {
      setDeletingId(<span class="hljs-literal">null</span>);
    }
  };

  <span class="hljs-keyword">return</span> (
    &lt;div className=<span class="hljs-string">"min-h-screen"</span>&gt;
      &lt;Navigation /&gt;
      &lt;main className=<span class="hljs-string">"max-w-7xl mx-auto p-8"</span>&gt;
        &lt;div className=<span class="hljs-string">"flex items-center justify-between mb-6"</span>&gt;
          &lt;h1 className=<span class="hljs-string">"text-3xl font-bold"</span>&gt;Documents&lt;/h1&gt;
          &lt;button
            onClick={<span class="hljs-function">() =&gt;</span> setShowUploadModal(<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 font-medium"</span>
          &gt;
            Upload Document
          &lt;/button&gt;
        &lt;/div&gt;

        {loading ? (
          &lt;div className=<span class="hljs-string">"text-center py-12"</span>&gt;
            &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400"</span>&gt;Loading documents...&lt;/p&gt;
          &lt;/div&gt;
        ) : error ? (
          &lt;div className=<span class="hljs-string">"bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4"</span>&gt;
            &lt;p className=<span class="hljs-string">"text-red-800 dark:text-red-200"</span>&gt;<span class="hljs-built_in">Error</span>: {error}&lt;/p&gt;
          &lt;/div&gt;
        ) : documents.length === <span class="hljs-number">0</span> ? (
          &lt;div className=<span class="hljs-string">"bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-12 text-center"</span>&gt;
            &lt;p className=<span class="hljs-string">"text-gray-500 dark:text-gray-400 mb-4"</span>&gt;No documents uploaded yet.&lt;/p&gt;
            &lt;button
              onClick={<span class="hljs-function">() =&gt;</span> setShowUploadModal(<span class="hljs-literal">true</span>)}
              className=<span class="hljs-string">"text-blue-600 dark:text-blue-400 hover:underline font-medium"</span>
            &gt;
              Upload your first <span class="hljs-built_in">document</span>
            &lt;/button&gt;
          &lt;/div&gt;
        ) : (
          &lt;div className=<span class="hljs-string">"bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-lg shadow-sm overflow-hidden"</span>&gt;
            &lt;div className=<span class="hljs-string">"overflow-x-auto"</span>&gt;
              &lt;table className=<span class="hljs-string">"min-w-full divide-y divide-gray-200 dark:divide-gray-800"</span>&gt;
                &lt;thead className=<span class="hljs-string">"bg-gray-50 dark:bg-gray-800"</span>&gt;
                  &lt;tr&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      File Name
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Type
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Size
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Chunks
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Upload <span class="hljs-built_in">Date</span>
                    &lt;/th&gt;
                    &lt;th className=<span class="hljs-string">"px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"</span>&gt;
                      Actions
                    &lt;/th&gt;
                  &lt;/tr&gt;
                &lt;/thead&gt;
                &lt;tbody className=<span class="hljs-string">"bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800"</span>&gt;
                  {documents.map(<span class="hljs-function">(<span class="hljs-params">doc</span>) =&gt;</span> (
                    &lt;tr key={doc.id} className=<span class="hljs-string">"hover:bg-gray-50 dark:hover:bg-gray-800"</span>&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap"</span>&gt;
                        &lt;div className=<span class="hljs-string">"text-sm font-medium text-gray-900 dark:text-gray-100"</span>&gt;
                          {doc.file_name}
                        &lt;/div&gt;
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap"</span>&gt;
                        &lt;span className=<span class="hljs-string">"px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"</span>&gt;
                          {doc.file_type || <span class="hljs-string">'unknown'</span>}
                        &lt;/span&gt;
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                        {formatFileSize(doc.file_size)}
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                        {doc.total_chunks}
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400"</span>&gt;
                        {formatDate(doc.upload_date)}
                      &lt;/td&gt;
                      &lt;td className=<span class="hljs-string">"px-6 py-4 whitespace-nowrap text-sm font-medium"</span>&gt;
                        &lt;div className=<span class="hljs-string">"flex gap-3 items-center"</span>&gt;
                          {doc.file_name.toLowerCase().endsWith(<span class="hljs-string">'.pdf'</span>) ? (
                            &lt;button 
                              onClick={<span class="hljs-function">() =&gt;</span> {
                                <span class="hljs-keyword">const</span> pdfUrl = doc.file_url 
                                  ? <span class="hljs-string">`<span class="hljs-subst">${doc.file_url}</span>?view=true`</span> 
                                  : <span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${doc.id}</span>&amp;file=true&amp;view=true`</span>;
                                setSelectedPDF({ url: pdfUrl, name: doc.file_name, id: doc.id });
                                setShowPDFModal(<span class="hljs-literal">true</span>);
                              }} 
                              className=<span class="hljs-string">"text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"</span>
                            &gt;
                              Preview
                            &lt;/button&gt;
                          ) : (
                            &lt;&gt;
                              &lt;button 
                                onClick={<span class="hljs-function">() =&gt;</span> {
                                  setSelectedPDF({ 
                                    url: doc.file_url || <span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${doc.id}</span>&amp;file=true`</span>, 
                                    name: doc.file_name, 
                                    id: doc.id, 
                                    isPDF: <span class="hljs-literal">false</span> 
                                  });
                                  setShowPDFModal(<span class="hljs-literal">true</span>);
                                }} 
                                className=<span class="hljs-string">"text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300"</span>
                              &gt;
                                View
                              &lt;/button&gt;
                              {(doc.file_url || doc.file_path) &amp;&amp; (
                                &lt;a 
                                  href={doc.file_url || <span class="hljs-string">`/api/documents?id=<span class="hljs-subst">${doc.id}</span>&amp;file=true`</span>} 
                                  download={doc.file_name}
                                  className=<span class="hljs-string">"text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300"</span> 
                                  target=<span class="hljs-string">"_blank"</span> 
                                  rel=<span class="hljs-string">"noopener noreferrer"</span>
                                &gt;
                                  Download
                                &lt;/a&gt;
                              )}
                            &lt;/&gt;
                          )}
                          &lt;button 
                            onClick={<span class="hljs-function">() =&gt;</span> handleDelete(doc.id, doc.file_name)} 
                            disabled={deletingId === doc.id}
                            className=<span class="hljs-string">"text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 disabled:opacity-50 disabled:cursor-not-allowed"</span>
                          &gt;
                            {deletingId === doc.id ? <span class="hljs-string">'Deleting...'</span> : <span class="hljs-string">'Delete'</span>}
                          &lt;/button&gt;
                        &lt;/div&gt;
                      &lt;/td&gt;
                    &lt;/tr&gt;
                  ))}
                &lt;/tbody&gt;
              &lt;/table&gt;
            &lt;/div&gt;
          &lt;/div&gt;
        )}

        {selectedPDF &amp;&amp; (
          &lt;PDFViewerModal 
            isOpen={showPDFModal} 
            onClose={<span class="hljs-function">() =&gt;</span> { 
              setShowPDFModal(<span class="hljs-literal">false</span>); 
              setSelectedPDF(<span class="hljs-literal">null</span>); 
            }}
            fileUrl={selectedPDF.url} 
            fileName={selectedPDF.name} 
            documentId={selectedPDF.id} 
            isPDF={selectedPDF.isPDF !== <span class="hljs-literal">false</span>} 
          /&gt;
        )}
        &lt;UploadModal 
          isOpen={showUploadModal} 
          onClose={<span class="hljs-function">() =&gt;</span> setShowUploadModal(<span class="hljs-literal">false</span>)} 
          onUploadSuccess={fetchDocuments} 
        /&gt;
      &lt;/main&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>This page provides a comprehensive document management interface with a table showing all documents, their metadata, and action buttons for preview, download, and deletion. The page automatically refreshes after uploads and handles loading and error states gracefully.</p>
<p>Now that all your components and pages are built, let's test the complete application.</p>
<h2 id="heading-step-13-test-your-application">Step 13: Test Your Application</h2>
<p>Start your development server:</p>
<pre><code class="lang-typescript">npm run dev
</code></pre>
<p>Open <a target="_blank" href="http://localhost:3000/"><strong>http://localhost:3000</strong></a> in your browser.</p>
<h3 id="heading-test-the-upload-flow">Test the Upload Flow</h3>
<ol>
<li><p>Navigate to the Documents page</p>
</li>
<li><p>Click "Upload Document"</p>
</li>
<li><p>Select a PDF, DOCX, or TXT file</p>
</li>
<li><p>Wait for the upload and processing to complete (this may take a moment as embeddings are generated)</p>
</li>
<li><p>You should see your document in the list with its metadata:</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769376932518/cf1bcd3c-3ab2-4602-8df0-bca909c0edb0.png" alt="RAG search documents management page showing a table with uploaded documents." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h3 id="heading-test-the-search-flow">Test the Search Flow</h3>
<ol>
<li><p>Navigate to the Search page (or click "Search" in the navigation)</p>
</li>
<li><p>Make sure you've uploaded at least one document first</p>
</li>
<li><p>Type a question about your uploaded document (for example, "What is this document about?" or ask about specific content)</p>
</li>
<li><p>Click "Search" or press Cmd/Ctrl + Enter</p>
</li>
<li><p>You should see an AI-generated answer with source citations showing which document chunks were used</p>
</li>
</ol>
<p>Once the embedding is done, you can navigate to search and look for the sample test command based on the documents you have uploaded. You can also check the source from which the search results were pulled.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1769377080953/c15678d6-59d0-4e97-8a1b-fe049e5fa6a9.png" alt="RAG Search application search interface showing a query input to search from the RAG database." class="image--center mx-auto" width="600" height="400" loading="lazy"></p>
<h3 id="heading-test-document-management">Test Document Management</h3>
<ol>
<li><p>On the Documents page, click "Preview" or "View" on a document</p>
</li>
<li><p>Try downloading a document</p>
</li>
<li><p>Test deleting a document (be careful - this is permanent)</p>
</li>
</ol>
<p>If everything works correctly, you're ready to deploy your application!</p>
<h2 id="heading-step-14-deploy-your-application">Step 14: Deploy Your Application</h2>
<h3 id="heading-deploy-to-vercel">Deploy to Vercel</h3>
<p>Vercel is the easiest way to deploy Next.js applications and is made by the creators of Next.js:</p>
<p>To get started, you’ll need to push your code to GitHub. So go ahead and create a repository and push your code.</p>
<p>Then go to <a target="_blank" href="https://vercel.com/"><strong>vercel.com</strong></a> and sign in with your GitHub account. Click "New Project" and import your GitHub repository.</p>
<p>Add your environment variables in the project settings:</p>
<ul>
<li><p><code>NEXT_PUBLIC_SUPABASE_URL</code></p>
</li>
<li><p><code>NEXT_PUBLIC_SUPABASE_PUBLISHABLE_DEFAULT_KEY</code></p>
</li>
<li><p><code>SUPABASE_SERVICE_ROLE_KEY</code></p>
</li>
<li><p><code>OPENAI_API_KEY</code></p>
</li>
</ul>
<p>Then click "Deploy", and your application will be live in minutes! Vercel automatically builds and deploys your Next.js application, and you'll get a URL like <a target="_blank" href="http://your-app.vercel.app/"><code>your-app.vercel.app</code></a>.</p>
<h3 id="heading-important-deployment-notes">Important Deployment Notes</h3>
<ul>
<li><p>Make sure all environment variables are set in your Vercel project settings</p>
</li>
<li><p>The service role key is required for file uploads to work</p>
</li>
<li><p>Supabase Storage bucket should be accessible (public or with proper RLS policies)</p>
</li>
<li><p>Your OpenAI API key should have sufficient credits</p>
</li>
</ul>
<h2 id="heading-how-rag-search-works">How RAG Search Works</h2>
<p>Your application uses the RAG (Retrieval-Augmented Generation) pattern. This combines information retrieval with AI text generation. Here's how it works step by step:</p>
<ol>
<li><p><strong>Document processing</strong>: When you upload a document, it's split into chunks. These are typically 800 characters each with 100-character overlap. Each chunk gets an embedding. This is a 1536-dimensional vector that represents its semantic meaning.</p>
</li>
<li><p><strong>Storage</strong>: Embeddings are stored in a vector database. This is PostgreSQL with the pgvector extension. They're stored alongside the original text chunks. The original files are stored in Supabase Storage.</p>
</li>
<li><p><strong>Query processing</strong>: When you search, your query is converted into an embedding. It uses the same model that processed the documents. This ensures the query and documents are in the same "vector space."</p>
</li>
<li><p><strong>Similarity search</strong>: The system finds the most similar document chunks. It uses cosine similarity on the embeddings. Cosine similarity measures the angle between vectors. Smaller angles mean more similar content, even if the exact words differ.</p>
</li>
<li><p><strong>Answer generation</strong>: The retrieved chunks are used as context for an AI model. This model is GPT-4o-mini. It generates an accurate answer. The system prompt instructs the AI to only answer based on the provided context. This ensures accuracy.</p>
</li>
</ol>
<p>This approach gives you several benefits.</p>
<p>First, you get accuracy. Answers are based on your actual documents, not just the AI's training data. Second, you get transparency. You can see which document chunks were used to generate each answer. Third, you get efficiency. Only relevant chunks are used, which reduces token usage and costs. Finally, you get up-to-date information. You can update your knowledge base by uploading new documents without retraining the AI.</p>
<h2 id="heading-troubleshooting-common-issues">Troubleshooting Common Issues</h2>
<h3 id="heading-storage-rls-error-when-uploading">"Storage RLS error" when uploading</h3>
<p>This means your <code>SUPABASE_SERVICE_ROLE_KEY</code> is not set or incorrect. Make sure the key is in your <code>.env.local</code> file for local development. Also make sure you're using the service role key, not the anon key. Finally, make sure the key is correctly set in your deployment environment, such as Vercel.</p>
<h3 id="heading-failed-to-extract-text-from-file">"Failed to extract text from file"</h3>
<p>Make sure your file is a valid PDF, DOCX, or TXT file. Check that the file isn't corrupted. For PDFs, ensure they contain extractable text. Scanned PDFs with only images won't work without <a target="_blank" href="https://en.wikipedia.org/wiki/Optical_character_recognition">OCR</a>.</p>
<h3 id="heading-no-answer-generated">"No answer generated"</h3>
<p>Make sure you've uploaded at least one document. Try a different query that's more likely to match your documents. Check that embeddings were successfully created. You can verify this in your Supabase database.</p>
<h3 id="heading-vector-similarity-search-not-working">Vector similarity search not working</h3>
<p>Ensure the <code>vector</code> extension is enabled in Supabase. You can do this by running <code>CREATE EXTENSION IF NOT EXISTS vector;</code>. Verify the <code>match_documents</code> function exists in your database. You can check this in the SQL Editor. Check that embeddings are being stored correctly. They should be JSON strings in the embedding column.</p>
<h3 id="heading-slow-search-or-upload-times">Slow search or upload times</h3>
<p>Large documents take longer to process. This is because more chunks mean more embedding API calls. Consider reducing chunk size or processing documents in batches. Also check your OpenAI API rate limits.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>Now that you have a working RAG search application, you can extend it with additional features. Here are some examples of useful features you could add:</p>
<ul>
<li><p>You can add more file types by extending the text extraction to support Markdown, HTML, or other formats.</p>
</li>
<li><p>You can improve chunking by experimenting with different chunk sizes, overlap strategies, or semantic chunking.</p>
</li>
<li><p>You can add authentication to protect your documents with user authentication using Supabase Auth.</p>
</li>
<li><p>You can enhance the UI by adding features like search history, document tags, or advanced filters.</p>
</li>
<li><p>You can optimize performance by adding caching, pagination, or streaming responses.</p>
</li>
<li><p>You can add filters to allow users to search within specific documents or date ranges.</p>
</li>
<li><p>Finally, you can improve search by adding hybrid search, which combines keyword and semantic search, or reranking.</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built a complete RAG search application from scratch. This application demonstrates modern web development with Next.js and TypeScript. It shows vector database operations with Supabase and pgvector. It demonstrates AI integration with OpenAI embeddings and chat completions. It includes file handling and storage with Supabase Storage. Finally, it features a production-ready user interface with Tailwind CSS.</p>
<p>The RAG pattern you've implemented is used by many production applications. These include <a target="_blank" href="https://www.freecodecamp.org/news/how-to-build-an-embeddable-ai-chatbot-widget-with-cloudflare-workers/">chatbots</a>, knowledge bases, document search systems, and AI assistants. You now have the foundation to build more advanced features on top of this.</p>
<p>The skills you've learned are highly valuable in today's AI-driven development landscape. You've learned to work with embeddings, vector databases, and the RAG pattern. You can apply these concepts to build intelligent search systems, document Q&amp;A applications, or AI-powered knowledge bases.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
