Skip to main content

Folder Structure

This guide defines standard folder organization patterns for Activatus projects.

Principles​

  1. Predictability - Developers should know where to find things
  2. Scalability - Structure should work for small and large projects
  3. Consistency - Same patterns across all projects
  4. Clarity - Folder names should describe their purpose

For Next.js projects using App Router, use the root-level app/ directory. This is the modern Next.js standard.

project-root/
β”œβ”€β”€ app/ # Next.js App Router pages & layouts
β”‚ β”œβ”€β”€ (routes)/ # Route groups (kebab-case)
β”‚ β”‚ β”œβ”€β”€ account-settings/
β”‚ β”‚ β”‚ β”œβ”€β”€ components/ # Route-specific components
β”‚ β”‚ β”‚ β”‚ └── accountForm/ # Example component structure
β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ AccountForm.tsx # Component implementation
β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ AccountForm.test.tsx # Unit tests
β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ types.ts # Component types
β”‚ β”‚ β”‚ β”‚ └── index.ts # Export file
β”‚ β”‚ β”‚ └── page.tsx
β”‚ β”‚ └── page.tsx
β”‚ β”œβ”€β”€ api/ # API routes (kebab-case)
β”‚ β”‚ β”œβ”€β”€ users/
β”‚ β”‚ β”‚ └── route.ts
β”‚ β”‚ └── webhooks/
β”‚ β”‚ └── stripe/
β”‚ β”‚ └── route.ts
β”‚ β”œβ”€β”€ (homepage)/ # The homepage, served at /
β”‚ β”‚ β”œβ”€β”€ components/ # Used only by the homepage
β”‚ β”‚ └── page.tsx
β”‚ └── layout.tsx
β”œβ”€β”€ components/ # React components
β”‚ β”œβ”€β”€ ui/ # Design System Components
β”‚ β”‚ └── button/ # Example component structure
β”‚ β”‚ β”œβ”€β”€ Button.tsx # Component implementation
β”‚ β”‚ β”œβ”€β”€ Button.test.tsx # Unit tests
β”‚ β”‚ β”œβ”€β”€ Button.stories.tsx # Storybook stories
β”‚ β”‚ β”œβ”€β”€ types.ts # Component types
β”‚ β”‚ └── index.ts # Export file
β”‚ β”œβ”€β”€ layout/ # The site frame: navigation, header, footer
β”‚ └── shared/ # Used by three or more pages
β”œβ”€β”€ lib/ # Outside connections and app-wide setup
β”‚ β”œβ”€β”€ permissions/ # Who may do what
β”‚ β”‚ β”œβ”€β”€ index.ts # Main definitions
β”‚ β”‚ β”œβ”€β”€ api.ts # API permissions
β”‚ β”‚ β”œβ”€β”€ page.ts # Page permissions
β”‚ β”‚ └── plan.ts # Plan permissions
β”‚ β”œβ”€β”€ integrations/ # The only code that talks to outside services
β”‚ β”‚ β”œβ”€β”€ stripe.ts # Payment processing
β”‚ β”‚ └── supabase/ # Database integration
β”‚ β”‚ β”œβ”€β”€ client.ts
β”‚ β”‚ └── mock/
β”‚ β”œβ”€β”€ middleware/ # Code that runs on every request
β”‚ β”‚ β”œβ”€β”€ cors.ts
β”‚ β”‚ β”œβ”€β”€ rateLimit.ts
β”‚ β”‚ └── session.ts
β”‚ β”œβ”€β”€ validation/ # Schemas that check input
β”‚ β”œβ”€β”€ constants/ # Constants shared across the app: see Constants below
β”‚ β”‚ β”œβ”€β”€ routes.ts # r: every link
β”‚ β”‚ β”œβ”€β”€ strings.ts # s: every piece of static text
β”‚ β”‚ β”œβ”€β”€ numbers.ts # Numbers more than one file uses
β”‚ β”‚ β”œβ”€β”€ noData.ts # Stand-in data for local development
β”‚ β”‚ β”œβ”€β”€ status.ts # Statuses the app shares, once there are any
β”‚ β”‚ └── index.ts
β”‚ β”œβ”€β”€ errors/ # Error types and handlers shared across the app
β”‚ └── types/ # Types shared across the app; a component's own types stay in its types.ts
β”œβ”€β”€ utils/ # Reusable functions used across the app
β”‚ β”œβ”€β”€ formatCurrency/ # One folder per function
β”‚ β”‚ β”œβ”€β”€ formatCurrency.ts
β”‚ β”‚ β”œβ”€β”€ formatCurrency.test.ts
β”‚ β”‚ └── index.ts
β”‚ β”œβ”€β”€ formatDate/
β”‚ β”‚ β”œβ”€β”€ formatDate.ts
β”‚ β”‚ β”œβ”€β”€ formatDate.test.ts
β”‚ β”‚ └── index.ts
β”‚ └── index.ts # Exports every function
β”œβ”€β”€ hooks/ # Custom React hooks
β”œβ”€β”€ services/ # Business logic: what the app does
β”œβ”€β”€ stores/ # State management stores
β”œβ”€β”€ styles/ # Global styles
β”œβ”€β”€ public/ # Static assets
β”œβ”€β”€ tests/ # Test files
β”œβ”€β”€ docs/ # Project documentation β€” every file is a Decision Doc
β”‚ β”œβ”€β”€ overview/ # What the project is and how it works
β”‚ β”œβ”€β”€ architecture/ # How the system is built
β”‚ β”œβ”€β”€ scope/ # What is in, out and later
β”‚ └── ... # The other standard folders
β”œβ”€β”€ docsSite/ # Docusaurus viewer for docs/ (config only)
β”œβ”€β”€ package.json
β”œβ”€β”€ next.config.mjs
β”œβ”€β”€ tsconfig.json
└── eslint.config.mjs

