cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

Structuring a large app

Organize a growing Next.js app by feature: folders that make no routes, a server-only data module per feature, shared UI, @/ imports and ESLint rules.

By the end, you can
  • 01
    Choose a folder layoutSort a growing app's files by kind, by feature or beside the routes, knowing that only page and route files make addresses.
  • 02
    Keep each feature's data on the serverGive each feature one server-only way in for its data, keep small helpers apart from it, and spot a barrel file that mixes server and client code.
  • 03
    Share UI and write rules downKeep shared components plain, put "use client" on the leaves, import with @/, and let ESLint check which folder may import which.

One tea shop, two ways to sort its files

The tea shop has grown. It has teas, reviews, a cart, accounts, and a team for each. Next.js decides what the files in app do, and leaves the rest of your files to you (Project structure showed where each kind goes). In a small app, any order works. In a large one, where a file lives decides how many folders you open for one change, and whether two teams end up changing the same files. Here are the shop's teas and reviews, sorted the way many apps start, by kind of file:

Before: the shop sorted by kind of fileText
app/
  reviews/
    page.tsx
  teas/
    page.tsx
  layout.tsx
components/
  add-to-cart.tsx
  card.tsx
  review-list.tsx
  tea-card.tsx
lib/
  format.ts
  reviews.ts
  teas.ts

components holds every component and lib every other module. The teas are four files: components/tea-card.tsx, components/add-to-cart.tsx, lib/teas.ts and lib/format.ts. They sit in two folders, between the reviews' files. With ten features, each folder would hold dozens of files from all of them. Now the same files, sorted by feature: a part of the shop that a visitor would name, like the teas or the reviews.

After: the same shop sorted by featureText
app/
  reviews/
    page.tsx
  teas/
    page.tsx
  layout.tsx
features/
  reviews/
    dal.ts
    review-list.tsx
  teas/
    add-to-cart.tsx
    dal.ts
    format.ts
    tea-card.tsx
ui/
  card.tsx

features/teas holds everything about teas: the card, the button, the module that reads the teas, and a price helper. features/reviews does the same for reviews. ui/card.tsx is the one component both features use, so it sits below them, in a folder for shared UI. And app holds only routes. This version is a real Next.js 16.4.3 project, built with next build and run with next start; what it sent back was recorded. Click a file in the tree to read it.

The tea shop sorted by featureNext.js
app/reviews/teas/features/reviews/teas/ui/
app/teas/page.tsx
import TeaCard from "@/features/teas/tea-card";import { getTeas } from "@/features/teas/dal"; export default async function TeasPage() {  const teas = await getTeas();  return (    <main>      <h1>Teas</h1>      {teas.map((tea) => (        <TeaCard key={tea.id} tea={tea} />      ))}    </main>  );}
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
Teas
Green tea
Add for $4
Mint tea
Add for $3

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

In app/teas/page.tsx, line 1 imports TeaCard from @/features/teas/tea-card, and line 2 imports getTeas from @/features/teas/dal. Line 5 waits for the teas, and lines 9 to 11 draw a card for each. The page is short: it puts the feature's parts together, and the feature does the work. The Browser tab shows the two teas with their buttons. The Terminal tab lists the routes: ○ /_not-found, ○ /reviews and ○ /teas, all prerendered as static content. In the Network tab, /features/teas/tea-card answered 404: a folder outside app is never part of an address.

Two editor windows. Left, the shop sorted by kind of file: the four teas files, shaded, are spread between components and lib, among the reviews files. Right, sorted by feature: the four teas files sit together in features/teas, with the reviews in features/reviews and the shared card in ui. Under both, the build lists the same routes: /_not-found, /reviews and /teas.By kind of fileby kindcomponents/tea-card.tsxcomponents/review-list.tsxcomponents/add-to-cart.tsxlib/reviews.tslib/teas.tslib/format.tsBy featureby featurefeatures/teas/tea-card.tsxfeatures/teas/add-to-cart.tsxfeatures/teas/dal.tsfeatures/teas/format.tsfeatures/reviews/…ui/card.tsx○ /reviews, ○ /teas○ /reviews, ○ /teas
The shaded cards are the teas' four files. Sorted by kind of file, they're spread across components and lib. Sorted by feature, they sit together in features/teas. Both builds list the same routes, because only the files in app make them.
Real-life analogyA kitchen sorted by station

