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
PascalCasenames - All other files have a
camelCasename - Directories have a
kebab-casename (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
functionover aconstassignment 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 defaultfor 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
classNameandchildrenat the end, but this is not enforced. UI components often have those props defined viaPropsWithChildrenfrom 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.