πŸ“ Next.js Project Structure​

Next.js modern standard (App Router) uses root-level directories:

project-root/
β”œβ”€β”€ app/ # Next.js App Router
β”œβ”€β”€ components/ # React components
β”œβ”€β”€ lib/ # Outside connections and app-wide setup
β”œβ”€β”€ utils/ # Reusable functions
β”œβ”€β”€ public/ # Static assets
β”œβ”€β”€ docs/ # Project documentation (Markdown)
β”œβ”€β”€ docsSite/ # Docusaurus viewer for docs/
β”œβ”€β”€ package.json
β”œβ”€β”€ next.config.mjs
└── tsconfig.json

πŸ’‘ Why root-level for Next.js?​

  • Next.js standard - Official documentation uses root-level
  • Simpler imports - Cleaner import paths
  • Faster development - No abstraction layer
  • Better Next.js integration - Optimized for the framework
  • Easier for beginners - Less complexity
  • Framework conventions - Matches Next.js ecosystem

🧩 Where components live​

A component lives where it is used. There are no feature folders: a component belongs to one page or one section, is shared, or is part of the site frame.

Used byLives inExample
Every project the same way β€” the Design System Componentscomponents/ui/components/ui/button/
The site frame β€” navigation, header, footercomponents/layout/components/layout/siteHeader/
Three or more pages of this projectcomponents/shared/components/shared/pricingTable/
Three or more pages in one sectionA components/ folder in that section's route groupapp/(billing)/components/invoiceTable/
Three or more pages in a section that already shares a folderA components/ folder in that shared folderapp/orders/[id]/components/orderSummary/
One or two pagesA components/ folder in that page's route folderapp/account-settings/components/accountForm/
The homepageapp/(homepage)/components/app/(homepage)/components/heroSection/

A route group is a folder in brackets, such as (billing). It groups pages without changing their URLs: app/(billing)/invoices/page.tsx is served at /invoices. A route group is only needed when a section's pages do not already share a folder. When they all sit under one URL folder, such as app/orders/[id]/, that folder is the section, and its components go in its own components/ folder. Never move pages just to make a route group.

The homepage is a route group too: app/(homepage)/. It holds the homepage's page.tsx and its own components/ and utils/ folders, and is served at /. Without it, the homepage's components sit loose in app/components/ beside the root layout, with no clean place for the next page's. app/ keeps only the root layout.tsx and the files Next.js needs there, such as globals.css. A folder inside app/ is only a route when it holds a page.tsx or route.ts, so components/ and utils/ folders inside app/ are safe: they never become URLs.

