Skip to content

Technical

Inertia Patterns That Keep Pages Clear and Fast

The ultimate goal with Inertia is not to transform the app into a complex SPA monolith, but to retain a comfortable server-driven flow while smoothing out the UX.

How to architect an Inertia.js application to keep it feeling straightforward on the backend while maximizing the user experience benefits of frontend frameworks.

Back to blog

Inertia Patterns That Keep Pages Clear and Fast

One of the reasons I enjoy working with Laravel, React, and Inertia.js is that they let me build modern web applications without maintaining a separate REST API for every page.

Laravel handles routing, authentication, authorization, database operations, and business logic. React handles interactive interfaces and reusable UI components. Inertia connects the two by allowing Laravel controllers to render React pages with server-provided data.

This approach has become particularly useful in my projects, where I often need to build administrative dashboards, management interfaces, and applications with complex workflows.

However, using Inertia doesn't automatically make an application maintainable or performant.

A page can still become difficult to understand when it contains hundreds of lines of JSX, manages too many unrelated states, or receives large amounts of unnecessary database data.

Similarly, an application can feel slow when every navigation reloads the entire browser, retrieves excessive data, or repeatedly executes expensive queries.

I've been exploring several patterns that help me keep Inertia applications organized while taking advantage of what Laravel and React already provide.

The goal is simple:

Keep the backend responsible for application data and business rules, keep the frontend responsible for presentation and interaction, and make the boundary between them explicit.


1. Understand the responsibilities of Laravel, Inertia, and React

Before discussing component structure, it's important to understand how the different parts of an Inertia application work together.

A typical request follows this flow:

Browser
   |
   v
Laravel Route
   |
   v
Controller
   |
   v
Application Logic / Database
   |
   v
Inertia::render()
   |
   v
React Page Component
   |
   v
Rendered Interface

During the initial page visit, Inertia returns an HTML response that boots the frontend application. Subsequent Inertia navigation requests can return page data without requiring a full browser reload.

This gives the application a single-page application experience while keeping Laravel responsible for routing and server-side operations.

For example, a Laravel controller might return:

use Inertia\Inertia;
use Inertia\Response;

public function index(): Response
{
    return Inertia::render('Projects/Index', [
        'projects' => Project::query()
            ->latest()
            ->get(),
    ]);
}

Inertia passes the projects data to the corresponding React page.

type Project = {
    id: number;
    name: string;
};

type Props = {
    projects: Project[];
};

export default function Index({ projects }: Props) {
    return (
        <div>
            <h1>Projects</h1>

            {projects.map((project) => (
                <p key={project.id}>
                    {project.name}
                </p>
            ))}
        </div>
    );
}

This is straightforward, and that's one of Inertia's strengths.

However, real applications rarely stop at a simple list of projects.

A project management page might eventually include searching, filtering, sorting, pagination, permissions, create and edit dialogs, activity history, and several reusable UI components.

Without a clear structure, the page component can quickly become responsible for too many things.


2. Keep page components focused on composing the screen

I think of an Inertia page as the entry point for a particular screen.

Its primary responsibility is to receive page props and compose the components needed to display that screen.

It shouldn't need to implement every detail of the interface itself.

For example, consider a project management page with the following responsibilities:

  • Display the page heading.
  • Show a project summary.
  • Render search and filter controls.
  • Display a list of projects.
  • Provide actions for creating and editing projects.
  • Handle empty states and pagination.

Putting all of these responsibilities into a single large component can make the page difficult to navigate.

Instead, I prefer to extract meaningful UI sections.

resources/js/
├── pages/
│   └── projects/
│       └── index.tsx
│
├── components/
│   └── projects/
│       ├── project-filters.tsx
│       ├── project-list.tsx
│       ├── project-card.tsx
│       └── project-empty-state.tsx
│
├── layouts/
│   └── app-layout.tsx
│
└── types/
    └── project.ts

The page component can then focus on composition:

import AppLayout from '@/layouts/app-layout';

import ProjectFilters from '@/components/projects/project-filters';
import ProjectList from '@/components/projects/project-list';

import type { Project } from '@/types/project';

type Props = {
    projects: Project[];
};

