agentsclimarketplace

Labrodev inertia react

Skill labrodev/laravel-playbook/skills/labrodev-inertia-react

An opinionated Laravel playbook for AI-assisted development. Architecture conventions, coding standards, and stubs for Cursor, Claude Code, Codex, and Junie — installed via Laravel Boost.

Install
npx -y skills add labrodev/laravel-playbook --skill labrodev-inertia-react

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

Use when creating or reviewing React/Inertia frontend code in a Labrodev Laravel project — Inertia page components under resources/js/pages, typed Props interfaces, useForm form submissions, Wayfinder route usage, t()/lang/*.json i18n wiring, flash/toast consumption, destructive-action confirmation dialogs, or in-page JSON API calls via @/lib/http.

The file declares its own license as MIT. That is the author’s claim about this one file, and it is not the same thing as the license GitHub reports for the repository, which is listed with the other numbers below.

SKILL.md

17.2 KB, ~3.9k tokens by cl100k_base, as published. Nobody here has run it

Inertia + React Frontend

Part of the Labrodev playbook skill set — assumes labrodev-core and labrodev-naming are installed. If absent, minimum global rules: declare(strict_types=1), final classes, App/Layer depends on Core (never the reverse), named-argument invocation.

The frontend is a delivery surface, not a business layer. It renders data shaped by ViewModels and Resources, resolves UI copy from i18n, collects user input, and sends it back. It never decides, calculates, authorizes, or compensates for missing backend work. Backend prop assembly (ViewModels, Resources, field allowlisting) → see the labrodev-viewmodel-resource skill.

Stack: React 19, Inertia.js v2 (React adapter), TypeScript (strict), Tailwind CSS v4, Wayfinder (generated route functions), Vite.

Rules

Musts

  • One Inertia page = one file at resources/js/pages/{render-path}.tsx, where {render-path} is exactly the kebab-case string passed to Inertia::render(...) on the backend. Inertia::render('bookings/index', ...)pages/bookings/index.tsx.
  • Every page is a default-exported PascalCase function component named {Resource}{Action} (BookingIndex, BookingShow, BookingForm), with an explicit interface Props at the top of the file matching the ViewModel output exactly.
  • Every page wraps its content in exactly one layout (AppLayout for dashboard pages, AuthLayout variants for auth, SettingsLayout for settings), passes breadcrumbs, and renders <Head title={t('…')} />.
  • All routes come from Wayfinder — generated functions under @/routes/ (named routes) and @/actions/ (controller actions). Use <Link href={...}> and router.visit()/router.delete() from @inertiajs/react for navigation.
  • All forms use useForm from @inertiajs/react, posting to the per-action routes (store/update/remove) via Wayfinder. Field names are snake_case and match the backend Data class attributes → see the labrodev-data skill.
  • All user-facing text goes through t() backed by Laravel lang/*.json. Keys are readable English source strings (t('Save changes')), never dotted slugs (t('common.save')).
  • Enum display/typing follows the enum contract: render *_label, use the raw value for logic → see the labrodev-enum skill.
  • In-page JSON calls (autocomplete, wizards, live calculations) go through the helpers in @/lib/http only, targeting JsonController routes via Wayfinder, with explicit 419/401/403/422 handling.
  • Destructive actions use an AlertDialog confirmation requiring the user to type the entity's unique business key, then router.delete() to the Wayfinder route.
  • Rows in index tables are identified by uuid in URLs — routes bind {model:uuid}, so viewHref and form targets receive item.uuid, never a database id. Backend binding rules → see the labrodev-controller skill.

Must-nots

  • No hardcoded user-facing strings in .tsx — no JSX literals, no ?? 'Fallback text' shipped as a substitute for t(). A missing key is a lang/ bug to fix, not something to patch in React.
  • No translations prop (or any map of UI strings) in page Props — ViewModels do not ship page copy; t() does.
  • No fetching of initial page data (fetch(), axios, SWR, useEffect loaders) — initial data comes exclusively from Inertia props.
  • No hand-written URL strings, no <a href="/..."> for internal links — Wayfinder only.
  • No hardcoded select option values — options come from ViewModel props.
  • No business rules, calculations, filtering, sorting, or authorization checks in React — that belongs in Core Actions/Rules, IndexQueries, ViewModels, or Policies on the backend.
  • No window.confirm for destructive actions.
  • No raw fetch()/axios for JSON API calls — @/lib/http handles CSRF, session cookies, and headers.
  • No editing files under @/routes/ or @/actions/ — they are Wayfinder-generated.
  • No new UI primitives when an equivalent exists in components/ui/; no inline style attributes or per-component CSS files — Tailwind utilities with cn() from @/lib/utils.
  • No faking or muting backend gaps — never hardcode data to make something "look like it works", never silently swallow errors, never hide a missing prop behind an empty state. Make the gap visible; fixing it is a backend task.

Directory structure

resources/js/
    pages/          — Inertia page components (one file per route, kebab-case path = render string)
    layouts/        — AppLayout, AuthLayout variants, SettingsLayout
    components/     — shared reusable components
    components/ui/  — primitives (button, input, card, …)
    components/dashboard/ — composed dashboard components (DataTable, HorizontalInput, SectionCard, …)
    hooks/          — custom React hooks (use-i18n, …)
    types/          — shared TypeScript types (SharedData, BreadcrumbItem, PaginatedData, …)
    lib/            — utilities (cn(), http.ts)
@/routes/, @/actions/ — Wayfinder-generated; never edit manually

A helper component used by only one domain's pages may be co-located inside that domain's pages/ subfolder (e.g. pages/bookings/status-badge.tsx) — it must not be default-exported (default export is reserved for page components). Once it is used by a second domain, move it to components/.

Index page template

Naming pattern: file pages/{resource-plural}/index.tsx, component {Resource}Index. Worked example for a Booking domain:

import { Head } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import { DataTable } from '@/components/dashboard/data-table';
import { useI18n } from '@/hooks/use-i18n';
import dashboard from '@/routes/dashboard';
import * as bookingRoutes from '@/routes/bookings';
import type { BreadcrumbItem, PaginatedData } from '@/types';

// Literal union mirroring the backend enum's backing values (BookingStatus)
type BookingStatusValue = 1 | 2 | 3;

interface Booking {
    uuid: string;
    reference: string;
    status: BookingStatusValue;
    status_label: string; // pre-translated on the backend
    booked_at: string;
}

interface Props {
    bookings: PaginatedData<Booking>;
    statusOptions: Array<{ value: BookingStatusValue; label: string }>;
}

export default function BookingIndex({ bookings, statusOptions }: Props) {
    const t = useI18n();

    const breadcrumbs: BreadcrumbItem[] = [
        { title: t('Dashboard'), href: dashboard.index().url },
        { title: t('Bookings') },
    ];

    return (
        <AppLayout breadcrumbs={breadcrumbs}>
            <Head title={t('Bookings')} />
            <DataTable
                columns={[
                    { key: 'reference', label: t('Reference'), sortable: true },
                    {
                        key: 'status',
                        label: t('Status'),
                        filterable: true,
                        filterType: 'select',
                        filterOptions: statusOptions,
                        render: (row: Booking) => row.status_label ?? String(row.status),
                    },
                    { key: 'booked_at', label: t('Booked at'), sortable: true },
                ]}
                data={bookings}
                baseUrl={bookingRoutes.index().url}
                viewHref={(row: Booking) => bookingRoutes.show(row.uuid).url}
                idKey="uuid"
            />
        </AppLayout>
    );
}

Notes on the template:

  • Props mirrors the ViewModel toArray() output exactly. Which fields exist there is decided on the backend → see the labrodev-viewmodel-resource skill.
  • An unknown enum value renders an explicit fallback (the raw value), never nothing.
  • idKey="uuid" because show/update routes bind {booking:uuid}.

Form template (create + edit in one component)

Naming pattern: file pages/{resource-plural}/form.tsx, component {Resource}Form. Field names are snake_case and mirror the Data class attributes.

import { Head } from '@inertiajs/react';
import { useForm } from '@inertiajs/react';
import AppLayout from '@/layouts/app-layout';
import FlashAlert from '@/components/flash-alert';
import { HorizontalInput } from '@/components/dashboard/horizontal-input';
import { HorizontalSelect } from '@/components/dashboard/horizontal-select';
import { Button } from '@/components/ui/button';
import { useI18n } from '@/hooks/use-i18n';
import * as bookingRoutes from '@/routes/bookings';

interface Props {
    booking?: { uuid: string; reference: string; status: number } | null;
    statusOptions: Array<{ value: number; label: string }>;
}

export default function BookingForm({ booking, statusOptions }: Props) {
    const t = useI18n();
    const isEdit = Boolean(booking?.uuid);

    const { data, setData, post, put, processing, errors } = useForm({
        reference: booking?.reference ?? '',
        status: booking?.status ?? statusOptions[0]?.value ?? '',
    });

    const onSubmit = (e: React.FormEvent) => {
        e.preventDefault();
        if (isEdit && booking?.uuid) {
            put(bookingRoutes.update(booking.uuid).url, { preserveScroll: true });
        } else {
            post(bookingRoutes.store().url);
        }
    };

    return (
        <AppLayout breadcrumbs={[]}>
            <Head title={isEdit ? t('Edit booking') : t('Create booking')} />
            <FlashAlert />
            <form onSubmit={onSubmit}>
                <HorizontalInput
                    label={t('Reference')}
                    value={data.reference}
                    onChange={(v: string) => setData('reference', v)}
                    error={errors.reference}
                    autoComplete="off"
                />
                <HorizontalSelect
                    label={t('Status')}
                    value={data.status}
                    options={statusOptions}
                    onChange={(v: string) => setData('status', Number(v))}
                    error={errors.status}
                />
                <Button type="submit" disabled={processing}>
                    {t('Save changes')}
                </Button>
            </form>
        </AppLayout>
    );
}

Form rules recap: initial values come from props (pre-filled for edit, empty defaults for create); validation errors from errors display inline (the Horizontal* dashboard components embed InputError; use InputError directly with ui/ primitives); processing disables submit; preserveScroll: true on edit forms; autoComplete="off" on business-data inputs (not on auth forms). The backend controller flashes a toast with Inertia::flash('toast', ...) and redirects with to_route() → see the labrodev-controller skill.

TypeScript conventions

  • interface for object shapes, type for unions and aliases. Page-specific interfaces live at the top of the page file; shared types in types/ re-exported from types/index.ts.
  • PascalCase for interfaces, types, and components; camelCase for variables, functions, and constants (no UPPER_SNAKE).
  • any is discouraged; prefer unknown plus narrowing. Use ?? and ?. for null safety.
  • Optional props (?) only when the backend contract is genuinely optional (e.g. booking?: Booking | null on a shared create/edit form). Never use ? to hide missing required data.
  • Shared per-page data (auth, flash, sidebarOpen) is typed as SharedData in types/index.ts and accessed via usePage<SharedData>() only when needed.

i18n contract

  • Authoritative source: Laravel lang/*.json. The runtime frontend dictionary (modules the Vite pipeline loads) must stay in sync — same keys, same source strings.
  • Every string passed to t('…') must exist in that pipeline. A missing key is a backend/asset-pipeline bug; surface it visibly, never invent a JSX fallback.
  • Keys are readable English source strings. Split lang/ into multiple JSON files by domain if volume demands — do not invent dot hierarchies.
  • The single exception to "all copy via t()": enum labels, which follow the enum contract pointer in Musts → see the labrodev-enum skill.

Flash / toast consumption

The backend flashes with Inertia::flash('toast', ...) before to_route() (→ see the labrodev-controller skill). On the frontend:

  • Flash data arrives in shared props (usePage<SharedData>().props.flash).
  • Form and mutation pages render FlashAlert (@/components/flash-alert) to display success/error flashes; the app shell's toast listener consumes the toast flash for transient notifications.
  • Never re-implement flash display ad hoc per page; use the shared components.

Destructive-action confirmation

Delete/cancel actions must:

  1. Open an AlertDialog (modal from components/ui/), never window.confirm.
  2. Require the user to type the entity's unique business key (e.g. the booking reference) into an input.
  3. Keep the confirm button disabled until the typed value matches exactly.
  4. On confirm, submit router.delete(bookingRoutes.remove(booking.uuid).url) (Wayfinder route; the backend exposes deletion as POST/DELETE per its route conventions → see the labrodev-controller skill).

JSON API calls from pages

For in-page reactivity (autocomplete, multi-step wizards, server-side calculations) call JsonController endpoints. Backend anatomy, naming, and the json/ route prefix → see the labrodev-controller skill. Frontend rules:

  • Use jsonGet / jsonPost from @/lib/http only — it sends Accept: application/json, Content-Type: application/json, the decoded X-XSRF-TOKEN header, and credentials: 'include'. Never hand-roll CSRF handling.
  • Target the endpoint via its Wayfinder function.
  • Required status handling: 419window.location.reload() (refresh CSRF token); 401/403 → redirect to login; 422 → display validation errors from the response body; 500/other → generic user-visible error. Never silently swallow a failed JSON call.
import { jsonPost } from '@/lib/http';
import * as bookingSlotRoutes from '@/routes/bookings/json';

const response = await jsonPost(bookingSlotRoutes.slots().url, {
    service_uuid: selectedService,
    date: selectedDate,
});
if (response.status === 419) { window.location.reload(); return; }
if (response.status === 401 || response.status === 403) { window.location.href = '/login'; return; }
if (!response.ok) { /* surface the error to the user */ }
const slots = await response.json();