project-root/
β”œβ”€β”€ app/
β”‚ β”œβ”€β”€ (billing)/ # A section: groups pages, not part of the URL
β”‚ β”‚ β”œβ”€β”€ components/ # Used by three or more billing pages
β”‚ β”‚ β”‚ └── invoiceTable/
β”‚ β”‚ β”œβ”€β”€ invoices/
β”‚ β”‚ β”‚ └── page.tsx # /invoices
β”‚ β”‚ └── payments/
β”‚ β”‚ └── page.tsx # /payments
β”‚ β”œβ”€β”€ (homepage)/ # The homepage, served at /
β”‚ β”‚ β”œβ”€β”€ components/ # Used only by the homepage
β”‚ β”‚ β”‚ └── heroSection/
β”‚ β”‚ └── page.tsx # /
β”‚ β”œβ”€β”€ account-settings/
β”‚ β”‚ β”œβ”€β”€ components/ # Used only by this page
β”‚ β”‚ β”‚ └── accountForm/
β”‚ β”‚ β”‚ β”œβ”€β”€ AccountForm.tsx
β”‚ β”‚ β”‚ β”œβ”€β”€ AccountForm.test.tsx
β”‚ β”‚ β”‚ β”œβ”€β”€ types.ts
β”‚ β”‚ β”‚ └── index.ts
β”‚ β”‚ └── page.tsx # /account-settings
β”‚ └── layout.tsx # The root layout
└── components/
β”œβ”€β”€ ui/ # Design System Components: Button, Card, Modal
β”‚ └── button/
β”œβ”€β”€ layout/ # The site frame: navigation, header, footer
β”‚ β”œβ”€β”€ siteHeader/
β”‚ └── siteFooter/
└── shared/ # Used by three or more pages of this project
└── pricingTable/

Rules​

  • Start where it is used, and share it on the third use. Two uses are not enough to see which props really change from one use to the next. Three are.
    1. First use: a new component goes in its page's components/ folder.
    2. Second use: copy it into the second page's components/ folder. Only one copy, and each copy has a comment naming the other: // Copy of app/invoices/components/invoiceSummary/. Merge on the third use.
    3. Third use: merge the copies into one component. Design its props from all three uses, move it to the section's components/ folder if all three pages are in one section, or to components/shared/ if not, and delete the copies.
  • This is the only copying allowed. A third copy is a mistake. It does not apply to Design System Components, which are shared from the start, or to constants, which always live in lib/constants/.
  • components/ui/ is only for Design System Components. They take everything through props and import nothing from the project, so they can be copied into another project unchanged. See Reusable by design.
  • A props-only component that is not on the list yet stays in components/ui/. A project cannot edit the standards, so it cannot add the component to the list itself. If a component takes everything through props and imports nothing from the project, keep it in components/ui/ and record it in docs/development/coding-standards.md, under "Components for the standards", with its props. Tell the person, so it is added to the list in the next release.
  • components/layout/ is the site frame: the navigation, header, footer and anything else that frames the pages. It is this project's own code, so it may read r and s, and usually wraps Design System Components such as Navigation Bar and Footer. It does not have to be on every page, and it does not have to be rendered from a layout.tsx file. Layout primitives, such as Duplex and Section Wrapper, are Design System Components: they go in components/ui/, never components/layout/.
  • components/shared/ is everything else used by three or more pages. The difference from components/layout/ is what it is, not how often it is used: a footer is layout even on one page, and a pricing table used on three pages is shared.
  • A page or shared component wraps Design System Components. It holds this project's logic and data, and passes props down.
  • Never group by type (atoms/, molecules/, organisms/). Group by where a component is used.

Other frameworks follow the same rule with their own folders. In a React app without Next.js, a page's folder is the folder for that screen.


πŸ—‚οΈ utils, services and lib​

Three folders hold functions that are not components. Each function belongs in exactly one of them:

FolderWhat goes in itAsk yourselfExamples
utils/Small reusable functions with no side effects. The same input always gives the same output: formatting, calculating, sorting, checking"Does it only work with what it is given?"formatCurrency, calculateInvoiceTotal, sortInvoicesByDate
services/The business logic: what the app actually does. Its rules and operations, which may read and write data"Is this a rule or an action of the product itself?"createInvoice, decideNextActions, sendWelcomeEmail
lib/Code that connects the app to something outside it, and the app-wide setup around that: integration clients, permissions, middleware, validation schemas, constants, errors and shared types"Does it talk to another system, or set up something the whole app uses?"lib/integrations/stripe/, lib/permissions/, lib/validation/
  • Pages, API routes and server actions stay thin. They take the request, call a function in services/, and return the result. Business logic never lives in them.
  • services/ uses lib/ to reach the outside world. A service that charges a card calls the Stripe client in lib/integrations/stripe/, never the Stripe package directly.
  • utils/ uses nothing outside itself. If a function needs a database, an API or the environment, it is not a util: it belongs in services/ or lib/.
  • Group services/ by the project's own ontology word it works on, one folder per function: services/invoice/createInvoice/. The project's ontology is usually in docs/product/ontology.md, but it can be in another file. If you can't find it, ask.