export default function ProjectsIndex({
    projects,
}: Props) {
    return (
        <AppLayout>
            <div className="space-y-6">
                <header>
                    <h1 className="text-2xl font-semibold">
                        Projects
                    </h1>

                    <p className="text-muted-foreground">
                        Manage your projects.
                    </p>
                </header>

                <ProjectFilters />

                <ProjectList projects={projects} />
            </div>
        </AppLayout>
    );
}

This makes the page easier to understand.

When I need to modify the project list, I know where to look. When I need to change the layout, I don't have to edit every individual page.

However, I also avoid extracting every small element into its own component.

A simple heading or a few lines of JSX don't necessarily need a dedicated file.

I extract components when they represent a meaningful responsibility, are reused, contain substantial logic, or make the parent component easier to understand.

The objective isn't to create the largest possible component library. It's to keep each part of the interface easy to find, understand, and change.


3. Keep reusable components independent of page-specific data

One pattern I try to avoid is making every reusable component read directly from Inertia's global page props.

For example, a project card could retrieve its own project data using usePage().

But that would couple the card to a particular page structure.

Instead, I prefer passing the required data through explicit component props.

type Project = {
    id: number;
    name: string;
    status: string;
};

type ProjectCardProps = {
    project: Project;
};

export default function ProjectCard({
    project,
}: ProjectCardProps) {
    return (
        <article className="rounded-lg border p-4">
            <h2 className="font-semibold">
                {project.name}
            </h2>

            <p className="text-sm text-muted-foreground">
                {project.status}
            </p>
        </article>
    );
}

The component doesn't need to know whether its data came from Inertia, a local state variable, or another parent component.

This makes it easier to reuse and test.

I generally reserve usePage() for components that genuinely depend on page-level context, such as navigation components, shared authenticated-user information, or global application UI.

For ordinary reusable components, explicit props usually make dependencies easier to understand.


4. Shape server-side data for the screen

One of the most important lessons I've learned when working with Inertia is that the data you return from Laravel should match what the frontend actually needs.

Consider this controller:

public function index(): Response
{
    return Inertia::render('Projects/Index', [
        'projects' => Project::with([
            'owner',
            'members',
            'activities',
        ])->get(),
    ]);
}

This might work, but it can send substantially more data than the screen requires.

If the page only displays a project name, status, and creation date, why load every related member and activity?

Passing unnecessary data increases query work, serialization overhead, and the size of the Inertia response.

It can also expose fields that should never be sent to the frontend.

Instead, I prefer to shape the response intentionally.

public function index(): Response
{
    $projects = Project::query()
        ->select([
            'id',
            'name',
            'status',
            'created_at',
        ])
        ->latest()
        ->paginate(15);

    return Inertia::render('Projects/Index', [
        'projects' => $projects->through(
            fn (Project $project) => [
                'id' => $project->id,
                'name' => $project->name,
                'status' => $project->status,
                'created_at' => $project
                    ->created_at
                    ->toDateString(),
            ]
        ),
    ]);
}

Now the frontend receives a predictable structure containing only the information required to render the screen.

The database query is also more intentional.

I'm selecting the columns I need and paginating the results instead of retrieving every project in the database.

For larger applications, Laravel API Resources can help standardize how models are transformed into presentation data.

For example:

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ProjectResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->name,
            'status' => $this->status,
            'created_at' => $this
                ->created_at
                ->toDateString(),
        ];
    }
}

A resource can be useful when several screens need the same representation of a project.

However, different screens may require different data.

A project index page might need only a name and status, while a project detail page might need members, permissions, milestones, and activity history.

I don't force every screen to use the same large resource if their data requirements are different.

The frontend should receive what it needs to display the current screen, not an unrestricted representation of the underlying database.

This also creates a clearer boundary between the database schema and the frontend's expected data structure.


5. Use TypeScript to make page contracts explicit

One advantage of combining Inertia with React and TypeScript is that page props can have clearly defined types.

For example:

export type Project = {
    id: number;
    name: string;
    status: 'draft' | 'active' | 'completed';
    created_at: string;
};

export type PaginatedProjects = {
    data: Project[];
    current_page: number;
    last_page: number;
    total: number;
};

export type ProjectsIndexProps = {
    projects: PaginatedProjects;
};

The page can then use these types:

