How to structure the frontend of a Laravel Inertia React application

In the past decade of building Laravel apps, our PHP application structure has remained consistent. Meanwhile, our frontend stack has bounced between jQuery, React, Vue, and back to React with Inertia and TypeScript.
Now that we have large, long-running Inertia apps in production, we know what we like, don't like, and what works. It’s time to formalize our thoughts and decisions as we’re starting new projects and onboarding a few developers this year.
Our frontend code structure depends on the application. There's a spectrum between an Inertia app with hundreds of views across multiple apps or zones with shared components and a single page app with the complexity of a small Figma clone. In this post, we'll discuss our newly defined starting point for applications, which might evolve within a project.
To get the ball rolling, here's a slice of a typical Inertia React app:
resources
├── css
│ └── app.css
└── js
├── common
│ ├── button
│ │ └── Button.tsx
│ └── card
│ ├── Card.tsx
│ ├── CardHeader.tsx
│ └── CardContent.tsx
├── modules
│ ├── auth
│ │ ├── Avatar.ts
│ │ └── useCurrentUser.ts
│ └── categories
│ └── CategoryBadge.tsx
├── pages
│ ├── layouts
│ │ └── Layout.tsx
│ ├── profile
│ │ ├── layouts
│ │ │ └── ProfileLayout.tsx
│ │ └── ProfilePage.tsx
│ ├── posts
│ │ ├── components
│ │ │ └── PublishStatus.tsx
│ │ ├── helpers
│ │ │ └── generateSlug.ts
│ │ ├── CreatePostPage.tsx
│ │ ├── EditPostPage.tsx
│ │ └── PostsIndexPage.tsx
│ └── DashboardPage.tsx
├── shadcn
│ ├── dialog.tsx
│ ├── input.tsx
│ └── select.tsx
└── app.tsx
Let’s dive in!
JavaScript application structure
We have 4 base directories under resources/js
:
common
: Contains modules with generic code & components. They could theoretically be used across projects, but styling is project-specific and abstractions evolve differently over time.modules
: Contains project-specific code & components. Stuff shared across multiple pages throughout the app, or parts that warrant their own context outside ofpages
.pages
: Inertia-rendered pages. They may also contain additional code & components scoped to a specific page/section of the app.shadcn
: Auto-generated components from shadcn/ui.
Common & modules
Both common
and modules
contain one level of directories to define contexts. Their contents can be freely determined based on the context’s needs. However, when a directory grows in size, we recommend organizing it by type.
A common module like button
or card
might only contain a few top-level components.
resources
└── js
└── common
├── button
│ └── Button.tsx
└── card
├── CardContents.tsx
├── CardHeader.tsx
└── Card.tsx
An app-specific module like agenda
would be organized by type.
resources
└── js
└── modules
└── agenda
├── components
│ ├── Agenda.tsx
│ ├── ListView.tsx
│ └── GridView.tsx
├── contexts
│ └── AgendaContext.tsx
├── helpers
│ └── parseDate.ts
├── hooks
│ └── useAgenda.ts
└── types.ts
Subdirectories by type
Typical subdirectories to organize a module would be:
components
contexts
constants
helpers
hooks
stores
We usually group all of a module's type definitions in a single types.ts
, but if it grows too large, we'll introduce a types
directory.
constants
, helpers
, and hooks
can exist at the top-level of the common
directory for low-level utilities.
resources
└── js
└── common
├── helpers
│ └── parseDateFromServer.ts
└── hooks
└── useIntersectionObserver.ts
Casing things
We have clear rules for casing:
- Files exporting components or React contexts have
PascalCase
names - All other files have a
camelCase
name - Directories have a
kebab-case
name (like npm modules)
Common vs. modules
There's not always a clear-cut answer to what belongs in common
and modules
. When in doubt, we ask ourselves the following question: "Does it relate to a domain or feature of our application?" If yes, it probably belongs in modules
.
Pages
Pages contain Inertia-rendered page components. We try to keep this directory close to the URL structure. Page components are suffixed with Page
.
In addition to pages, there's a top-level layouts
folder for global layouts. We might have additional layouts in nested directories. When we want to split pages into partial components or need some helper functions, we'll use our type-based components/helpers/hooks/…
structure so we only have page components in subdirectories.
resources
└── js
└── pages
├── layouts
│ └── Layout.tsx
├── profile
│ ├── layouts
│ │ └── ProfileLayout.tsx
│ └── ProfilePage.tsx
├── posts
│ ├── components
│ │ └── PublishStatus.tsx
│ ├── helpers
│ │ └── generateSlug.ts
│ ├── CreatePostPage.tsx
│ ├── EditPostPage.tsx
│ └── PostsIndexPage.tsx
└── DashboardPage.tsx
Shadcn
When starting a new project, we don't want to spend days or weeks setting up a component library before shipping features. We'll use shadcn/ui to kickstart a project and gradually migrate components to our common
folder based on project needs.
Besides styling tweaks, we avoid major changes to shadcn components. The API shadcn and Radix use for their components is often too low-level for our taste in application code.
export function FruitSelect() {
return (
<Select>
<SelectTrigger>
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Fruits</SelectLabel>
<SelectItem value="apple">Apple</SelectItem>
<SelectItem value="banana">Banana</SelectItem>
<SelectItem value="blueberry">Blueberry</SelectItem>
<SelectItem value="grapes">Grapes</SelectItem>
<SelectItem value="pineapple">Pineapple</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
)
}
This is great for building a custom UI library, but we'll often abstract them in our own implementations.
export function FruitSelect() {
return (
<Select
placeholder="Select a fruit"
options={[
{ value: 'apple', label: 'Apple' },
{ value: 'banana', label: 'Banana' },
{ value: 'blueberry', label: 'Blueberry' },
{ value: 'grapes', label: 'Grapes' },
{ value: 'pineapple', label: 'Pineapple' },
]}
/>
)
}
Stylesheets
We use Tailwind for styling because its utility-first approach suits a component-based framework like React. Since Tailwind has a lot included, we often have enough with a single app.css
.
resources
└── css
└── app.css
For projects with specific styling needs, we'll add one or more of base
, components
, and utilities
directories to organize styles based on their scopes.
resources
└── css
├── base
│ ├── a.css
│ └── h1.css
├── components
│ ├── button.css
│ └── card.css
├── utilities
│ └── typography.css
└── app.css
Multi-zone apps
Some apps have distinct sections, like an admin and client-facing app. In that case, we'll introduce an apps
folder to separate them. common
and modules
can live in app-specific directories and/or can be shared across apps under resources/js
.
In most cases we'll have a single common
directory for the shared design system, a global modules
directory for shared modules, and additional app-specific modules
directories.
resources
├── css
│ ├── admin
│ │ └── app.css
│ └── client
│ └── app.css
└── js
├── apps
│ ├── admin
│ │ ├── modules
│ │ ├── pages
│ │ └── app.tsx
│ └── client
│ ├── modules
│ ├── pages
│ └── app.tsx
├── common
└── modules
Notes on React components
This is what a typical React component looks like in our applications:
import { PropsWithChildren } from 'react';
import { cn } from '@/common/helpers/cn';
import { PropsWithClassName } from '@/common/types/props';
type Props = PropsWithClassName<PropsWithChildren<{
onClick?: () => void;
type?: 'button' | 'submit';
}>>;
export function Button({ onClick, type, className, children }: Props) {
return (
<button type={type} onClick={onClick} className={cn(className)}>
{children}
</button>
);
}
- Prefer defining components as a
function
over aconst
assignment for a better visual distinction between variables and functions/components. Anonymous functions passed as callbacks are defined as arrow functions. - Use named exports for consistent component names across usage. One file should contain and export one component only. We only use
export default
for page components, as Inertia loads components based on their filename. - We don't use barrel files. In theory they're a nice way to decide which components you want to expose from a module, but in practice they're difficult to enforce and create indirection.
- Imports are stored in two blocks, enforced by prettier-plugin-sort-imports: library imports and application imports. For application imports, we use absolute paths with an alias.
- We sort props alphabetically with
className
andchildren
at the end, but this is not enforced. UI components often have those props defined viaPropsWithChildren
from React, andPropsWithClassName
; a custom generic type for conditional classes with clsx.
Optimizing for the backend and full(er)stack dev
We're a company with a 1:4 frontend:backend developer ratio. This taught us to optimize how we build apps and enable backend-first developers to build UIs without a full-time frontend developer.
This is where the split between common
/modules
and pages
becomes extra useful. The common
and modules
folders contain the necessary plumbing and abstractions for backend developers to build features in pages
. When heavy lifting is required for state management or other interactions, we'll have someone with the necessary React knowledge to implement a feature. But for 75% of an app built from forms, datatables, and reusable patterns, a developer not specialized in React has enough to get the job done with common
/modules
.