🧰 Utils​

Every reusable function lives in a utils/ folder, never inside a UI file. Where it goes depends on who uses it:

Used byLocationExample
The whole appGlobal utils/ folder at the project rootutils/formatCurrency/
One sectionA utils/ folder in that section's route groupapp/(billing)/utils/calculateInvoiceTotal/
One componentA utils/ folder inside that component's folderapp/(billing)/components/invoiceTable/utils/sortInvoicesByDate/

Every utils/ folder has the same shape, at every level: one folder per function, holding the function, its test file and an index.ts β€” and an index.ts in the utils/ folder that exports them all.

project-root/
β”œβ”€β”€ utils/ # Global: used across the app
β”‚ β”œβ”€β”€ formatCurrency/
β”‚ β”‚ β”œβ”€β”€ formatCurrency.ts
β”‚ β”‚ β”œβ”€β”€ formatCurrency.test.ts
β”‚ β”‚ └── index.ts
β”‚ β”œβ”€β”€ formatDate/
β”‚ β”‚ β”œβ”€β”€ formatDate.ts
β”‚ β”‚ β”œβ”€β”€ formatDate.test.ts
β”‚ β”‚ └── index.ts
β”‚ └── index.ts
└── app/
└── (billing)/
β”œβ”€β”€ utils/ # Section: used only by billing pages
β”‚ β”œβ”€β”€ calculateInvoiceTotal/
β”‚ β”‚ β”œβ”€β”€ calculateInvoiceTotal.ts
β”‚ β”‚ β”œβ”€β”€ calculateInvoiceTotal.test.ts
β”‚ β”‚ └── index.ts
β”‚ └── index.ts
└── components/
└── invoiceTable/
β”œβ”€β”€ InvoiceTable.tsx
└── utils/ # Component: used only by InvoiceTable
β”œβ”€β”€ sortInvoicesByDate/
β”‚ β”œβ”€β”€ sortInvoicesByDate.ts
β”‚ β”œβ”€β”€ sortInvoicesByDate.test.ts
β”‚ └── index.ts
└── index.ts

The index.ts files​

Each function folder has an index.ts that exports its function, and the utils/ folder has an index.ts that exports every function, so imports stay neat:

utils/formatCurrency/index.ts
export { formatCurrency } from "./formatCurrency";
utils/index.ts
export { formatCurrency } from "./formatCurrency";
export { formatDate } from "./formatDate";
// βœ… Good β€” import from the utils folder
import { formatCurrency, formatDate } from "@/utils";
import { sortInvoicesByDate } from "./utils";

// ❌ Bad β€” reaching into the function's folder
import { formatCurrency } from "@/utils/formatCurrency/formatCurrency";

Add the export to utils/index.ts whenever you add a function.

Rules​

  • Start local, promote when shared. A function begins in the component's utils/. When another component in the same section needs it, move its folder to the section's utils/. When anything outside the section needs it, move it to the global utils/.
  • Sort, find, get, filter and format functions are utils from the start. A function that only works on the data passed to it goes in a utils/ folder the first time it is written, never inside the component, even with one user.
  • One function per folder, with the folder and file named after the function (formatCurrency/formatCurrency.ts), plus its test file and an index.ts.
  • Every function has a test file in the same folder. See Readable Code.
  • Import from the utils folder, never from the files inside it.
  • Keep UI files clean. If a function could be written in another file, it does not belong in the component file. See Writing a component.

πŸ”€ Constants: r, s and shared values​

Every link goes in r, and every piece of text people see or hear goes in s, both in lib/constants/, never written straight into a component. A link then changes in one place, and every piece of text is in one place when the site is translated into other languages.

Every other value lives with the code that uses it, until it is shared. A number, list or internal string that one file uses is a named constant at the top of that file. Once more than one file needs it, it moves to lib/constants/, into the file that fits it.

lib/constants/
β”œβ”€β”€ routes.ts # r: every link
β”œβ”€β”€ strings.ts # s: every piece of text people see or hear
β”œβ”€β”€ numbers.ts # Numbers more than one file uses
β”œβ”€β”€ noData.ts # Stand-in data, once the data's shape is decided
β”œβ”€β”€ animation.ts # How things move, when more than one file uses it
β”œβ”€β”€ artwork.ts # What is drawn and where, when more than one file uses it
β”œβ”€β”€ classNames.ts # Class names shared between files
β”œβ”€β”€ status.ts # Statuses the app shares
β”œβ”€β”€ unsortedValues.ts # Values waiting for the person to say where they go
└── index.ts # Exports all of them