export default function ProjectsIndex({
    projects,
}: ProjectsIndexProps) {
    return (
        <div>
            <h1>Projects</h1>

            <p>
                Total projects: {projects.total}
            </p>

            <ProjectList projects={projects.data} />
        </div>
    );
}

This provides better editor support and helps catch mistakes when a frontend component expects a field that no longer exists.

However, TypeScript types alone don't guarantee that the actual Laravel response matches the declared structure.

If the backend changes a field from name to title, a manually maintained frontend interface will not automatically update.

For larger projects, type-generation tools or a shared schema strategy can help reduce this drift.

For my current applications, I try to keep page prop types explicit and review them whenever I change the corresponding Laravel response.

The important thing is maintaining a clear contract between the backend and frontend rather than relying on assumptions about the data being returned.


6. Use Inertia Links for internal navigation

Inertia provides a Link component designed for navigation between Inertia pages.

Instead of using a normal anchor element for every internal link, I use Inertia's Link for routes handled by the application.

import { Link } from '@inertiajs/react';

export default function Navigation() {
    return (
        <nav>
            <Link href="/dashboard">
                Dashboard
            </Link>

            <Link href="/projects">
                Projects
            </Link>

            <Link href="/blogs">
                Blogs
            </Link>
        </nav>
    );
}

Unlike a conventional browser navigation, an Inertia visit can retrieve the next page's data and update the interface without reloading the entire document.

This helps maintain a more continuous application experience.

However, Inertia navigation should not be confused with client-side routing in a conventional React SPA.

Laravel remains responsible for resolving application routes and returning the appropriate page.

For external websites, file downloads, or routes that require ordinary browser navigation, a standard anchor element may still be the appropriate choice.

I use Inertia Links for navigation within the Inertia application and normal browser links when a full browser navigation is intended.


7. Centralize navigation and active states

As an application grows, navigation can become more complicated.

An administrative dashboard might have sections for projects, blogs, users, roles, inquiries, and settings.

Instead of manually implementing separate active-state logic in every navigation component, I prefer centralizing the navigation configuration.

For example:

type NavigationItem = {
    title: string;
    href: string;
};

const navigationItems: NavigationItem[] = [
    {
        title: 'Dashboard',
        href: '/dashboard',
    },
    {
        title: 'Projects',
        href: '/projects',
    },
    {
        title: 'Blogs',
        href: '/blogs',
    },
];

A reusable navigation component can use Inertia's current page URL to determine which item is active.

import { Link, usePage } from '@inertiajs/react';

export default function AppNavigation() {
    const { url } = usePage();

    const currentPath =
        url.split('?')[0].replace(/\/+$/, '') || '/';

    return (
        <nav>
            {navigationItems.map((item) => {
                const itemPath =
                    item.href.replace(/\/+$/, '') || '/';

                const isActive =
                    currentPath === itemPath ||
                    (
                        itemPath !== '/' &&
                        currentPath.startsWith(
                            `${itemPath}/`
                        )
                    );

                return (
                    <Link
                        key={item.href}
                        href={item.href}
                        aria-current={
                            isActive ? 'page' : undefined
                        }
                        className={
                            isActive
                                ? 'font-semibold text-primary'
                                : 'text-muted-foreground'
                        }
                    >
                        {item.title}
                    </Link>
                );
            })}
        </nav>
    );
}

This example handles both the main Projects page and nested project routes.

It also avoids marking unrelated routes active simply because they share a similar URL prefix.

For more complicated routing structures, route names or explicit navigation metadata may provide a more reliable solution than comparing URL strings.

In applications using Laravel Wayfinder, I can also use generated route definitions rather than maintaining every route path manually.

The main objective is to keep navigation behavior consistent across the application.

When navigation items are defined in one place, changing a label, destination, or active-state rule becomes easier.


8. Use persistent layouts when preserving interface state matters

Most applications have layout elements shared across multiple pages.

For example:

Application Layout
├── Sidebar
├── Top Navigation
├── Main Content
└── Footer

A straightforward approach is to wrap every page in the same layout component.

export default function Dashboard() {
    return (
        <AppLayout>
            <DashboardContent />
        </AppLayout>
    );
}

This works, but a layout declared directly inside each page may be remounted when navigating between different pages.

That can become inconvenient when the layout contains state that should persist across navigation.

