Most naming convention posts are wish lists. This one started as a wish list too, so before republishing it I went and counted: twelve of my own Next.js repos, every .ts and .tsx file in each, every route folder.
Two useful things fell out. First, about half of what I had written down was true. Second, the half that was false was false in a specific way: I had invented rules for things the framework already decides, and left the genuinely contested decisions vague.
So this is split accordingly. Rules the framework owns, where your only job is to spell them correctly. Rules I own, where I will tell you what I picked, why, and where my own repos still disagree with each other.
Summary
| Thing | Convention | Example |
|---|---|---|
| Files and folders I name | kebab-case | components/blogs/blog-card.tsx |
| Special App Router files | exact framework names | page.tsx, layout.tsx, route.ts |
| Route segment folders | kebab-case, lowercase | app/glossary/, about-us/ |
| Route groups | (name), lowercase, no URL effect | app/(app)/, app/(website)/ |
| Non-routable colocated folders | _name | app/demos/_playgrounds/ |
| Dynamic segments | short lowercase noun in brackets | [slug], [...slug], [[...slug]] |
| Components | PascalCase export from a kebab-case file | page-header.tsx exports PageHeader |
| Hooks | use prefix in both the name and the file | hooks/use-outside-click.ts |
| Types and interfaces | PascalCase, no I prefix | interface Frontmatter { ... } |
| Functions and variables | camelCase | normalizeSiteUrl, configuredSiteUrl |
| Module constants | UPPER_SNAKE_CASE | PROJECT_CHAPTERS |
| Env vars | UPPER*SNAKE_CASE, NEXT_PUBLIC* when the browser needs it | NEXT_PUBLIC_APP_URL |
| Client storage keys | short app prefix, kebab-case | pg-sound, pg-loaded |
| Cookies | lowercase, plus a __Host- prefix when you can | __Host-session |
| Query params | lowercase, single word where possible | ?url=, ?v= |
| Tests | mirror the source filename | use-capabilities.test.tsx |
The framework owns the app directory
Inside app/, filenames are an API. You are not naming these files, you are matching a string Next.js looks for. The current set is layout, page, loading, not-found, error, global-error, route, template, default, plus forbidden and unauthorized. The full list lives in the project structure docs, and it grows, which is the single best argument for keeping your own files out of the way of it.
Folder conventions are the same kind of contract:
(group)is a route group. The parentheses are stripped from the URL, so it exists purely to share a layout or split one._folderis a private folder. It and everything under it opts out of routing.[slug],[...slug],[[...slug]]are a single dynamic segment, a catch-all, and an optional catch-all.@slotnames a parallel route slot, and(.),(..),(...)prefixes are intercepting routes.
I have not shipped a parallel or intercepting route in any of these twelve repos, so I have no earned opinion there beyond spelling. I have shipped route groups: nexora-marketplace splits app/(app)/ from app/(website)/ so the logged-in shell and the marketing shell get different root layouts at the same URL depth. That is the one thing route groups do that nothing else does, and it is the only reason I reach for them.
The common mistake: using a route group where a private folder belongs. If the folder holds components rather than routes, _components is the honest spelling, and it removes any chance of a future Next.js convention colliding with it. web-dev-assets has app/demos/_demos/, _playgrounds/ and _components/ for exactly this. Worth noting that colocation already works without the underscore, since a folder is only routable once it contains a page or route file. The underscore is a guarantee, not a requirement.
Use proxy.ts, not middleware.ts
As of Next.js 16 the middleware file convention is deprecated and renamed to proxy. The file is proxy.ts at the project root, or inside src/ if you use one, at the same level as app/. It must export a single function, either as the default export or named proxy. There is a codemod, npx @next/codemod@canary middleware-to-proxy ., which renames both the file and the function.
The rename is documented as a deliberate signal: the old name invited people to treat it as Express middleware and reach for it first. Two of my repos have a proxy.ts and both use it for exactly one thing, vanity redirects (/github, /linkedin). That is about the right amount of ambition for this file.
The common mistake: copying a middleware.ts snippet from a 2024 blog post into a Next 16 app and wondering why nothing runs.
Metadata files are named, not configured
icon.tsx, apple-icon.tsx, opengraph-image.tsx, twitter-image.tsx, sitemap.ts and robots.ts are all file-based metadata conventions. Put the file in the right segment and the tag or the route appears. This repo has all six in app/.
The common mistake: hand-rolling a /sitemap.xml route handler when app/sitemap.ts returning an array would do.
Files I name are kebab-case
Every file and folder whose name is mine, not the framework's, is lowercase with hyphens. components/blogs/blog-card.tsx, lib/utils/format-date.ts, hooks/use-outside-click.ts.
The reason is not aesthetics, it is case sensitivity. macOS is case-insensitive and Linux is not, so import Card from './Card' against a file committed as card.tsx works on my laptop and fails in CI. Git also tracks case changes badly enough that renaming Header.tsx to header.tsx needs two commits or a --force flag. Lowercase everywhere means the class of bug does not exist. The secondary reason applies only inside app/: the folder name becomes the URL segment, and Google's guidance on URL structure prefers hyphens to separate words.
Here is where my repos disagree with each other. Seven of the twelve use kebab-case component files (roughly 217 files: piyushgambhir.com, prism, web-dev-assets, nexora-marketplace, image-everything, pdf-everything, madhavgambhir.com). Four use PascalCase (roughly 186 files: codiot-web and three client marketing sites).
That is close enough that I cannot pretend the kebab side won on merit alone, so here is the honest tiebreak: the PascalCase repos are marketing sites and site ports, built against an existing design, and the naming came from whatever the first component was called. The kebab repos are the ones I have maintained for more than a month. Everything I have touched most recently, including this site, is kebab-case, and when I go back to codiot-web I will rename rather than keep two systems.
The common mistake: letting the exception be a directory rather than a file. One PascalCase file in a kebab folder is a typo you fix. A whole components/ tree in the other style is a decision, and then every new file needs a judgment call.
Components are PascalCase in code, kebab-case on disk
components/ui/page-header.tsx exports PageHeader. The two cases are answering different questions: PascalCase in code is a hard React requirement, because JSX resolves a lowercase tag to a DOM element, so <userProfile /> silently becomes an unknown HTML tag. Kebab-case on disk is the filesystem rule above.
The connection I do enforce is that the file name is the component name, lowercased and hyphenated. Given <StatsWidget /> in JSX, I should be able to guess stats-widget.tsx and be right. That predictability is the entire payoff of a naming convention, and it is worth more than having the two cases match.
The common mistake: a file whose name has drifted from its export, usually because a component got renamed and the file did not.
Hooks carry use in both places
hooks/use-outside-click.ts exports useOutsideClick. The prefix on the function is not style, it is how React's linter decides whether to apply the Rules of Hooks to a function at all, and custom hooks are defined by the prefix rather than by anything the type system can see.
Keeping use- in the filename is my choice, and it earns its keep in fuzzy file search: typing use- lists every hook in the project. My shared hooks directory is carried between repos almost verbatim (use-debounce.ts, use-local-storage.ts, use-media-query.ts, use-event-listener.ts), and in three repos it has an index.ts barrel.
That barrel is the one place I still use one. Barrel files inside app/ defeat tree shaking and route-level code splitting, and a barrel over a components/ tree turns one import into a whole-directory dependency. A small barrel over a leaf directory of independent hooks is fine; components/index.ts re-exporting forty components is not.
The common mistake: naming a non-hook useSomething, which makes the linter enforce hook rules on a plain function. The reverse is worse: a hook named authState() that calls useState internally and gets no linting at all.
Dynamic segments are short lowercase nouns
Across all twelve repos, every dynamic segment I have written is a single lowercase word: [slug], [id], [city], [industry], [tool], plus [...slug] and [[...slug]].
I used to advise [userId] over [id], and the audit says I never once did it. The reason is that the segment name is only ever read next to its parent: app/locations/[city]/[industry]/page.tsx needs no further disambiguation, and params.city reads better than params.cityId. The framework does not care about the case, so if I ever need two words I would write [projectid], not [projectId], to keep the URL predictable if the value ever leaks into a path.
The common mistake: writing Express route syntax in a Next.js context. /:projectId is not a thing here; the folder is [projectid] and the URL is /123.
lib/, utils/, hooks/, providers/
The split I use is by dependency direction, not by file type.
lib/holds modules that know about my domain or about a third party:lib/seo.ts,lib/github.ts,lib/analytics/amplitude.ts.lib/utils/(or a top-levelutils/in older repos) holds pure functions with no app knowledge:cn.ts,format-date.ts,slugify.ts.hooks/holds reusable hooks. A hook used by exactly one component lives next to that component.providers/holds context providers, one per file, named for what they provide:providers/gsap-provider.tsxexportsGsapProvider.
src/ is the one thing I am genuinely split on. Five repos use src/app/, seven put app/ at the root, and I do not think the choice matters as long as proxy.ts sits beside app/ either way.
The common mistake: letting utils/ become the folder for anything you could not name. If a file in utils/ imports from lib/, it is not a util.
Types are PascalCase, and they live next to what they describe
interface Frontmatter, type ContentItem. No I prefix, no Type suffix. TypeScript already distinguishes types from values, so the prefix carries no information.
Where they live is the part I had wrong. I used to say "a types/ directory." What I actually do is put shared content types in types/content.ts, ambient declarations in types/global.d.ts, and everything else in a types.ts next to the module that owns it, like lib/tools/types.ts. Props types stay in the component file, named after the component with a Props suffix: PageHeaderProps.
The common mistake: a central types.ts that every module imports from, which becomes an import cycle magnet and a file nobody can safely delete from.
Env vars, and what a .env line actually looks like
UPPER*SNAKE_CASE, and the NEXT_PUBLIC*prefix only when a value has to reach the browser. Anything without the prefix is server-only, which is the actual security boundary, not a style choice. My real names look likeNEXT_PUBLIC_APP_URL, NEXT_PUBLIC_CLARITY_PROJECT_ID, RESEND_API_KEY, DATABASE_URL.
A .env file is not JavaScript. No quotes unless the value contains spaces, no semicolons, no spaces around the equals sign:
NEXT_PUBLIC_APP_URL=https://www.piyushgambhir.com
RESEND_API_KEY=re_xxxxxxxx
NEXT_PUBLIC_ values are inlined into the client bundle at build time, which has a consequence people get caught by: changing one requires a rebuild, not a restart, and the value is public forever once shipped.
The common mistake: prefixing something with NEXT_PUBLIC_ to make an error go away, and shipping a secret. If a server component needs it, it does not need the prefix.
Client storage keys and cookies
Here is the other place the old version of this post was wrong. I claimed camelCase for localStorage keys. What I actually ship is a short app prefix and kebab-case: pg-sound, pg-loaded.
The prefix is the load-bearing part. Storage is per origin, so anything else served from that origin, including a stray script, shares the namespace. A two-letter prefix costs nothing and makes a key greppable. The case is arbitrary, but kebab matches the rest of the project, and a storage key is a string, not an identifier.
Cookies are not camelCase either. I keep them lowercase and reach for the __Host- or __Secure- cookie prefixes when the cookie matters, because those prefixes are enforced by the browser rather than being decoration. A correct Set-Cookie header for a session looks like this, and note that __Host- requires Secure, Path=/, and no Domain attribute:
Set-Cookie: __Host-session=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
The common mistake: an unprefixed session cookie with no HttpOnly, set from client JavaScript because that was the shortest path.
Query params
The old version of this post claimed snake_case for query parameters and cited "several blogs." I went looking for the snake_case params in my own code and there are none. What I have is ?url= and ?v=: lowercase single words.
So the rule is just that, lowercase and short, and the reason is that most query params are not yours to name. They arrive from an analytics tool, an OAuth provider, or a payment redirect, and consistency with your codebase is not on offer. Reserve your opinion for the ones you invent.
Tests mirror the file under test
__tests__/hooks/use-capabilities.test.tsx tests hooks/use-capabilities.ts. The test filename is the source filename plus .test, which keeps the two sorted together and makes the missing test obvious. In image-everything the tests live in a __tests__/ tree that mirrors the source layout; colocating them next to the source works equally well as long as you pick one.
What I would actually enforce
Not all of this is worth a lint rule. Three things are, because they cause bugs rather than arguments: filename casing (a case-sensitivity failure in CI), the hook prefix (it gates the React linter), and NEXT_PUBLIC_ discipline (it leaks secrets). The rest is worth being consistent about and not worth relitigating, which is the whole reason to write it down once.
The test for any of it is the same. Given a name in one place, can you guess the name in the other place and be right? If yes, the convention is doing its job. If you have to go look, it is decoration.