routes.ts, strings.ts and index.ts exist from the start. Every other file is created only once there is something to put in it. Each is one file, never split by topic.

A shared value that is…Goes in
A linkr in routes.ts
Text people see or hears in strings.ts
A single number with one jobnumbers.ts
Fake data shaped like a real responsenoData.ts
A group of values that together say how something movesanimation.ts
A group of values that together say what is drawn and whereartwork.ts
A Tailwind class string used by more than one fileclassNames.ts
A status more than one part of the app readsstatus.ts

This table is the base, and where each value goes is the project's call. A shared value that fits none of these goes in a file of its own in lib/constants/, named after what it holds. When it is not clear where a value goes, it goes in unsortedValues.ts until the person says.

A file outside the folder imports everything it needs in one line:

import { NO_DATA_INSIGHTS, r, s } from "@/lib/constants";

<a href={r.contact}>{s.contact}</a>;

r: routes​

r holds every link the app uses: its own pages, outside sites, and mailto:, tel: and sms: links.

lib/constants/routes.ts
export const r = {
contact: "/contact",
email: "mailto:mail@example.com",
insights: "/insights",
linkedIn: "https://www.linkedin.com/in/example",
sms: "sms:+61400000000",
};
  • A dynamic route starts from its r route. Build the changing part where it is used: `${r.insights}/${insight.slug}`.
  • Name each key after the place it goes, in camelCase: r.myStory, r.linkedIn.
  • No "#" placeholder links. A link with nowhere to go yet is shown as plain text, without a link, with a TODO above it: {/* TODO: Link to the pricing page once it exists */}. A "#" link is a bug a user can click.

s: strings​

s holds every piece of text people see or hear: headings, buttons, labels, aria-labels, page titles and descriptions, and longer Markdown copy.

lib/constants/strings.ts
export const s = {
contact: "CONTACT",
emailAriaLabel: "Email Tom",
pageNotFound: "PAGE NOT FOUND",
pageNotFoundDescription:
"The page you are looking for does not exist",
};
  • The keys of s are the site's translation keys. When a site gets more languages, each key is translated into each language. Name each key after what the text says or does, and never use one key for two different jobs.
  • Only real copy goes in s. Dummy text belongs in noData.ts.
  • Internal strings are not s. Ids, keys, event names, log messages and media queries are never shown to people. Each is a key in the object that uses it, or a named constant at the top of the file that uses it, such as const CLOSE_EVENT = "close";. A status more than one part of the app reads goes in status.ts.

Each item's words go in s and its link goes in r, under the same key, so the words can be translated and the link stays with the other links. The component reads each one by name:

lib/constants/strings.ts
export const s = {
plans: {
starter: { title: "Starter", action: "Start free" },
team: { title: "Team", action: "Talk to us" },
},
};
lib/constants/routes.ts
export const r = {
plans: {
starter: "/signup?plan=starter",
team: "/contact",
},
};
app/(homepage)/components/pricingPlans/PricingPlans.tsx
<Link href={r.plans.starter}>{s.plans.starter.action}</Link>
<Link href={r.plans.team}>{s.plans.team.action}</Link>

Write each key out by name when you mean one item: r.plans.starter, never r.plans[planId]. A reader can see exactly which words and which link each line uses.

Inside a loop over the list, look each item up by its key. A loop cannot name its items, so it reads r and s with the item's key, typed from r itself:

lib/types/index.ts
import type { r } from "@/lib/constants/routes";

export type PlanIdType = keyof typeof r.plans;
{planIds.map((planId) => (
<Link key={planId} href={r.plans[planId]}>
{s.plans[planId].action}
</Link>
))}
  • The type comes from r, so a missing or misspelt key fails the type check.
  • No separate lookup objects that map a key to its link. r already is that lookup: a second one is a copy that drifts out of step.

noData: stand-in data for local development​

noData.ts stands in for outside data while you work locally, when the CMS, database or API can't be reached, such as when you have no internet. Every page still renders with something that looks real.

Create it once the project has decided how its data comes in, the shape of the CMS, database or API response. Not before: the shape may still change. If the person asks for stand-in data before then, such as a list of fake users, put it in noData.ts with a comment above it: // Not the real schema: a basic shape to finish the task.