A small kitchen can keep all its knives in one drawer and all its bowls on one shelf. A busy restaurant kitchen sorts by station instead: the salad station has its own knife, bowl and dressing within reach, so the cook never crosses the room. The plates that every station uses sit on one shared shelf. The guests only see the menu.

In real life: A drawer with every knife in the kitchen
In Next.js: components/: every component in the app
In real life: The salad station, with its own knife, bowl and dressing
In Next.js: features/teas/: the teas' card, button, data module and helper
In real life: The plates every station uses, on one shared shelf
In Next.js: ui/: shared components, like Card
In real life: The menu the guests read
In Next.js: app/: the routes visitors open
The main idea

Next.js gives addresses only to the routing files in app, like page.tsx and route.ts; where the rest of your code lives is your choice. For a growing app, keep app for routes, group the other files by feature, give each feature one server-only module for its data, keep shared UI plain, and write the rules down where a tool can check them.

By kind of file, by feature, or beside the routes

Next.js's docs call the framework unopinionated about how you organize your project, and describe a few common ways. Three of them, for the same shop. Each row is one layout: where the teas' files go, and what that gives you.

Three ways to sort the same files
LayoutWhere the teas' files areWhat it gives you
By kind of filecomponents/tea-card.tsx, components/add-to-cart.tsx, lib/teas.ts, lib/format.ts: one folder for each kind of file.It's quick to start. As the shop grows, components and lib fill with files from every part of it, and one change to teas touches both.
By featurefeatures/teas/: the card, the button, the data module and the helper, side by side. Shared parts go in ui/.A change to teas stays in one folder, and a team can own one. Code that two features need goes down into ui/.
Beside the routesapp/teas/: next to the page that uses them. Shared parts go in a private folder, app/_ui/.The route and its parts live together. When two routes need the same code, it has to move somewhere both can reach.

The third layout keeps each route's files in its own folder in app, next to its page.tsx. That's called colocation, and its tree looks like this:

Beside the routes: the same shop, colocated in appText
app/
  _ui/
    card.tsx
  reviews/
    dal.ts
    page.tsx
    review-list.tsx
  teas/
    add-to-cart.tsx
    dal.ts
    format.ts
    page.tsx
    tea-card.tsx
  layout.tsx

Each layout below is the same shop, built and run on its own. Before you click, guess: does any of them change the routes the build lists?