Never use JSON calls for initial page data (Inertia props), for form submissions that navigate (useForm), or to replace Inertia v2 built-ins (polling, deferred props, prefetching).

Edge cases

  • Inertia v2 features (deferred props, polling, prefetching, merge/infinite scroll with WhenVisible, once props) are allowed; consult the Inertia v2 docs before implementing. Deferred props always get a skeleton/loading placeholder.
  • Data exposure: everything in page props is serialized into the DOM data-page attribute and is public. During development, inspect it — if you see internal ids, secrets, or fields the page does not render, the fix is in the Resource/ViewModel allowlist → see the labrodev-viewmodel-resource skill.
  • Show pages compose SectionCard + InfoRow from @/components/dashboard/ for read-only label/value layouts; KPI tiles use DashboardCard.
  • Breadcrumbs: array of BreadcrumbItem per page; the last item carries no href (current page); titles via t().
  • Missing backend data (prop, option list, translation, calculation): render a visible gap or error, file it as a backend task. Do not fill it with frontend logic.

Review checklist

  1. Does the page file path exactly mirror the Inertia::render string (pages/{render-path}.tsx, kebab-case), with a default-exported PascalCase {Resource}{Action} component?
  2. Is there an explicit interface Props matching the ViewModel output, with no translations prop, and do enum fields follow the enum contract pointer in Musts (labrodev-enum)?
  3. Are all user-facing strings resolved via t('Readable English') — zero hardcoded JSX copy, zero dotted slug keys, zero shipped fallbacks?
  4. Do all links, form targets, and JSON calls use Wayfinder-generated route functions (no hand-written URLs, {model:uuid} values passed as item.uuid)?
  5. Do forms use useForm with snake_case fields matching the Data class, inline InputError display, processing-disabled submit, and FlashAlert rendered?
  6. Is initial page data delivered only via Inertia props (no fetch/axios/useEffect loaders), with select options coming from ViewModel props?
  7. Do destructive actions use an AlertDialog with typed-business-key confirmation and router.delete() (no window.confirm)?
  8. Do JSON API calls go through @/lib/http with explicit 419/401/403/422 handling and no silently swallowed errors?
  9. Is the component free of business logic, authorization checks, and frontend workarounds for missing backend functionality?
  10. Does inspecting the data-page attribute show only fields the page actually renders (no internal ids or sensitive data)?

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.