lib/constants/noData.ts
const DUMMY_HEADING = "A heading for local development";
const DUMMY_SLUG = "/insights";

export const NO_DATA_INSIGHT = {
heading: DUMMY_HEADING,
slug: DUMMY_SLUG,
};

export const NO_DATA_INSIGHTS = [NO_DATA_INSIGHT];
const insights =
query?.insights ??
(IS_LOCAL_DEVELOPMENT ? NO_DATA_INSIGHTS : []);

IS_LOCAL_DEVELOPMENT is a constant in lib/constants/: export const IS_LOCAL_DEVELOPMENT = process.env.NODE_ENV === "development";.

  • Local development only. noData is never shown in production. Where the real data is missing in production, show the page's empty state.
  • Name each export NO_DATA_ plus the exact thing it stands in for: singular for one item, plural for a list. NO_DATA_INSIGHT and NO_DATA_INSIGHTS, NO_DATA_EVENT, NO_DATA_TOPICS.
  • Shape it exactly like the real response, so the code using it cannot tell the difference.
  • Dummy values sit at the top of noData.ts as their own constants, DUMMY_HEADING and the rest. They are never added to s.
  • noData is for the whole site. All of it lives in this one file.

Numbers: named, and shared from one file​

A number other than -1, 0 or 1 gets a name, such as MAXIMUM_RETRY_ATTEMPTS. Those three mean "nothing", "one more" and "not found", so they read clearly as they are. Any other number was chosen by someone, and its name says what it controls. ESLint checks this with no-magic-numbers.

A number only one file uses is a named constant at the top of that file. A number more than one file uses lives in lib/constants/numbers.ts. There is no n object: each number is its own SCREAMING_SNAKE_CASE export.

lib/constants/numbers.ts
export const COOKIE_DURATION_DAYS = 30;
export const MAXIMUM_RETRY_ATTEMPTS = 3;
export const SESSION_TIMEOUT_MILLISECONDS = 900_000;
  • One file, never split by topic. numbers.ts can run past 300 lines, like r and s.
  • Loose numbers only. A number that only means something beside other values stays inside its object in animation.ts or artwork.ts. It is never lifted out into numbers.ts.
  • Config files and scripts outside the app, such as eslint.config.mjs, keep their numbers at the top of the file: they are not part of the app and do not import from it.
  • Put the unit in the name: _MILLISECONDS, _DAYS, _PIXELS.

Animation: how things move​

animation.ts holds every object that says how and when something moves.

lib/constants/animation.ts
import type { EntranceAnimationType } from "@/lib/types";

export const ENTRANCE_ANIMATION: EntranceAnimationType = {
delaySeconds: 0.1,
durationSeconds: 0.4,
offsetPixels: 24,
};
  • Each object stays whole. Values that only mean something together stay in one object, named by their keys. Never lift its numbers out into numbers.ts.
  • Values the animation library reads are not text on screen, so they stay in the object, never in s.
  • Put the unit in the key: durationSeconds, offsetPixels.
  • Media queries the animation reads stay in its object, such as reducedMotionQuery: "(prefers-reduced-motion: reduce)". They are read by code, not CSS, so they never move to globals.css.

Artwork: what is drawn and where​

artwork.ts holds every object that says what is drawn, where it ends up, and how it is grouped. Where something ends up is artwork; how and when it gets there is animation.

lib/constants/artwork.ts
import type { IllustrationLayoutType } from "@/lib/types";

export const ILLUSTRATION_LAYOUT: IllustrationLayoutType = {
heightPixels: 600,
widthPixels: 800,
};
  • Each object stays whole, the same as in animation.ts.
  • Drawing data the code reads goes here. A static drawing that nothing reads goes in an .svg file in public/ or in an icon component.

Class names shared between files​

classNames.ts holds Tailwind class names that more than one file uses: a single class string, or a set of them keyed by variant.

lib/constants/classNames.ts
export const SURFACE_CLASS_NAME =
"rounded-2xl border border-border bg-card";
  • Make a component first. A class string repeated across files usually means a component is missing, and the component is what makes the styling reusable. Use classNames.ts only when a component cannot hold the classes and they still repeat, such as when they go on different elements.
  • A class name used in one file stays inline in that file.
  • Name each export after what it styles, ending in _CLASS_NAME, or _CLASS_NAMES for a set keyed by variant.

Statuses​

