Att strukturera Next.js-projekt i stor skala: Vad jag lärde mig den hårda vagen
Dyrkøpte lärdomar om att organisera Next.js-kodbaser som inte kollapsar under sin egen tyngd. Funktionsbaserad arkitektur, route groups, server/klient-granser, barrel file-fallor och en riktig mappstruktur du kan stjala.
Varje Next.js-projekt börjar rent. Några sidor, en components-mapp, kanske en lib/utils.ts. Du kanns dig produktiv. Du kanns dig organiserad.
Sa gar det sex manader. Tre utvecklare ansluter. Components-mappen har 140 filer. Någon skapade utils2.ts for att utils.ts var 800 rader. Ingen vet vilken layout som omsluter vilken route. Din build tar fyra minuter och du ar inte säker pa varför.
Jag har gatt igenom den har cykeln flera ganger. Det som foljer ar vad jag faktiskt gor nu, efter att ha lart mig de flesta av dessa lärdomar genom att leverera fel sak forst.
Standardstrukturen faller samman snabbt#
Next.js-dokumentationen foreslar en projektstruktur. Den ar okej. Den fungerar for en personlig blogg eller ett litet verktyg. Men i samma ogonblick som mer an tva utvecklare arbetar i samma kodbas börjar det "typbaserade" tillvagagangssattet spricka:
src/
components/
Button.tsx
Modal.tsx
UserCard.tsx
DashboardSidebar.tsx
InvoiceTable.tsx
InvoiceRow.tsx
InvoiceActions.tsx
...137 more files
hooks/
useAuth.ts
useInvoice.ts
useDebounce.ts
utils/
format.ts
validate.ts
misc.ts <- borjan pa slutet
app/
page.tsx
dashboard/
page.tsx
Problemet ar inte att detta ar "fel." Det ar att det skalar linjart med funktioner men inte med forstaelse. Nar en ny utvecklare behover arbeta med fakturor maste de oppna components/, hooks/, utils/, types/ och app/ samtidigt. Fakturafunktionen ar utspridd över fem kataloger. Det finns ingen enskild plats som sager "sa har ser fakturering ut."
Jag sag ett team pa fyra utvecklare lagga en hel sprint pa att refaktorera till exakt den har strukturen, overtygade om att det var det "rena" sattet. Inom tre manader var de tillbaka till att vara forvirrade, bara med fler mappar.
Funktionsbaserad samlokalisering vinner. Varje gang.#
Monstret som faktiskt overlevde kontakt med ett riktigt team ar funktionsbaserad samlokalisering. Iden ar enkel: allt som ror en funktion lever tillsammans.
Inte "alla komponenter i en mapp och alla hooks i en annan." Tvartom. Alla komponenter, hooks, typer och verktyg for fakturering lever i features/invoicing/. Allt for autentisering lever i features/auth/.
// features/invoicing/hooks/use-invoice-actions.ts
import { useCallback } from "react";
import { Invoice } from "../types";
import { formatInvoiceNumber } from "../utils/format";
import { invoiceApi } from "../api/client";
export function useInvoiceActions(invoice: Invoice) {
const markAsPaid = useCallback(async () => {
await invoiceApi.updateStatus(invoice.id, "paid");
}, [invoice.id]);
const duplicate = useCallback(async () => {
const newNumber = formatInvoiceNumber(Date.now());
await invoiceApi.create({ ...invoice, number: newNumber, status: "draft" });
}, [invoice]);
return { markAsPaid, duplicate };
}Lagg marke till importerna. De ar alla relativa, alla inom funktionen. Den har hooken stracker sig inte till någon global hooks/-mapp eller en delad utils/format.ts. Den anvander faktureringsfunktionens egen formateringslogik for att formatering av fakturanummer ar ett faktureringsproblem, inte ett globalt.
Den kontraintuitiva delen: detta innebar att du kommer att ha viss duplicering. Din faktureringsfunktion kan ha en format.ts och din debiteringsfunktion kan också ha en format.ts. Det ar okej. Det ar faktiskt bättre an en delad formatters.ts som 14 funktioner ar beroende av och ingen säkert kan andra.
Jag motstod det har lange. DRY var evangelium. Men jag har sett delade utility-filer bli de farligaste filerna i en kodbas, filer dar en "liten fix" for en funktion forstorer tre andra. Samlokalisering med kontrollerad duplicering ar mer underhallbart an patvingad delning.
Mappstrukturen jag faktiskt anvander#
Har ar hela tradet. Det har stridstestats i tva produktionsapplikationer med team pa 4-8 personer:
src/
├── app/
│ ├── (public)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── pricing/
│ │ │ └── page.tsx
│ │ └── blog/
│ │ ├── page.tsx
│ │ └── [slug]/
│ │ └── page.tsx
│ ├── (auth)/
│ │ ├── layout.tsx
│ │ ├── login/
│ │ │ └── page.tsx
│ │ └── register/
│ │ └── page.tsx
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── page.tsx
│ │ ├── invoices/
│ │ │ ├── page.tsx
│ │ │ ├── [id]/
│ │ │ │ └── page.tsx
│ │ │ └── new/
│ │ │ └── page.tsx
│ │ └── settings/
│ │ └── page.tsx
│ ├── api/
│ │ └── webhooks/
│ │ └── stripe/
│ │ └── route.ts
│ ├── layout.tsx
│ └── not-found.tsx
├── features/
│ ├── auth/
│ │ ├── components/
│ │ │ ├── login-form.tsx
│ │ │ ├── register-form.tsx
│ │ │ └── oauth-buttons.tsx
│ │ ├── actions/
│ │ │ ├── login.ts
│ │ │ └── register.ts
│ │ ├── hooks/
│ │ │ └── use-session.ts
│ │ ├── types.ts
│ │ └── constants.ts
│ ├── invoicing/
│ │ ├── components/
│ │ │ ├── invoice-table.tsx
│ │ │ ├── invoice-row.tsx
│ │ │ ├── invoice-form.tsx
│ │ │ └── invoice-pdf.tsx
│ │ ├── actions/
│ │ │ ├── create-invoice.ts
│ │ │ └── update-status.ts
│ │ ├── hooks/
│ │ │ └── use-invoice-actions.ts
│ │ ├── api/
│ │ │ └── client.ts
│ │ ├── utils/
│ │ │ └── format.ts
│ │ └── types.ts
│ └── billing/
│ ├── components/
│ ├── actions/
│ ├── hooks/
│ └── types.ts
├── shared/
│ ├── components/
│ │ ├── ui/
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ ├── modal.tsx
│ │ │ └── toast.tsx
│ │ └── layout/
│ │ ├── header.tsx
│ │ ├── footer.tsx
│ │ └── sidebar.tsx
│ ├── hooks/
│ │ ├── use-debounce.ts
│ │ └── use-media-query.ts
│ ├── lib/
│ │ ├── db.ts
│ │ ├── auth.ts
│ │ ├── stripe.ts
│ │ └── email.ts
│ └── types/
│ └── global.ts
└── config/
├── site.ts
└── navigation.ts
Några säker att lagga marke till:
app/innehaller bara routinglogik. Sidfiler ar tunna. De importerar franfeatures/och komponerar. En sidfil bor vara 20-40 rader, max.features/ar dar den riktiga koden lever. Varje funktion ar fristdende. Du kan ta bortfeatures/invoicing/och inget annat gar sönder (forutom sidorna som importerar fran den, vilket ar precis den koppling du vill ha).shared/ar inte en samlingslada. Den har strikta underkategorier. Mer om detta nedan.
Route Groups: Mer an bara organisation#
Route groups, mapparna med (parenteser), ar en av de mest underutnyttjade funktionerna i App Router. De paverkar inte URL:en alls. (dashboard)/invoices/page.tsx renderar pa /invoices, inte /dashboard/invoices.
Men den verkliga kraften ar layout-isolering. Varje route group far sin egen layout.tsx:
// app/(dashboard)/layout.tsx
import { redirect } from "next/navigation";
import { getSession } from "@/shared/lib/auth";
import { Sidebar } from "@/shared/components/layout/sidebar";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getSession();
if (!session) {
redirect("/login");
}
return (
<div className="flex min-h-screen">
<Sidebar user={session.user} />
<main className="flex-1 p-6">{children}</main>
</div>
);
}// app/(public)/layout.tsx
import { Header } from "@/shared/components/layout/header";
import { Footer } from "@/shared/components/layout/footer";
export default function PublicLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<>
<Header />
<main>{children}</main>
<Footer />
</>
);
}Autentiseringskontrollen sker en gang pa layout-nivan, inte pa varje sida. Publika sidor far ett helt annat skal. Autentiseringssidor far ingen layout alls, bara ett centrerat kort. Det ar rent, explicit och svart att gora fel.
Jag har sett team lagga autentiseringskontroller i middleware, i individuella sidor, i komponenter, allt samtidigt. Valj en plats. For de flesta appar ar route group-layouten den platsen.
shared/-mappen ar inte utils/#
Det snabbaste sattet att skapa en underhallsmardrom ar en lib/utils.ts-fil. Den börjar smatt. Sedan blir den kodbasens skraplade, dar varje funktion som inte har ett uppenbart hem hamnar.
Har ar min regel: shared/-mappen kraver motivering. Något hamnar i shared/ bara om:
- Det anvands av 3+ funktioner, och
- Det ar genuint generiskt (inte affarslogik forkladd som generisk)
En datumformateringsfunktion som formaterar forfallodatum pa fakturor? Den hamnar i features/invoicing/utils/. En datumformateringsfunktion som tar ett Date och returnerar en lokal strang? Den kan hamna i shared/.
Undermappen shared/lib/ ar specifikt for tredjepartsintegrationer och infrastruktur: databasklienter, autentiseringsbibliotek, betaltjanstleverantorer. Det ar säker som hela applikationen ar beroende av men som ingen enskild funktion ager.
// shared/lib/db.ts — den har hor hemma i shared
import { drizzle } from "drizzle-orm/neon-http";
import { neon } from "@neondatabase/serverless";
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
// features/invoicing/utils/format.ts — den har hor INTE hemma i shared
export function formatInvoiceNumber(timestamp: number): string {
const date = new Date(timestamp);
const year = date.getFullYear();
const seq = Math.floor(Math.random() * 9000) + 1000;
return `INV-${year}-${seq}`;
}Om du stracker dig efter shared/utils/ och inte kan namnge filen något mer specifikt an helpers.ts, stanna upp. Den koden hor förmodligen hemma i en funktion.
Server- vs. klientkomponenter: Dra gransen medvetet#
Har ar ett monster jag konstant ser i kodbaser som har prestandaproblem:
// Det har ser rimligt ut men ar ett misstag
"use client";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
import { useInvoiceActions } from "@/features/invoicing/hooks/use-invoice-actions";
export default function InvoicesPage() {
const { data } = useInvoiceActions();
return (
<div>
<InvoiceFilters />
<InvoiceTable invoices={data} />
</div>
);
}Sidan ar markerad med "use client". Nu hamnar allt den importerar, InvoiceTable, InvoiceFilters, hooken, varje beroende av varje beroende, i klientbundlen. Din 200-raders tabell som kunde ha renderats pa servern utan någon JavaScript skickas nu till webblasaren.
Losningen ar att trycka ner "use client" till lovkomponenterna som faktiskt behover det:
// app/(dashboard)/invoices/page.tsx — Server Component (ingen direktiv)
import { db } from "@/shared/lib/db";
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
import { InvoiceFilters } from "@/features/invoicing/components/invoice-filters";
export default async function InvoicesPage() {
const invoices = await db.query.invoices.findMany({
orderBy: (inv, { desc }) => [desc(inv.createdAt)],
});
return (
<div>
<InvoiceFilters /> {/* Klientkomponent — har interaktiva filter */}
<InvoiceTable data={invoices} /> {/* Serverkomponent — renderar bara rader */}
</div>
);
}// features/invoicing/components/invoice-filters.tsx
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export function InvoiceFilters() {
const router = useRouter();
const params = useSearchParams();
const setStatus = (status: string) => {
const next = new URLSearchParams(params.toString());
next.set("status", status);
router.push(`?${next.toString()}`);
};
return (
<div className="flex gap-2">
{["all", "draft", "sent", "paid"].map((s) => (
<button
key={s}
onClick={() => setStatus(s)}
className={params.get("status") === s ? "font-bold" : ""}
>
{s}
</button>
))}
</div>
);
}Regeln jag foljer: varje komponent ar en Server Component tills den behover useState, useEffect, event handlers eller webblasarens API:er. Forst da, och bara da, lagg till "use client", och forsok gora den komponenten sa liten som möjligt.
Children-monstret ar din basta van har. En klientkomponent kan ta emot serverkomponenter som children:
"use client";
export function InteractiveWrapper({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && children} {/* children kan vara serverkomponenter */}
</div>
);
}Sa har haller du 90% av ditt komponenttrad pa servern samtidigt som du har interaktivitet dar det behovs.
Barrel-filer: Den tysta build-dodaren#
Jag brukade lagga en index.ts i varje mapp. Rena importer, eller hur?
// features/invoicing/components/index.ts
export { InvoiceTable } from "./invoice-table";
export { InvoiceRow } from "./invoice-row";
export { InvoiceForm } from "./invoice-form";
export { InvoicePdf } from "./invoice-pdf";Sedan kunde jag importera sa har:
import { InvoiceTable } from "@/features/invoicing/components";Ser snyggt ut. Har ar problemet: nar bundlern ser den importen maste den utvardera hela barrel-filen for att ta reda pa vad InvoiceTable ar. Det innebar att den laddar InvoiceRow, InvoiceForm och InvoicePdf också, även om du bara behover tabellen.
I ett projekt minskar borttagning av interna barrel-filer modulantalet fran 11 000 till 3 500 under utveckling. Devservern gick fran 5-10 sekunders sidladdningar till under 2 sekunder. Forsta laddningens JavaScript minskar fran över en megabyte till runt 200 KB.
Vercel byggde optimizePackageImports i next.config.ts for att hantera detta for tredjepartspaket fran node_modules. Men det fixar inte dina egna barrel-filer. For din egen kod ar svaret enklare: anvand dem inte.
// Gor sa har
import { InvoiceTable } from "@/features/invoicing/components/invoice-table";
// Inte sa har
import { InvoiceTable } from "@/features/invoicing/components";Ja, importsokgvagen ar langre. Din build ar snabbare, dina bundlar ar mindre och din devserver kvavs inte. Det ar ett byte jag tar varje gang.
Det enda undantaget: en funktions publika API. Om features/invoicing/ exponerar en enda index.ts pa funktionens rotniva med bara de 2-3 sakerna andra funktioner far importera, sa ar det okej. Det fungerar som en grans, inte en bekvamlighetsgenbesvag. Men barrel-filer i undermappar som components/index.ts eller hooks/index.ts? Avskaffa dem.
Tunna sidor, feta funktioner#
Den viktigaste arkitekturregeln jag foljer ar denna: sidfiler ar koppling, inte logik.
En sidfil i app/ bor gora tre säker:
- Hamta data (om det ar en Server Component)
- Importera funktionskomponenter
- Komponera dem tillsammans
Det ar allt. Ingen affarslogik. Ingen komplex villkorlig rendering. Inga 200-raders JSX-trad. Om en sidfil vaxer forbi 40 rader lagger du funktionskod pa fel plats.
// app/(dashboard)/invoices/[id]/page.tsx
import { notFound } from "next/navigation";
import { db } from "@/shared/lib/db";
import { InvoiceDetail } from "@/features/invoicing/components/invoice-detail";
interface Props {
params: Promise<{ id: string }>;
}
export default async function InvoicePage({ params }: Props) {
const { id } = await params;
const invoice = await db.query.invoices.findFirst({
where: (inv, { eq }) => eq(inv.id, id),
});
if (!invoice) notFound();
return <InvoiceDetail invoice={invoice} />;
}Tolv rader. Sidan hamtar den data den behover, hanterar not-found-fallet och delegerar allt annat till funktionen. Om fakturans detaljvy ändras redigerar du features/invoicing/, inte app/. Om routen ändras flyttar du sidfilen, och funktionskoden bryr sig inte.
Sa har far du en kodbas dar 15 utvecklare kan arbeta samtidigt utan att trampa pa varandra.
Den obekvama sanningen#
Det finns ingen mappstruktur som forhindrar dalig kod. Jag har sett vackert organiserade projekt fulla av hemska abstraktioner, och rorigt platta strukturer som var latta att arbeta i for att koden i sig var tydlig.
Struktur ar ett verktyg, inte ett mal. Det funktionsbaserade tillvagagangssattet jag beskrivit har fungerar for att det optimerar for det som spelar mest roll i stor skala: kan en utvecklare som aldrig har sett den har kodbasen hitta det de behover och andra det utan att forstora något annat?
Om din nuvarande struktur svarar "ja" pa den fragan, refaktorera den inte for att ett blogginlagg sa at du skulle gora det, inte ens det har.
Men om dina utvecklare spenderar mer tid pa att navigera i kodbasen an pa att skriva kod, om "var ska det har ligga?" ar en aterkommande fraga i PR:er, om en andring i debiteringen pa något satt forstort instrumentpanelen, da kampar din struktur emot dig. Och monstren ovan ar hur jag har fixat det, varje gang.