Chapter 20: Architect's Pitfall Avoidance Guide
Welcome to the final part of this book. In the past 19 chapters, we've gone through a mindset shift from Python backend to modern JS full-stack together. We've built a feature-complete, observable, testable, safely deployable AI SAAS.
Chapter 20: Architect's Pitfall Avoidance Guide
Welcome to the final part of this book. In the past 19 chapters, we've gone through a mindset shift from Python backend to modern JS full-stack together. We've built a feature-complete, observable, testable, safely deployable AI SAAS.
You're now a full-stack architect. But an architect's job isn't just "building"—it's "anticipating". A great architect can identify those "traps" or "anti-patterns" that seem convenient but have disastrous consequences.
This chapter is our "pitfall avoidance guide" summarized from real-world experience. We'll dive deep into the most common mistakes Next.js beginners make (especially those migrating from traditional backends like Python), particularly around RSC caching and database performance. Mastering these allows you to truly wield this modern tech stack instead of being trapped by it.
20.1. [Skill Practice]: Deep Dive into Common Anti-Patterns
[Skill Practice: claude-nextjs-skills/nextjs-anti-patterns/SKILL.md]
This "anti-pattern" list is a "must-read" every Next.js developer should keep in mind.
Anti-Pattern #1: Importing "use client" Components in Server Components
This is the most common and deadly performance trap.
-
Wrong Approach:
// src/components/HeavyClientComponent.tsx 'use client'; import 'heavy-library'; // Assume this is a 500KB library export default function HeavyClientComponent() { /* ... */ } // src/app/page.tsx (Server Component) import HeavyClientComponent from '@/components/HeavyClientComponent'; // [!] Trap! export default function Page() { // ... some server logic ... return ( <div> <HeavyClientComponent /> </div> ); } -
Why is it a trap?: You might think
Pageis a server component,HeavyClientComponentis a client component, and they're independent. But Next.js's rule is: once a file is marked as"use client", all its dependencies get bundled into the client JS bundle. -
In the above example:
Page.tsxitself isn't marked as"use client", but itimports a client component. That's fine. But what if reversed?// src/components/ClientWrapper.tsx 'use client'; import ServerComponent from '@/components/ServerComponent'; // [!] Trap! export default function ClientWrapper() { return ( <div> <ServerComponent /> {/* This component is now a client component */} </div> ); }In this example,
ServerComponent.tsx(even without"use client"marker) will also be bundled to the client! Because it's imported by a client file. This unwittingly bloats your client JS bundle size. -
Correct Approach (using
childrenprop):// src/components/ClientWrapper.tsx 'use client'; import { useState } from 'react'; // Accept a ReactNode-type children prop export default function ClientWrapper({ children }: { children: React.ReactNode }) { const [count, setCount] = useState(0); return ( <div> <button onClick={() => setCount(c => c + 1)}>Click {count}</button> {children} {/* Render server component here */} </div> ); } // src/app/page.tsx (Server Component) import ClientWrapper from '@/components/ClientWrapper'; import ServerComponent from '@/components/ServerComponent'; // Real data-fetching component export default function Page() { return ( // Pass server component as 'children' to client component <ClientWrapper> <ServerComponent /> </ClientWrapper> ); }In this pattern,
ServerComponentis rendered on the server, and its HTML is "slotted" intoClientWrapper.ClientWrapperand its dependencies are sent to the client, butServerComponentand its data-fetching logic never get sent to the client.
Anti-Pattern #2: Fetching Data in Client Components (useEffect + fetch)
This is the hardest habit to break when migrating from Python/Jinja2 or traditional React SPAs.
-
Wrong Approach (
"use client"):'use client'; import { useState, useEffect } from 'react'; export default function Dashboard() { const [data, setData] = useState(null); useEffect(() => { fetch('/api/my-data') .then(res => res.json()) .then(setData); }, []); if (!data) return <div>Loading...</div>; // [!] Layout shift (CLS) return <div>{data.name}</div>; // [!] Client-server waterfall } -
Why is it a trap?:
- Performance Waterfall: Server returns empty
DashboardHTML → Browser downloads JS → React hydrates →useEffectruns → then makes second request to/api/my-data→ API route then queries database. This round-trip dramatically slows content rendering. - Layout Shift (CLS): Page first shows
Loading..., then replaces with<div>...</div>after data returns, causing content to "jump" and poor Web Vitals.
- Performance Waterfall: Server returns empty
-
Correct Approach (RSC):
// src/app/dashboard/page.tsx (Server Component) import { db } from '@/db'; async function getMyData() { // Directly access database on server return db.query.users.findFirst({ ... }); } export default async function DashboardPage() { // 1. Wait for data on server const data = await getMyData(); // 2. Return HTML with data return <div>{data.name}</div>; }No
useEffect, nouseState, noLoading..., no API route, no client-server waterfall. Server returns complete HTML in one shot for optimal performance.
Anti-Pattern #3: Using Server Actions for "Read" Operations
Server Actions (Chapter 6) are very powerful, but they're designed for "write" operations (Mutations) (POST, PUT, DELETE).
-
Wrong Approach:
// src/actions/data.actions.ts 'use server'; export async function getMyData() { return db.query...; } // src/app/page.tsx import { getMyData } from '@/actions/data.actions'; export default async function Page() { const data = await getMyData(); // [!] Technically works, but violates pattern // ... } -
Why is it a trap?: This works functionally but adds unnecessary abstraction. RSC's core advantage is that it can access data sources directly. Wrapping "read" operations as Server Actions makes code harder to understand ("where is this function called?") and misses out on RSC's simpler caching and data flow patterns.
-
Correct Approach: Keep "read" operations (data fetching) as simple
asyncfunctions withinpage.tsxorlayout.tsx, or in asrc/lib/data.tshelper file. Only use Server Actions for "write" operations (like form submissions).
20.2. RSC Caching Traps
This is the most advanced and error-prone area in Next.js 15+. By default, Next.js caches everything on the server.
Based on knowledge from (cache-components) in next-devtools-mcp library
Trap #1: "Why isn't my data refreshing?" (Full Route Cache)
- Scenario: You wrote a dashboard page
src/app/dashboard/page.tsxthatawait db.query.users...to fetch user data. - Problem: You log in → visit dashboard → log out → log in as different user → visit dashboard... and you see still the first user's data!
- Why is it a trap?: Next.js aggressively caches server component render results by default (full route cache). If your page is dynamic (e.g.,
page.tsx), it's rendered and cached on first request. Subsequent navigation (<Link href="...">) hits this cache and doesn't re-executeawait db.query.... - Correct Approach (Dynamic Rendering):
-
Method A (Recommended): In your data-fetching function, use
fetch(..., { cache: 'no-store' }). If you're not usingfetch(e.g., Drizzle), use Method B. -
Method B (Drizzle/Prisma users): Add at the top of your
page.tsxfile:export const dynamic = 'force-dynamic'; // This tells Next.js to never cache this page, // re-render on every request. -
Method C (On-demand revalidation): If you want the page cached but refreshed after specific events (e.g., user updates profile), you must call
revalidatePath('/dashboard')in the corresponding Server Action.
-
Trap #2: fetch vs Drizzle/Prisma (Caching & Deduplication)
-
Scenario: You call
await db.query.users.findFirst(...)in both a<Navbar />(RSC) at the top and<Profile />(RSC) at the bottom of a page to fetch current user info. -
Problem: You check server logs and find Next.js makes two
SELECTqueries to your database, even though they're requesting identical data. -
Why is it a trap?: Next.js only automatically provides request deduplication for the
fetchAPI. It can't automatically recognize that yourdb.querycalls are identical. -
Correct Approach (using React
cache):// src/lib/data.ts import { cache } from 'react'; import { db } from '@/db'; // Key: Wrap your database call in React's cache() function export const getCachedUser = cache(async (userId: string) => { console.log('FETCHING FROM DB:', userId); return db.query.users.findFirst({ where: eq(users.id, userId) }); }); // src/components/Navbar.tsx (RSC) import { getCachedUser } from '@/lib/data'; export async function Navbar() { const user = await getCachedUser('user_123'); // First call // ... } // src/components/Profile.tsx (RSC) import { getCachedUser } from '@/lib/data'; export async function Profile() { const user = await getCachedUser('user_123'); // Second call // ... }Now, when
NavbarandProfileare called in the same render,console.log('FETCHING FROM DB...')will only print once. Thecachefunction "remembers" the result of calls with the same parameters ('user_123') in the same render and returns it immediately, avoiding N+1 queries.
20.3. Supabase vs Drizzle Performance Traps
Finally, let's review the architectural choice we made in Chapter 7 and examine the performance pitfalls of each path.
Supabase Trap #1: Connection Pool (PgBouncer) Exhaustion
- Problem: Your SAAS app goes live, and suddenly Sentry (Chapter 17) starts alerting "Too many clients", app crashes.
- Why is it a trap?: As mentioned in section 7.2.3, Supabase (and any Postgres) has connection limits. Next.js's Serverless/Edge environment (RSC, Server Actions) can create a new database connection for every concurrent request. 1000 concurrent users = 1000 connections = database down.
- Avoidance Guide (Rule 1):
- Must use Supabase's provided PgBouncer connection pooling mode.
- Ensure your database connection string includes
?pgbouncer=true. - Don't create "global"
supabaseclient instances in your Serverless functions. Ensure connections are properly released after function execution.
Supabase Trap #2: Slow RLS (Row-Level Security) Policies
- Problem: Your app runs fast locally, but in production, all data queries (
SELECT) become extremely slow. - Why is it a trap?: RLS (Rule 2) is very powerful, but it attaches to every single SQL query. If for convenience you write a complex
JOINin an RLS policy or call a custom SQL function (e.g.,check_user_permission()), this complex operation executes on everySELECT. - Avoidance Guide:
- Keep RLS policies extremely simple.
- Ideal RLS policies should only contain direct comparisons between
auth.uid()and columns in the queried table (e.g.,USING (auth.uid() = user_id)). - If you need complex permission checks, perform them in your application layer (Server Action or Server Component), not in RLS.
Drizzle Trap #1: N+1 Queries (Forgetting with:)
-
Problem: Your dashboard page needs to load 100 "project" records and display each project's "owner" name. Page load takes 10 seconds.
-
Why is it a trap?: This is the classic N+1 query trap for Drizzle (or any ORM).
// Wrong approach (101 queries) const projects = await db.query.projects.findMany(); // 1 query const projectsWithOwners = await Promise.all( projects.map(async (p) => { // N queries (N = projects.length) const owner = await db.query.users.findFirst({ where: eq(users.id, p.ownerId) }); return { ...p, owner }; }) ); -
Avoidance Guide: Always use Drizzle's
with:syntax to eagerly load related data.// Correct approach (1 query) const projectsWithOwners = await db.query.projects.findMany({ with: { owner: true // Tell Drizzle to JOIN users table in same query } });Drizzle compiles this to an efficient
JOINSQL statement, reducing queries from 101 to 1.
Drizzle Trap #2: Using db:push in Production
- Problem: You just want to add a small field to the
userstable. You modifyschema.tslocally, then (like in development) runpnpm db:pushon the production server. Suddenly, yourprojectstable (and all data) disappears. - Why is it a trap?:
drizzle-kit db:pushis a destructive development tool. It "syncs" database state to match your schema. If it thinks you need to drop a table to add a new field (e.g., due to complex constraint changes), it will unhesitatinglyDROP TABLE. - Avoidance Guide (Rule 10):
- Never use
db:pushin production. - Always use the migration workflow (Chapter 10):
pnpm db:generate: Generate a SQL migration file (e.g.,0001_add_user_avatar.sql).- Manually review this SQL file to ensure it only does what you want (e.g.,
ALTER TABLEnotDROP TABLE). pnpm db:migrate: Safely run this reviewed SQL file in production (or via CI).
- Never use
Chapter 20 Summary
In this chapter, we confronted the most common and dangerous pitfalls when transitioning from Python traditional backends to Next.js full-stack architecture. These anti-patterns and performance issues are what distinguish "usable" from "excellent" architects.
- RSC Anti-Patterns: We learned how to properly compose server and client components using
childrenprops, avoiding unintended client JS bundle bloat. We emphasized that data must be fetched in server components to avoid client-server waterfalls and layout shifts. - RSC Caching Traps: We revealed the "double-edged sword" of Next.js's aggressive caching strategy. We learned to use
export const dynamic = 'force-dynamic'for dynamic data and React'scache()function to prevent N+1 database queries in the same render. - Database Performance Traps: We revisited core challenges with Supabase and Drizzle. For Supabase, the key is proper PgBouncer (connection pooling) usage and keeping RLS policies simple. For Drizzle, the key is using
with:to preload data and avoid N+1 queries, and always usingdb:migrate(notdb:push) to manage production database changes.
This book's journey is nearing its end. You've mastered full-stack skills from frontend UI to backend API, from database architecture to DevOps pipelines, from payment integration to AI services. What you possess now is not just the label of "Python developer" or "Next.js developer", but the vision and capabilities of a "full-stack architect".
Categories
"use client" Components in Server ComponentsAnti-Pattern #2: Fetching Data in Client Components (useEffect + fetch)Anti-Pattern #3: Using Server Actions for "Read" Operations20.2. RSC Caching TrapsTrap #1: "Why isn't my data refreshing?" (Full Route Cache)Trap #2: fetch vs Drizzle/Prisma (Caching & Deduplication)20.3. Supabase vs Drizzle Performance TrapsSupabase Trap #1: Connection Pool (PgBouncer) ExhaustionSupabase Trap #2: Slow RLS (Row-Level Security) PoliciesDrizzle Trap #1: N+1 Queries (Forgetting with:)Drizzle Trap #2: Using db:push in ProductionChapter 20 SummaryMore Posts
Chapter 22: Conclusion - Becoming a Next.js Full-Stack Architect
If you've followed along from Chapter 1, you've made a remarkable journey: from an experienced Python backend developer to a full-stack architect who can navigate the modern JavaScript ecosystem.
Chapter 8: SAAS Authentication: Auth Solution Comparison
In Chapter 7, we chose the ORM path, deciding to use Drizzle to control our database. This decision directly impacts our Authentication solution.
Chapter 15: Code Quality (Biome) and Content (Fumadocs)
In Chapter 14, we established a solid CI/CD pipeline that acts as the 'gatekeeper' for our SAAS application. One of the core responsibilities of this gatekeeper is running pnpm lint and pnpm typecheck. But what's actually working behind these commands?