status.ts holds the statuses more than one part of the app reads, such as the states a review or a workflow can be in. A status only one file reads stays at the top of that file.

lib/types/status.ts
export type ReviewStatusType = "passed" | "rejected" | "errored";
lib/constants/status.ts
import type { ReviewStatusType } from "@/lib/types";

export const REVIEW_FAILED_STATUSES: readonly ReviewStatusType[] = [
"rejected",
"errored",
];
  • The status is the value the code checks. The words people see stay in s, under the same key: "rejected" is what the code compares, and s.reviewStatuses.rejected is the "Rejected" on the badge.
  • Name each export after what it lists, ending in _STATUSES or _STATES.

When it is not clear where a value goes​

Put it in lib/constants/unsortedValues.ts, and carry on. The work never waits for the answer.

At the end of the work, ask the person about every value in it, one line each, with where you would put it: at the top of the file that uses it, or in a shared file named after what it holds.

REVIEW_FAILED_STATUSES (used in runReview and ReviewBadge)
β†’ keep it in runReview.ts, or move it to lib/constants/status.ts?

Move each value where the person says, then delete the file. pnpm standards:check reports unsortedValues.ts for as long as it exists, so nothing is left waiting.

Types for constants​

Every object and list constant in lib/constants/ gets a real type where it is declared, from lib/types/. r and s are the exception: their types are read from their values, such as keyof typeof r.plans, so they are never typed by hand. A single number or string needs no type of its own: const COOKIE_DURATION_DAYS = 30; is already exactly typed, and writing : number adds nothing.

lib/types/animation.ts
export interface EntranceAnimationType {
delaySeconds: number;
durationSeconds: number;
offsetPixels: number;
}
lib/constants/animation.ts
import type { EntranceAnimationType } from "@/lib/types";

export const ENTRANCE_ANIMATION: EntranceAnimationType = {
delaySeconds: 0.1,
durationSeconds: 0.4,
offsetPixels: 24,
};
  • A constant kept at the top of the file that uses it is typed beside it, from that file's own .types.ts, the same as its Props. lib/types/ is for the shared constants.

  • Types live in lib/types/, in files named by topic, such as animation.ts and artwork.ts. A types file is not exempt from the 300-line limit: split it by topic when it grows.

  • A type a constant needs lives in lib/types/, with every type it depends on. Components import it from @/lib/types.

  • lib/types/ reads a constant's type straight from its file: import type { r } from "@/lib/constants/routes";, never through @/lib/constants. It is the one exception to importing constants in one line. import type is removed at build, so the two folders reading each other's types never makes a real loop.

  • No as const. When the code needs the exact values, they go in the type, such as an id typed by its id type: planId: PlanIdType. Otherwise use plain string, number and boolean. See No as const.

  • A list gets a type for one item, and is read-only: ENTRANCE_STEPS: readonly EntranceStepType[]. A list inside a list item gets its own item type too, and is read-only as well.

  • A map keyed by id uses Record over the id type: PLAN_BADGES: Record<PlanIdType, PlanBadgeType>, so a missing id fails the type check. When only some ids have a value, use Partial<Record<PlanIdType, PlanBadgeType>>.

  • A lookup inside r or s that only some ids have cannot be typed there, because r and s are never typed by hand. Declare it as a typed constant above r or s in the same file, then place it inside by name:

    lib/constants/routes.ts
    import type { FeatureIdType } from "@/lib/types";

    const FEATURE_GUIDE_LINKS: Partial<Record<FeatureIdType, string>> = {
    sharing: "/guides/sharing",
    };

    export const r = {
    featureGuideLinks: FEATURE_GUIDE_LINKS,
    };

    The id type must not be read from the object the lookup sits in: TypeScript rejects that as a loop. Here FeatureIdType is keyof typeof s.features, read from s, so it can be used inside r.

  • A list that must hold every id is checked with a Record, or with a test. A typed list checks that each item is a real id, not that none is missing.

  • A .map that builds typed items gets a return type: planIds.map((planId): PlanBadgeType => ({ … })), never a cast on a field.

  • When a function's Props and a constant share a shape, the shape is a type in lib/types/ and the Props is an alias of it: export type GetFramePathProps = FrameShapeType;.