For example:

  • An expanded or collapsed sidebar.
  • A media player.
  • A navigation menu with local UI state.
  • Other interface state that should remain active between pages.

Inertia supports persistent layouts for this purpose.

In a React application, a page can declare its layout:

import AppLayout from '@/layouts/app-layout';

function Dashboard() {
    return <DashboardContent />;
}

Dashboard.layout = (page) => (
    <AppLayout>{page}</AppLayout>
);

export default Dashboard;

With an appropriately configured Inertia page resolver, the layout can remain mounted while the page content changes.

This helps preserve layout-level component state across navigation.

However, persistent layouts are not necessary for every application.

A simple public-facing website may not benefit from preserving layout state between pages.

I generally use persistent layouts when the application interface contains meaningful state that users expect to remain unchanged as they navigate.

It's also important to remember that persistent layouts and data caching are different concerns. Keeping a layout mounted does not automatically cache the data returned by Laravel for each page visit.


9. Keep shared props small and intentional

Inertia allows Laravel to provide data that is shared across multiple pages.

Common examples include the authenticated user, flash messages, and application configuration.

In Laravel, shared props are commonly configured through the Inertia middleware.

For example:

public function share(Request $request): array
{
    return [
        ...parent::share($request),

        'auth' => [
            'user' => $request->user()?->only(
                'id',
                'name',
                'email'
            ),
        ],
    ];
}

This allows components such as the application navigation to access the authenticated user's information without requiring every controller to include the same data.

However, I avoid treating shared props as a place to store all application data.

For example, I wouldn't automatically include all projects, notifications, permissions, and dashboard statistics on every page.

Shared props may be included in many Inertia responses, depending on how they are defined and evaluated.

Large shared payloads can create unnecessary database queries and network transfers.

Instead, I prefer to share only information that is genuinely needed across a broad portion of the application.

Page-specific data should generally remain in the corresponding page response.

If an application needs shared information that is expensive to calculate, I consider appropriate caching, lazy evaluation, or a more targeted data-loading approach.

Shared props should represent shared application context, not become a global collection of everything the frontend might eventually need.


10. Use partial reloads for focused page updates

One useful Inertia feature is the ability to request only selected props when revisiting the same page component.

Consider a project index page with the following props:

return Inertia::render('Projects/Index', [
    'projects' => fn () => Project::query()
        ->latest()
        ->paginate(15),

    'statistics' => fn () => [
        'total' => Project::count(),

        'active' => Project::where(
            'status',
            'active'
        )->count(),
    ],
]);

Suppose the page contains a project list and a summary dashboard.

When the user changes a filter, the project list needs to update, but the summary statistics may not need to be recalculated.

Instead of requesting every prop again, the frontend can request only the projects:

import { router } from '@inertiajs/react';

function refreshProjects() {
    router.reload({
        only: ['projects'],
    });
}

Inertia can reuse the existing values of props that weren't requested, reducing unnecessary work.

This is particularly useful for pages with expensive server-side computations or several independent data sections.

However, partial reloads need to be used carefully.

If changing a project also changes the summary statistics, retaining the old statistics would leave the interface displaying outdated information.

In that case, the frontend should request both relevant props:

router.reload({
    only: ['projects', 'statistics'],
});

Partial reloads also don't replace proper database query optimization.

If loading the projects prop requires an expensive query, requesting fewer props won't make that query itself faster.

Use partial reloads when you know which data needs to change and which data can safely remain unchanged.


11. Preserve user context during filtering and pagination

A common frustration in administrative interfaces is losing the current search, filter, or scroll position after performing an action.

Imagine managing a list of several hundred projects.

You search for a specific client, navigate to another page of results, open a project, and update its status.

After saving, the application sends you back to the first page with all filters cleared.

The update worked, but the experience is unnecessarily disruptive.

Inertia provides navigation options that help preserve relevant user context.

For example, when changing a search filter:

import { router } from '@inertiajs/react';

function searchProjects(search: string) {
    router.get(
        '/projects',
        { search },
        {
            preserveState: true,
            replace: true,
        }
    );
}

Here, replace: true prevents every search change from adding another entry to the browser's navigation history.

preserveState: true helps retain the current page component's local state during the visit.

