Folder Structure
This guide defines standard folder organization patterns for Activatus projects.
Principlesβ
- Predictability - Developers should know where to find things
- Scalability - Structure should work for small and large projects
- Consistency - Same patterns across all projects
- Clarity - Folder names should describe their purpose
π Recommended Next.js Structureβ
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 by | Lives in | Example |
|---|---|---|
| Every project the same way β the Design System Components | components/ui/ | components/ui/button/ |
| The site frame β navigation, header, footer | components/layout/ | components/layout/siteHeader/ |
| Three or more pages of this project | components/shared/ | components/shared/pricingTable/ |
| Three or more pages in one section | A components/ folder in that section's route group | app/(billing)/components/invoiceTable/ |
| Three or more pages in a section that already shares a folder | A components/ folder in that shared folder | app/orders/[id]/components/orderSummary/ |
| One or two pages | A components/ folder in that page's route folder | app/account-settings/components/accountForm/ |
| The homepage | app/(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.
- First use: a new component goes in its page's
components/folder. - 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. - 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 tocomponents/shared/if not, and delete the copies.
- First use: a new component goes in its page's
- 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 incomponents/ui/and record it indocs/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 readrands, 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 alayout.tsxfile. Layout primitives, such as Duplex and Section Wrapper, are Design System Components: they go incomponents/ui/, nevercomponents/layout/.components/shared/is everything else used by three or more pages. The difference fromcomponents/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:
| Folder | What goes in it | Ask yourself | Examples |
|---|---|---|---|
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/useslib/to reach the outside world. A service that charges a card calls the Stripe client inlib/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 inservices/orlib/.- 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 indocs/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 by | Location | Example |
|---|---|---|
| The whole app | Global utils/ folder at the project root | utils/formatCurrency/ |
| One section | A utils/ folder in that section's route group | app/(billing)/utils/calculateInvoiceTotal/ |
| One component | A utils/ folder inside that component's folder | app/(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:
export { formatCurrency } from "./formatCurrency";
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'sutils/. When anything outside the section needs it, move it to the globalutils/. - 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 anindex.ts. - Every function has a test file in the same folder. See Readable Code.
- Import from the
utilsfolder, 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 link | r in routes.ts |
| Text people see or hear | s in strings.ts |
| A single number with one job | numbers.ts |
| Fake data shaped like a real response | noData.ts |
| A group of values that together say how something moves | animation.ts |
| A group of values that together say what is drawn and where | artwork.ts |
| A Tailwind class string used by more than one file | classNames.ts |
| A status more than one part of the app reads | status.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.
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
rroute. 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.
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
sare 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 innoData.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 asconst CLOSE_EVENT = "close";. A status more than one part of the app reads goes instatus.ts.
A list of items with linksβ
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:
export const s = {
plans: {
starter: { title: "Starter", action: "Start free" },
team: { title: "Team", action: "Talk to us" },
},
};
export const r = {
plans: {
starter: "/signup?plan=starter",
team: "/contact",
},
};
<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:
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.
ralready 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.
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_INSIGHTandNO_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.tsas their own constants,DUMMY_HEADINGand the rest. They are never added tos. - 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.
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.tscan run past 300 lines, likerands. - Loose numbers only. A number that only means something beside other values stays inside its object in
animation.tsorartwork.ts. It is never lifted out intonumbers.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.
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 toglobals.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.
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
.svgfile inpublic/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.
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.tsonly 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_NAMESfor 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.
export type ReviewStatusType = "passed" | "rejected" | "errored";
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, ands.reviewStatuses.rejectedis the "Rejected" on the badge. - Name each export after what it lists, ending in
_STATUSESor_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.
export interface EntranceAnimationType {
delaySeconds: number;
durationSeconds: number;
offsetPixels: number;
}
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 itsProps.lib/types/is for the shared constants. -
Types live in
lib/types/, in files named by topic, such asanimation.tsandartwork.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 typeis 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 plainstring,numberandboolean. See Noas 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
Recordover the id type:PLAN_BADGES: Record<PlanIdType, PlanBadgeType>, so a missing id fails the type check. When only some ids have a value, usePartial<Record<PlanIdType, PlanBadgeType>>. -
A lookup inside
rorsthat only some ids have cannot be typed there, becauserandsare never typed by hand. Declare it as a typed constant aboverorsin the same file, then place it inside by name:lib/constants/routes.tsimport 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
FeatureIdTypeiskeyof typeof s.features, read froms, so it can be used insider. -
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
.mapthat builds typed items gets a return type:planIds.map((planId): PlanBadgeType => ({ β¦ })), never a cast on a field. -
When a function's
Propsand a constant share a shape, the shape is a type inlib/types/and thePropsis 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 throughindex.ts. See Inside a folder, import files directly. -
randsare 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:
// Beforeexport const HIGHLIGHTED_PLAN_ID = "team";export const HIGHLIGHT_ANIMATION = { durationSeconds: 0.3 };// Afterexport const HIGHLIGHT_ANIMATION = {durationSeconds: 0.3,planId: "team",};A list of keys a loop runs over, such as
planIdsin 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 inanimation.ts. -
Every file in
lib/constants/can run past 300 lines. They are data only, so ESLint does not applymax-linesto 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
useprefix (useUserData.ts) - Service files: camelCase with
Servicesuffix (apiService.ts) - Route Folder names: kebab-case (
user-management/,api-routes/) - Component Folder names: camelCase (
header/,model/) - Documentation files: kebab-case (
background-jobs.md)