Rules​

  • Named exports only: export const r, export const s. No default exports.

  • Files outside lib/constants/ import from @/lib/constants, all in one import line.

  • Files inside lib/constants/ import each other directly, import { s } from "./strings";, never through index.ts. See Inside a folder, import files directly.

  • r and s are the only single-letter names allowed anywhere. See Human-Readable Names.

  • Other constants, such as numbers, are SCREAMING_SNAKE_CASE: see Naming Conventions.

  • An id lives in the object that uses it. A value that picks which item something acts on, such as which item an animation moves to, is a key inside that object, never a loose constant of its own:

    // Before
    export const HIGHLIGHTED_PLAN_ID = "team";
    export const HIGHLIGHT_ANIMATION = { durationSeconds: 0.3 };

    // After
    export const HIGHLIGHT_ANIMATION = {
    durationSeconds: 0.3,
    planId: "team",
    };

    A list of keys a loop runs over, such as planIds in A list of items with links, is not a loose id: it stays a list.

  • CSS belongs in a stylesheet, never in a constant. Styles written as a string in TypeScript move to globals.css. Styles for when JavaScript is off go there under @media (scripting: none); a browser without that query misses the fallback, and the standard accepts that. A media query the code reads is not CSS, such as the one an animation library matches to switch motion off: it stays in its object in animation.ts.

  • Every file in lib/constants/ can run past 300 lines. They are data only, so ESLint does not apply max-lines to them. Keep each one a single file: never split them to get under the limit.


πŸ§ͺ Test Placement​

Co-locate tests with the code they test:

components/
β”œβ”€β”€ button/
β”‚ β”œβ”€β”€ Button.tsx
β”‚ β”œβ”€β”€ Button.test.tsx
β”‚ β”œβ”€β”€ Button.stories.tsx
β”‚ └── index.ts
hooks/
β”œβ”€β”€ useUserData/
β”‚ β”œβ”€β”€ useUserData.ts
β”‚ β”œβ”€β”€ useUserData.test.ts
β”‚ └── index.ts
utils/
β”œβ”€β”€ formatCurrency/
β”‚ β”œβ”€β”€ formatCurrency.ts
β”‚ β”œβ”€β”€ formatCurrency.test.ts
β”‚ └── index.ts
└── index.ts

πŸ“ Project Documentation​

Keep project documentation in a root-level docs/ folder. Every file in it is a Decision Doc, and every project uses the same folders. The Docusaurus viewer that displays it at /docs lives beside it in docsSite/:

project-root/
β”œβ”€β”€ docs/ # The docs: plain Markdown only. Every file is a Decision Doc
β”‚ β”œβ”€β”€ overview/ # What the project is, how it works, how to write docs
β”‚ β”œβ”€β”€ architecture/ # How the system is built
β”‚ β”œβ”€β”€ design/ # How it looks and behaves
β”‚ β”œβ”€β”€ scope/ # What is in, out and later
β”‚ β”œβ”€β”€ ... # The other standard folders
β”‚ └── product/ # Decisions specific to this product
└── docsSite/ # The viewer: Docusaurus config, no content

See Documentation for the full list of folders, the doc format, and how docs are displayed and kept up to date.

Name documentation folders and files in kebab-case (background-jobs.md), not camelCase (backgroundJobs.md). The name becomes the page's URL on the docs site.


πŸ“„ Configuration Files​

Keep configuration at the root where tools expect it:

/root
β”œβ”€β”€ eslint.config.mjs # ESLint configuration
β”œβ”€β”€ prettier.config.mjs # Prettier configuration
β”œβ”€β”€ tsconfig.json # TypeScript configuration
β”œβ”€β”€ next.config.mjs # Next.js configuration
β”œβ”€β”€ package.json # Dependencies & scripts
└── .env.local # Environment variables (gitignored)

🚨 Red Flags​

Avoid these anti-patterns:

  • ❌ Deep nesting - More than 4-5 levels deep
  • ❌ Scattered logic - Related code in multiple unrelated folders
  • ❌ Vague names - helpers/, misc/, stuff/ without organization
  • ❌ Inconsistent patterns - Different structures for similar things

πŸ“š Naming Conventions​

See Naming Conventions for detailed file and folder naming rules.

Quick reference:

  • Component files: PascalCase (UserProfile.tsx)
  • Utils files: camelCase (apiUtils.ts)
  • Hook files: camelCase with use prefix (useUserData.ts)
  • Service files: camelCase with Service suffix (apiService.ts)
  • Route Folder names: kebab-case (user-management/, api-routes/)
  • Component Folder names: camelCase (header/, model/)
  • Documentation files: kebab-case (background-jobs.md)

πŸ“š Resources​