For forms or actions that should preserve the user's scroll position, Inertia also provides preserveScroll.

These options should be chosen according to the interaction.

For example, preserving a search input while updating filtered results is useful, but preserving stale component state when moving to an unrelated record may not be desirable.

For search interfaces, I also prefer storing meaningful filters in URL query parameters so users can refresh, bookmark, or share the current view.

The objective is to make navigation predictable and avoid forcing users to repeatedly reconstruct the same context.


12. Avoid unnecessary API calls from React

One of the main reasons I use Inertia is to avoid maintaining duplicate data-fetching infrastructure when Laravel already knows what a page needs.

Consider an application where React loads a page and then immediately makes several API calls:

React Page
    |
    +-- GET /api/projects
    |
    +-- GET /api/project-statistics
    |
    +-- GET /api/current-user
    |
    +-- GET /api/permissions

This can be appropriate in some applications, especially when different data sources have independent refresh requirements.

But if all of this information is already available during the Laravel request, the additional API calls may be unnecessary.

Instead, Laravel can return the initial page data through Inertia:

return Inertia::render('Projects/Index', [
    'projects' => $projects,
    'statistics' => $statistics,
    'permissions' => [
        'create' => $request
            ->user()
            ->can('create', Project::class),
    ],
]);

React receives the data needed to render the initial screen.

This avoids a pattern where the page loads first, displays several loading indicators, and then independently fetches data Laravel could have provided in the original response.

However, not every interaction needs to go through a full Inertia page visit.

I still use dedicated API endpoints when a feature requires independent client-side updates, frequent polling, specialized interactions, or integration with an external system.

Examples might include a live search component, an interactive map, or a background upload interface.

The important thing is to avoid introducing separate API calls simply because the frontend uses React.

Inertia is most useful when you let Laravel provide the page data and reserve separate API endpoints for interactions that genuinely need them.


13. Optimize the database before optimizing the frontend

A React component can be well structured and still feel slow if its backend query is inefficient.

For example, consider retrieving a list of projects and displaying each project's owner.

If the relationship is loaded individually for every project, the application may execute unnecessary database queries.

Instead, eager loading can retrieve the necessary relationships more efficiently.

$projects = Project::query()
    ->select([
        'id',
        'owner_id',
        'name',
        'status',
    ])
    ->with([
        'owner:id,name',
    ])
    ->latest()
    ->paginate(15);

This retrieves the related owner information without requiring a separate owner query for every project.

The selected columns also limit the data retrieved from the database.

For larger lists, pagination is important because retrieving thousands of records can increase database processing time, response payload size, and frontend rendering work.

If a page is still slow, I investigate where the time is being spent.

Is the database query expensive?

Is Laravel serializing too much data?

Is the response transferring a large payload?

Is React rendering an unnecessarily complex component tree?

The answer determines which part of the application needs optimization.

For my current architecture, where Laravel Cloud hosts the application and Supabase hosts PostgreSQL, efficient queries and intentional response payloads are particularly important because the application and database communicate across separate hosting environments.

Reducing unnecessary database operations and transferred data can improve both response time and infrastructure efficiency.

I prefer measuring the actual bottleneck before introducing complicated frontend optimization techniques.


14. Design forms around Inertia's existing capabilities

Inertia provides form handling utilities that work naturally with Laravel's validation and redirect-based workflows.

For example:

import { useForm } from '@inertiajs/react';

export default function CreateProject() {
    const {
        data,
        setData,
        post,
        processing,
        errors,
    } = useForm({
        name: '',
        description: '',
    });

    function submit(
        event: React.FormEvent<HTMLFormElement>
    ) {
        event.preventDefault();

        post('/projects');
    }

    return (
        <form onSubmit={submit}>
            <input
                value={data.name}
                onChange={(event) =>
                    setData('name', event.target.value)
                }
            />

            {errors.name && (
                <p>{errors.name}</p>
            )}

            <textarea
                value={data.description}
                onChange={(event) =>
                    setData(
                        'description',
                        event.target.value
                    )
                }
            />

            <button
                type="submit"
                disabled={processing}
            >
                Create Project
            </button>
        </form>
    );
}

On the Laravel side, a Form Request can handle validation.

class StoreProjectRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name' => [
                'required',
                'string',
                'max:255',
            ],

            'description' => [
                'nullable',
                'string',
                'max:2000',
            ],
        ];
    }
}

The controller can then invoke the appropriate application operation and redirect to the next page.

Inertia integrates with Laravel's validation and redirect behavior, so I don't need to recreate the entire form submission lifecycle manually.

For more complex forms, I extract sections or specialized controls into reusable components.

However, I keep the overall submission flow explicit so it's easy to see which Laravel endpoint receives the data and what should happen after a successful operation.


15. The structure I currently prefer

For a Laravel and React application with several management features, I generally prefer a structure similar to this:

app/
├── Actions/
│   ├── Projects/
│   └── Blogs/
│
├── Http/
│   ├── Controllers/
│   ├── Requests/
│   └── Resources/
│
├── Models/
├── Policies/
└── Services/

resources/js/
├── components/
│   ├── ui/
│   ├── projects/
│   └── blogs/
│
├── layouts/
│   ├── app-layout.tsx
│   └── guest-layout.tsx
│
├── pages/
│   ├── dashboard.tsx
│   ├── projects/
│   │   ├── index.tsx
│   │   ├── create.tsx
│   │   └── show.tsx
│   └── blogs/
│       ├── index.tsx
│       └── edit.tsx
│
├── hooks/
├── types/
└── app.tsx

The backend remains responsible for application operations, authorization, and data retrieval.

The Inertia controller prepares the data required for a particular screen.

The page component receives that data and composes the corresponding interface.

Reusable components handle their own presentation and local interaction.

Layouts provide a consistent application structure across pages.

This separation gives me clear places to look when something needs to change.

If a project is returning incorrect data, I can inspect the backend query or response transformation.

If the page layout needs improvement, I can work within the frontend components without changing the business logic.

If a workflow changes, I can update the corresponding Action without turning the React page into another backend.


16. What I avoid when building Inertia applications

As my applications grow, I try to avoid several patterns that can introduce unnecessary complexity.

Large page components

A page that handles every table, form, modal, filter, and interaction becomes difficult to maintain. I extract meaningful responsibilities into smaller components.

Sending entire Eloquent models without considering the payload

I shape data according to what the screen needs and avoid exposing unnecessary relationships or sensitive fields.

Duplicating backend business rules in React

The frontend may display available actions and provide immediate feedback, but Laravel remains responsible for enforcing authorization and important business rules.

Using usePage() as a replacement for explicit component props

Shared application context belongs in shared props. Ordinary reusable components should generally receive their required data explicitly.

Making separate API requests for data already available during the page request

I let Laravel provide the initial page data and introduce independent API calls when they solve a specific interaction requirement.

Preserving stale page state or data unnecessarily

Persistent layouts, partial reloads, and state preservation are useful, but they must reflect the actual dependencies between the page's data and UI state.

Optimizing without measuring

Before adding caching, deferred loading, or complicated component memoization, I try to identify whether the bottleneck comes from the database, server response, network, or frontend rendering.


Final thoughts

Inertia.js provides a productive middle ground between traditional server-rendered Laravel applications and completely separate frontend and backend applications.

I can keep Laravel responsible for routing, authentication, authorization, database operations, and application workflows while using React to build reusable, interactive interfaces.

But the real benefit comes from how these responsibilities are organized.

A maintainable Inertia application doesn't need every page to become a sophisticated frontend architecture.

It needs page components that are easy to understand, server responses that contain the right data, navigation that behaves predictably, and reusable components that don't depend on unnecessary global state.

For my projects, I try to keep the architecture straightforward:

  • Laravel decides what the application should do and what data the user is allowed to access.
  • Inertia connects the server response to the appropriate frontend page.
  • React handles presentation, interaction, and reusable interface components.
  • Shared layouts and components provide consistency without duplicating unnecessary state or logic.

As the application becomes more complex, I introduce additional patterns where they provide a clear benefit rather than treating every available feature as something that must be implemented.

The best Inertia applications feel simple because the backend and frontend have a clear understanding of their responsibilities.

That simplicity makes the application easier to build, easier to debug, and easier to maintain as new features are introduced.


I'm documenting the architecture, development decisions, and lessons behind my projects as I continue building and improving them.

Website: ShipWithIan

Projects: Explore my projects