Playground: does the layout change the routes?
The tea shop, in this layoutNext.js
app/reviews/teas/features/reviews/teas/ui/
app/teas/page.tsx
import TeaCard from "@/features/teas/tea-card";import { getTeas } from "@/features/teas/dal"; export default async function TeasPage() {  const teas = await getTeas();  return (    <main>      <h1>Teas</h1>      {teas.map((tea) => (        <TeaCard key={tea.id} tea={tea} />      ))}    </main>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /_not-found├ ○ /reviews└ ○ /teas ○  (Static)  prerendered as static content
Try it yourself
By feature

app holds only routes. Each feature's files live in features/, and the shared Card in ui/. The build lists ○ /reviews and ○ /teas, and /features/teas/tea-card answered 404.

Every version was built with next build from Next.js 16.4.3 and run with next start. Only where the files sit changes; their code is the same. The Network tab has the 404s. Reset goes back to the first layout.

None did. All three builds list ○ /_not-found, ○ /reviews and ○ /teas, and the pages show the same teas and reviews. In the colocated layout, app/teas/tea-card.tsx sits right beside page.tsx, and /teas/tea-card still answered 404, like /teas/dal and /_ui/card. Next.js only gives an address to a folder with a page.tsx or a route.ts in it, and a private folder like _ui never gets one, whatever it holds (Route groups and private folders).

No address isn't the same as private

A file without an address can still reach the browser. When a Client Component imports it, its code is sent with the component, and whatever a page renders is sent in its HTML and its RSC payload, the description of the rendered Server Components that goes with it. Keep secrets in server-only modules and server environment variables (Server-only code and environment variables).

So pick the layout for the people who read the code, not for Next.js. Small apps do well by kind of file. When components and lib fill with files from every part of the shop, sort by feature. The layouts also mix: the docs describe keeping code that the whole app shares at the top and splitting the rest by route, and route groups, folders in round brackets like (shop), sort the routes themselves by section or by team without changing an address.

A data access module for each feature

Every feature reads data somewhere. Give each one a single server-only way in, here features/teas/dal.ts: the teas' data access layer, or DAL, the name Next.js's data security guide gives the code that every read of your data goes through. Here it's one file; in a bigger feature it can be several modules behind one entry file, each starting with import "server-only". Authorization and the data access layer built one for orders, with a check of who is asking in every function. Teas are public, so this one only reads and picks fields:

features/teas/dal.ts: every read of the teas, in one fileTypeScript
import "server-only"; // A stand-in for the teas table. `cost` is what the shop pays: staff only.const rows = [  { id: "green", name: "Green tea", price: 4, cost: 1.2 },  { id: "mint", name: "Mint tea", price: 3, cost: 0.9 },]; export type Tea = { id: string; name: string; price: number }; export async function getTeas(): Promise<Tea[]> {  return rows.map(({ id, name, price }) => ({ id, name, price }));} 
  • Line 1, import "server-only", makes the build stop if a Client Component's code imports this file (Server-only code showed the error).
  • Lines 4 to 7 stand in for the teas table. Each row has a cost, what the shop pays for the tea, which only staff may see.
  • Line 9 names the shape the rest of the app gets: Tea, with an id, a name and a price.
  • Line 12 returns those three fields and leaves cost behind. An object made only to carry data out of a layer like this is a DTO, a data transfer object.

With one way in for each feature, the questions "where are the teas read?" and "which fields can a page get?" each have one answer. features/reviews/dal.ts does the same for reviews, and leaves each reviewer's email behind. Not every file in the feature belongs in it, though. Each row is one piece of the teas feature, and the file it goes in:

Which file gets which code
The codeWhere it goesWhy
Reading the teas table, or calling a service with a keyfeatures/teas/dal.ts, which starts with import "server-only".Every read goes through one file that only runs on the server and returns only the fields pages need.
Formatting a pricefeatures/teas/format.ts, with no server-only line.It holds no secrets, and the Add to cart button needs it in the browser.
The card that shows a teafeatures/teas/tea-card.tsx, a Server Component.It renders on the server, so its code stays there.
The Add to cart buttonfeatures/teas/add-to-cart.tsx, with "use client" at the top.It keeps state and handles clicks, so its code goes to the browser.

The Add to cart button shows the price, so it needs formatPrice. Here are three versions of the teas feature. Before you click, guess which ones build.

Playground: who may import the data module?
The teas feature, in this versionNext.js
app/reviews/teas/features/reviews/teas/ui/
app/teas/page.tsx
import TeaCard from "@/features/teas/tea-card";import { getTeas } from "@/features/teas/dal"; export default async function TeasPage() {  const teas = await getTeas();  return (    <main>      <h1>Teas</h1>      {teas.map((tea) => (        <TeaCard key={tea.id} tea={tea} />      ))}    </main>  );}
Recorded from Next.js 16.4.3
$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /_not-found├ ○ /reviews└ ○ /teas ○  (Static)  prerendered as static content
Try it yourself
The page imports it

Line 2 of the teas page imports getTeas from the data module. The page is a Server Component, so the module stays on the server, and the build lists ○ /teas.

Every version was built with next build from Next.js 16.4.3. The Terminal shows what it printed. Reset goes back to the first version.

What the build did in each version:

  • The page imports it: the teas page is a Server Component, so dal.ts stays on the server. This is the overview's shop.
  • The button imports a helper from it: someone put formatPrice in dal.ts, and the button imported it from there. The build stopped: 'server-only' cannot be imported from a Client Component module. (The first of its two messages mentions the Pages Router, which this app doesn't use.) The import trace under it starts with ./features/teas/dal.ts [Client Component Browser] and ./features/teas/add-to-cart.tsx [Client Component Browser]: a Client Component's imports go to the browser with it, the whole module, not only the function it uses.
  • The button imports through a barrel: a barrel file is a file that only re-exports other files, so the rest of the app can import a feature from one place. This one, features/teas/index.ts, re-exports the whole feature, and line 2 re-exports dal.ts. The button imported only formatPrice, from @/features/teas, and the build stopped with the same error. This time the trace runs through ./features/teas/index.ts [Client Component Browser].
features/teas/index.ts: a barrel with server and client code in itTypeScript
export { default as AddToCart } from "./add-to-cart";export { getTeas, type Tea } from "./dal";export { formatPrice } from "./format";export { default as TeaCard } from "./tea-card"; 

Line 1 re-exports the button, a Client Component, and line 2 the data module, which may only run on the server. When the button imports from this barrel, the compiler follows the barrel's imports to build the client's module graph, the list of modules the browser code depends on, and line 2 puts dal.ts in it. The server-only check looks at that graph, so the build stopped, although the button never uses getTeas. That's a separate question from how many bytes ship: a bundler may leave out exports nobody uses, but only when it can tell that dropping them changes nothing. Two fixes work: a client file imports the file it needs (./format), or a feature keeps no server-only module in a barrel that client code uses. The overview's shop has no barrel at all. Barrels can cost bytes too: Bundle size recorded a barrel of the shop's own files bringing a whole Markdown library to a page that only needed a button.

Two windows. Left, the server, with dal.ts, marked server-only, and tea-card.tsx. Right, the browser, with add-to-cart.tsx, marked use client, and format.ts, which it imports. An arrow from tea-card.tsx to add-to-cart.tsx shows the server card rendering the button. A crossed-out card says that add-to-cart.tsx importing dal.ts stops the build.Only on the serverserverdal.ts: server-onlytea-card.tsxAlso in the browserbrowseradd-to-cart.tsxformat.tsadd-to-cart.tsx imports dal.tsthe build stops
dal.ts and tea-card.tsx run only on the server, and the card renders the button (the arrow). add-to-cart.tsx starts with "use client", so its code and everything it imports, like format.ts, also go to the browser. If it imported dal.ts, import "server-only" would stop the build.

Shared UI: plain components and client leaves

Components that every feature uses, like a card, a button with the shop's colors, or a dialog, go in one shared folder, here ui/. A large app's shared components, with the rules for how they look, are often called its design system. Here is the shop's Card:

ui/card.tsx: a shared component with no directiveTSX
import type { ReactNode } from "react"; export default function Card({ title, children }: { title: string; children: ReactNode }) {  return (    <article className="ui-card">      <h2>{title}</h2>      {children}    </article>  );} 

Line 1 imports the ReactNode type, for anything React can render. Line 3 takes a title and children, and lines 5 to 8 frame them in an <article> with the class ui-card. It has no state and no event handlers, so it needs no "use client". A component like this works on both sides: a Server Component renders it on the server, with its code kept there, and a Client Component that imports it sends its code to the browser.

The part that does need the browser, the Add to cart button, has "use client" in its own small file, at the bottom of the tree. Such a component, with no components of yours below it, is a leaf. The directive marks where the client part starts, and everything the file imports goes with it (Client Components calls this keeping the boundary low). Try both versions of the Card:

Playground: does the Card need "use client"?
The teas page, with this version of ui/card.tsxNext.js
app/reviews/teas/features/reviews/teas/ui/
ui/card.tsx
import type { ReactNode } from "react"; export default function Card({ title, children }: { title: string; children: ReactNode }) {  return (    <article className="ui-card">      <h2>{title}</h2>      {children}    </article>  );}
Recorded from Next.js 16.4.3
localhost:3000/teasTea shop
Teas
Green tea
Add for $4
Mint tea
Add for $3

The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.

Try it yourself
Plain Card

No directive. The teas page, a Server Component, renders the Card on the server, and the Card's code stays there.

Both versions were built with next build from Next.js 16.4.3 and run with next start. The table under the playground says what the recorder found in /teas's JavaScript. Reset goes back to the plain Card.

Each row is one text the recorder looked for in the JavaScript files /teas loads, in each version:

Is it in the JavaScript /teas loads?
Text the recorder looked forPlain CardCard with "use client"
Add for, from the Add to cart buttonYes.Yes.
ui-card, the Card's class nameNo.Yes.

Both pages look the same. The recorder searched the JavaScript files /teas loads: the button's text, Add for, is in them both times. The Card's class name, ui-card, is only there once card.tsx starts with "use client": its code now goes to every page that uses a card, where it has nothing to do. So keep shared components plain, and give the directive only to the ones that need state, effects or event handlers.

Two more things change as a design system grows. A component from an npm package that uses hooks but has no directive needs a small file of yours with "use client" that re-exports it, as Client Components showed. And when several apps share the same components, the folder becomes a package of its own; the Monorepos lesson builds one.

Imports that survive a move: @/

Every import in the overview's pages starts with @/. That's a path alias: a short name for a folder, set in tsconfig.json. create-next-app writes this one, and every recorded project here has it:

tsconfig.json, as create-next-app writes itJSON
{  "compilerOptions": {    "target": "ES2017",    "lib": ["dom", "dom.iterable", "esnext"],    "allowJs": true,    "skipLibCheck": true,    "strict": true,    "noEmit": true,    "esModuleInterop": true,    "module": "esnext",    "moduleResolution": "bundler",    "resolveJsonModule": true,    "isolatedModules": true,    "jsx": "react-jsx",    "incremental": true,    "plugins": [      {        "name": "next"      }    ],    "paths": {      "@/*": ["./*"]    }  },  "include": [    "next-env.d.ts",    "**/*.ts",    "**/*.tsx",    ".next/types/**/*.ts",    ".next/dev/types/**/*.ts",    "**/*.mts"  ],  "exclude": ["node_modules"]} 

Lines 21 to 23 map @/* to ./*, the project's top folder. So @/features/teas/dal means the file features/teas/dal.ts, from any folder. Next.js reads the setting too, which is why the recorded builds found every file. Without it, app/teas/page.tsx would import ../../features/teas/dal. Move the page into a route group, to app/(shop)/teas/page.tsx, and that path breaks; the alias doesn't change.

A rule many teams follow, and the recorded files do: ./ for files in the same folder, like ./add-to-cart inside features/teas, and @/ for everything else. Then a feature can move as a whole without breaking its own imports, and an import line says where a file lives.

Rules a team can check

A layout only works while everyone follows it. Rules written in a README get forgotten; rules a tool checks don't. The shop's most important rule is about direction: app may import features, features may import ui, and ui imports neither, so a shared component never depends on one feature. ESLint, the linter create-next-app can set up, has a rule for this in its core, no-restricted-imports:

eslint.config.mjs: create-next-app's, with one rule for ui/JavaScript
import { defineConfig, globalIgnores } from "eslint/config";import nextVitals from "eslint-config-next/core-web-vitals";import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([  ...nextVitals,  ...nextTs,  {    // Shared UI knows nothing about features or routes.    files: ["ui/**"],    rules: {      "no-restricted-imports": ["error", {        patterns: [{          group: ["@/features/*", "@/app/*"],          message: "ui/ is shared by every feature: pass data in as props.",        }],      }],    },  },  // Override default ignores of eslint-config-next.  globalIgnores([    // Default ignores of eslint-config-next:    ".next/**",    "out/**",    "build/**",    "next-env.d.ts",  ]),]); export default eslintConfig; 

Lines 1 to 7, and the ignores after the block, are the config create-next-app writes when you pick ESLint. Lines 8 to 19 add a block. Line 10 says it's for files in ui/. Line 12 turns on no-restricted-imports as an error. Line 14 lists the imports it refuses: any path under @/features/ or @/app/. Line 15 is the message the report shows. Now someone adds a price tag to ui that formats the price with the teas' helper:

A shared price tag that imports from the teas featureNext.js
app/reviews/teas/features/reviews/teas/ui/
ui/price-tag.tsx
import { formatPrice } from "@/features/teas/format"; export default function PriceTag({ price }: { price: number }) {  return <span className="ui-price">{formatPrice(price)}</span>;}
Recorded from Next.js 16.4.3
$ npx eslint ./my-app/ui/price-tag.tsx  1:1  error  '@/features/teas/format' import is restricted from being used by a pattern. ui/ is shared by every feature: pass data in as props  no-restricted-imports ✖ 1 problem (1 error, 0 warnings) $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /_not-found├ ○ /reviews└ ○ /teas ○  (Static)  prerendered as static content

Line 1 of ui/price-tag.tsx imports formatPrice from @/features/teas/format. The Terminal tab starts with $ npx eslint ., which the recorder ran before the build: '@/features/teas/format' import is restricted from being used by a pattern. ui/ is shared by every feature: pass data in as props, on line 1, column 1 of /my-app/ui/price-tag.tsx (/my-app is the project's folder), then ✖ 1 problem (1 error, 0 warnings). ESLint ended with exit code 1. Then $ next build built every route anyway: since Next.js 16, next build doesn't run ESLint (Accessibility recorded the same). Run npx eslint . yourself, in the checks that run on every change, beside your tests (Testing Next.js apps).

The fix follows the message. PriceTag takes the text to show, and the tea card, inside the teas feature, formats the price:

The price tag shows the text it's givenNext.js
app/reviews/teas/features/reviews/teas/ui/
ui/price-tag.tsx
export default function PriceTag({ text }: { text: string }) {  return <span className="ui-price">{text}</span>;}
Recorded from Next.js 16.4.3
$ npx eslint .  $ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled   Creating an optimized production build ...✓ Compiled successfully  Running TypeScript ...  Finished TypeScript ...  Collecting page data ...✓ Generating static pages (4/4)  Finalizing page optimization ... Route (app)┌ ○ /_not-found├ ○ /reviews└ ○ /teas ○  (Static)  prerendered as static content

Now ui/price-tag.tsx imports nothing, and features/teas/tea-card.tsx passes text={formatPrice(tea.price)} on line 10. ESLint printed nothing and ended with exit code 0, and the page is unchanged: $4, then Add for $4.

Three windows in a row: app, with page.tsx and layout.tsx; features, with teas and reviews; ui, with card.tsx and price-tag.tsx. Arrows go from app to features and from features to ui. A curved arrow back from ui to features is marked: blocked by ESLint.apppage.tsxlayout.tsxfeaturesteas/reviews/uicard.tsxprice-tag.tsximports go this way →ui → features: blocked by ESLint
Imports go one way: app imports features, and features import ui. The ESLint rule in eslint.config.mjs reports any file in ui that imports from @/features or @/app.

Each row is one rule the shop keeps, and what checks it:

The shop's rules, and what checks each one
The ruleWhat checks itWhat happens when it's broken
Shared UI never imports a feature.ESLint's no-restricted-imports rule, for files in ui/.npx eslint . reports the import and exits with code 1.
A data module starts with import "server-only".Next.js, while it builds.next build stops if a Client Component's code imports the module.
Imports use @/, not long ../../ paths.Code review. A pattern in the same ESLint rule can forbid them too.Files can move without breaking their neighbours' imports.
Each feature has an owner.A CODEOWNERS file, on code hosts that support one, like GitHub.Changes to a feature's folder ask its team for a review.

Core ESLint's rule is enough for a few folders. For many layers, there are third-party plugins, such as eslint-plugin-boundaries, which let you name each kind of folder and list which may import which. None of the recorded examples uses one, so check its docs before you rely on it. Whatever the tool, the rules belong in the repository, next to the code they protect, so a new teammate learns them from the first report.

The tea shop's folders, sorted

Here is the whole shop by feature, as recorded in the overview. Pages and layouts stay in app; each feature has its components, its data module and its helpers; ui holds what both features use. The shop keeps adding code. Sort each new file into the folder it belongs in:

Which folder does it go in?
  • The page at /teas
  • The root layout with the shop's links
  • The route.ts that answers /api/reviews
  • The function that reads the teas table
  • The Add to cart button
  • The function that reads the reviews table
  • The list of a tea's reviews
  • A Card frame that teas and reviews both use
  • A Button with the shop's colors, used everywhere
Try it yourself
0 of 9 correct

Sort each file into app, a feature's folder, or the shared ui folder.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

One way to decide: if visitors open it at an address, it's in app. If only one feature uses it, it lives in that feature. If two features use it and it knows nothing about either, it's shared UI. Code that knows about one feature but is needed by another usually means the second feature should get the data from a page, as props.

Common mix-ups

  • "Next.js wants feature folders." No. The three layouts built with the same routes. Next.js only reads the routing files in app; the rest is for the people who read the code.
  • "Every file in app is a page." No. Only a page.tsx or a route.ts makes an address. /teas/tea-card answered 404, with tea-card.tsx right beside the page.
  • "A file with no address is private." No. A Client Component that imports it sends its code to the browser, and whatever a page renders is in its HTML and RSC payload.
  • "A helper with no secrets can live in the data module." Not if a Client Component needs it. Importing one function brings the whole module, and server-only stops the build. Keep helpers in their own files.
  • "A barrel file is only a shortcut." No. Importing anything from it puts everything it re-exports in the importer's module graph: a server-only module in a client file's barrel stops the build, and an unused library can add bytes when the bundler can't leave it out.
  • "Shared UI components need "use client"." No. A component without state or event handlers works in Server and Client Components. The directive only sent the Card's code to the browser.
  • "next build checks my lint rules." No. Since Next.js 16 it doesn't run ESLint; the recorded build passed right after ESLint reported an error.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upFind the address that doesn't exist

This is the shop sorted beside its routes. Of /teas, /reviews and /teas/tea-card, which address answers 404?

Starter codeText
app/
  _ui/
    card.tsx
  reviews/
    dal.ts
    page.tsx
    review-list.tsx
  teas/
    add-to-cart.tsx
    dal.ts
    format.ts
    page.tsx
    tea-card.tsx
  layout.tsx

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upName the layout

    Which of the three layouts is this: by kind of file, by feature, or beside the routes?

    Starter codeText
    app/
      reviews/
        page.tsx
      teas/
        page.tsx
      layout.tsx
    components/
      add-to-cart.tsx
      card.tsx
      review-list.tsx
      tea-card.tsx
    lib/
      format.ts
      reviews.ts
      teas.ts

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeWrite the import

      The shop is sorted by feature. Write line 2 of app/reviews/page.tsx: the import of getReviews, with the @/ alias.

      Starter codeTSX
      import ReviewList from "@/features/reviews/review-list";
      // Import getReviews from the reviews feature's data module.
      
      export default async function ReviewsPage() {
        const reviews = await getReviews();
        return (
          <main>
            <h1>Reviews</h1>
            <ReviewList reviews={reviews} />
          </main>
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeRead the lint report

        This is ui/price-tag.tsx, linted with the shop's eslint.config.mjs. Which rule reports line 1?

        Starter codeTSX
        import { formatPrice } from "@/features/teas/format";
        
        export default function PriceTag({ price }: { price: number }) {
          return <span className="ui-price">{formatPrice(price)}</span>;
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMove the helper

          The build stops with 'server-only' cannot be imported from a Client Component module, because the Add to cart button imports formatPrice from this features/teas/dal.ts. Where should formatPrice go?

          Starter codeTypeScript
          import "server-only";
          
          // A stand-in for the teas table. `cost` is what the shop pays: staff only.
          const rows = [
            { id: "green", name: "Green tea", price: 4, cost: 1.2 },
            { id: "mint", name: "Mint tea", price: 3, cost: 0.9 },
          ];
          
          export type Tea = { id: string; name: string; price: number };
          
          export function formatPrice(price: number) {
            return `$${price}`;
          }
          
          export async function getTeas(): Promise<Tea[]> {
            return rows.map(({ id, name, price }) => ({ id, name, price }));
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengePredict the barrel's build

            The team adds this features/teas/index.ts, and the Add to cart button imports formatPrice from @/features/teas. Does next build pass?

            Starter codeTypeScript
            export { default as AddToCart } from "./add-to-cart";
            export { getTeas, type Tea } from "./dal";
            export { formatPrice } from "./format";
            export { default as TeaCard } from "./tea-card";
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask which files make addresses, and where each module's code runs.

              The Structuring a large app quiz · 7 questionsScore: first tries count
              1. Question 1 of 7In the shop sorted beside its routes, app/teas/tea-card.tsx sits next to the teas page. What does /teas/tea-card answer?

                Choose an answer to see the explanation.

              2. Question 2 of 7The shop was built three times: sorted by kind of file, by feature, and beside the routes. Which routes did next build list?

                Choose an answer to see the explanation.

              3. Question 3 of 7Why does features/teas/dal.ts start with import "server-only"?

                Choose an answer to see the explanation.

              4. Question 4 of 7This version of the Add to cart button imports formatPrice from ./dal, which starts with import "server-only". What does next build do?

                Read the code, then predictTSX
                "use client";
                
                import { useState } from "react";
                import { formatPrice } from "./dal";
                
                export default function AddToCart({ price }: { price: number }) {
                  const [added, setAdded] = useState(false);
                  return (
                    <button type="button" onClick={() => setAdded(true)}>
                      {added ? "Added" : `Add for ${formatPrice(price)}`}
                    </button>
                  );
                }
                

                Choose an answer to see the explanation.

              5. Question 5 of 7Where does "use client" belong in a shared ui/ folder?

                Choose an answer to see the explanation.

              6. Question 6 of 7In import TeaCard from "@/features/teas/tea-card", what does @/ mean?

                Choose an answer to see the explanation.

              7. Question 7 of 7npx eslint . reported no-restricted-imports in ui/price-tag.tsx, yet next build passed. Why didn't the build stop?

                Choose an answer to see the explanation.

              Key takeaways

              • Next.js gives addresses only to routing files in app. Sorting the rest by kind of file, by feature or beside the routes changed no route in the recordings.
              • For a growing app, keep app for routes and group the other files by feature, with shared UI in a folder of its own.
              • Give each feature one server-only way in for its data, a module or a few behind one entry, that returns only the fields pages need. Keep helpers that the browser needs in their own files.
              • A barrel puts everything it re-exports in the module graph of whoever imports it: a server-only module behind it stops the build of any client file that uses it, even if nothing ships.
              • Keep shared components plain, and put "use client" on the small leaves that need the browser.
              • Import with @/ across folders, and let ESLint check which folder may import which. Run it yourself: next build doesn't.

              Remember the one-liner.
              Routes in app, everything else by feature, data behind server-only, shared UI plain, and the rules in ESLint.

              Coming next: Next.js as a backend for your frontend. The shop's Next.js app stands in front of other teams' services: Route Handlers as a public API, rewrites, and calling several services at once.

              CompleteFrontend Clear concepts. Working examples.