diff --git a/fastapi_startkit/src/fastapi_startkit/skills/registry.py b/fastapi_startkit/src/fastapi_startkit/skills/registry.py
index 6d60c5a1..124b4120 100644
--- a/fastapi_startkit/src/fastapi_startkit/skills/registry.py
+++ b/fastapi_startkit/src/fastapi_startkit/skills/registry.py
@@ -35,6 +35,9 @@ class SkillRegistry:
"database": [
".ai/fastapi-startkit/database/SKILL.md",
],
+ "inertia": [
+ ".ai/fastapi-startkit/inertia/SKILL.md",
+ ],
}
def __init__(self, app: "Application") -> None:
diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md
index ace0a63b..db798a26 100644
--- a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md
+++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/fastapi/SKILL.md
@@ -13,36 +13,76 @@ from fastapi_startkit.fastapi import Router
router = Router()
```
-and use the crud resources, for example
+and register routes explicitly, for example
```python
+router.get("/users", users_controller.index)
router.post("/users", users_controller.store)
+router.get("/users/{user_id}", users_controller.show)
router.put("/users/{user_id}", users_controller.update)
-router.patch("/users/{user_id}", users_controller.patch)
-router.delete("/users", users_controller.destroy)
+router.patch("/users/{user_id}", users_controller.update)
+router.delete("/users/{user_id}", users_controller.destroy)
```
-the controller will look like
-```python
-# app/http/controllers/users_controller.py
-async def index(request: Request):
- pass
+### Resourceful controllers
-async def show(user_id: int):
- pass
+Prefer `router.resource()` to register the seven standard CRUD routes in one
+call. It maps to a **resourceful controller** with these methods:
-async def store(data: UserSchema):
- pass
+| Method | Verb & URI | Purpose |
+|-----------|-----------------------------|----------------------------------|
+| `index` | GET `/users` | List the collection |
+| `create` | GET `/users/create` | Show the "new" form |
+| `store` | POST `/users` | Persist a new record |
+| `show` | GET `/users/{user}` | Show a single record |
+| `edit` | GET `/users/{user}/edit` | Show the "edit" form |
+| `update` | PUT/PATCH `/users/{user}` | Persist changes to a record |
+| `destroy` | DELETE `/users/{user}` | Delete a record |
-async def update(user_id: int, data: UserSchema):
- pass
+```python
+# routes/web.py
+router.resource("users", users_controller)
-async def destroy(user_id: int):
- pass
+# subset / exclusions
+router.resource("users", users_controller, only=['index', 'show'])
+router.resource("users", users_controller, excepts=['create', 'edit'])
```
-or use the resource function as:
+A full resourceful controller mirrors those seven methods exactly:
```python
-router.resource("users", users_controller, excepts=['create', 'edit'])
+# app/http/controllers/users_controller.py
+from fastapi_startkit.jsonapi import JsonResource
+
+from app.models import User
+from app.http.requests.user_store_request import UserStoreRequest
+
+async def index():
+ users = await User.all()
+ return JsonResource.collection(users)
+
+async def create():
+ # render/return the "create" form payload
+ ...
+
+async def store(request: UserStoreRequest):
+ user = await User.create(request.model_dump())
+ return JsonResource(user)
+
+async def show(user: int):
+ return JsonResource(await User.find_or_fail(user))
+
+async def edit(user: int):
+ # render/return the "edit" form payload for the record
+ return JsonResource(await User.find_or_fail(user))
+
+async def update(user: int, request: UserStoreRequest):
+ record = await User.find_or_fail(user)
+ await record.update(request.model_dump())
+ return JsonResource(record)
+
+async def destroy(user: int):
+ record = await User.find_or_fail(user)
+ await record.delete()
+ return JsonResource(record)
```
## ORM
diff --git a/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md
new file mode 100644
index 00000000..99190609
--- /dev/null
+++ b/fastapi_startkit/src/fastapi_startkit/skills/stubs/.ai/fastapi-startkit/inertia/SKILL.md
@@ -0,0 +1,333 @@
+---
+name: fastapi-startkit-inertia
+description: Building server-driven SPA pages with Inertia.js + FastAPI in fastapi-startkit (render, props, resources, forms, redirects, shared data).
+---
+
+# Inertia + FastAPI
+
+[Inertia.js](https://inertiajs.com/) lets you build a single-page app using
+classic server-side routing and controllers. In fastapi-startkit you return an
+`InertiaResponse` from a controller and Inertia renders the matching client
+page component (Vue/React/Svelte) with the props you pass.
+
+## Setup
+
+Inertia ships as a provider. Register `InertiaProvider` and add
+`InertiaMiddleware` to the app so `InertiaResponse` can negotiate full-page vs
+XHR (partial) requests.
+
+```python
+from fastapi_startkit.inertia import Inertia, InertiaMiddleware, InertiaProvider
+```
+
+## Rendering a page
+
+Use the `Inertia` facade. `Inertia.render(component, props)` returns an
+`InertiaResponse`; `component` is the client-side page name (e.g.
+`"Projects/Edit"`), `props` is a plain dict serialised to the page.
+
+```python
+# app/http/controllers/projects_controller.py
+from fastapi_startkit.inertia import Inertia, InertiaResponse
+
+from app.models import Project
+from app.http.resources.project_resource import ProjectResource
+
+
+async def edit(project: int) -> InertiaResponse:
+ p = await Project.find_or_fail(project)
+ return Inertia.render("Projects/Edit", {
+ "project": ProjectResource(p).serialize(),
+ })
+```
+
+Pair resources with Inertia to keep the shape you send to the front-end
+consistent. `ProjectResource` is a `JsonResource`; call `.serialize()` to get a
+plain dict suitable for props.
+
+## A resourceful Inertia controller
+
+Inertia controllers follow the same resourceful method names as the rest of the
+framework (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`).
+GET methods render a page; write methods persist and then **redirect** (Inertia
+expects a 303 redirect after `store`/`update`/`destroy`, not a JSON body).
+
+```python
+# app/http/controllers/projects_controller.py
+from fastapi import Request
+from fastapi.responses import RedirectResponse
+from fastapi_startkit.inertia import Inertia, InertiaResponse
+
+from app.models import Project
+from app.http.resources.project_resource import ProjectResource
+
+
+async def index() -> InertiaResponse:
+ projects = await Project.all()
+ return Inertia.render("Projects/Index", {
+ "projects": [ProjectResource(p).serialize() for p in projects],
+ })
+
+
+async def create() -> InertiaResponse:
+ return Inertia.render("Projects/Create")
+
+
+async def store(request: Request):
+ form = await request.json()
+ await Project.create(form)
+ return RedirectResponse(url="/projects", status_code=303)
+
+
+async def show(project: int) -> InertiaResponse:
+ p = await Project.find_or_fail(project)
+ return Inertia.render("Projects/Show", {
+ "project": ProjectResource(p).serialize(),
+ })
+
+
+async def edit(project: int) -> InertiaResponse:
+ p = await Project.find_or_fail(project)
+ return Inertia.render("Projects/Edit", {
+ "project": ProjectResource(p).serialize(),
+ })
+
+
+async def update(project: int, request: Request):
+ p = await Project.find_or_fail(project)
+ await p.update(await request.json())
+ return RedirectResponse(url=f"/projects/{project}/edit", status_code=303)
+
+
+async def destroy(project: int):
+ p = await Project.find_or_fail(project)
+ await p.delete()
+ return RedirectResponse(url="/projects", status_code=303)
+```
+
+Register the routes with `router.resource()`:
+
+```python
+# routes/web.py
+from fastapi_startkit.fastapi import Router
+
+from app.http.controllers import projects_controller
+
+router = Router()
+router.resource("projects", projects_controller)
+```
+
+## Request validation (Pydantic)
+
+Type-hint a `RequestModel` parameter on a write method and FastAPI validates
+the submitted form before your controller runs. `RequestModel` is a Pydantic
+model, so use Pydantic types and `Field(...)` constraints — `EmailStr` for
+emails, constrained `str`, `int`, optional/mixed fields, etc. Invalid input
+raises a `ValidationError` (422) that Inertia surfaces back to the form's
+`errors`.
+
+```python
+# app/http/requests/project_store_request.py
+from typing import Optional
+
+from pydantic import EmailStr, Field
+from fastapi_startkit.fastapi import RequestModel
+
+
+class ProjectStoreRequest(RequestModel):
+ name: str = Field(min_length=2, max_length=120) # required string, length-bounded
+ owner_email: EmailStr # validated email
+ budget: int = Field(ge=0) # integer, non-negative
+ priority: int = Field(default=1, ge=1, le=5) # mixed: optional with bounds
+ description: Optional[str] = Field(default=None, max_length=500)
+```
+
+```python
+# app/http/controllers/projects_controller.py
+from fastapi.responses import RedirectResponse
+
+from app.models import Project
+from app.http.requests.project_store_request import ProjectStoreRequest
+
+
+async def store(request: ProjectStoreRequest):
+ await Project.create(request.model_dump())
+ return RedirectResponse(url="/projects", status_code=303)
+```
+
+## Shared data
+
+Use `Inertia.share()` to expose props on every page (e.g. the authenticated
+user or flash messages) without repeating them in each controller.
+
+```python
+Inertia.share("auth", {"user": current_user})
+Inertia.share({"flash": {"success": "Project updated."}})
+```
+
+# Frontend (React)
+
+Pages live under `resources/js/Pages/` and are resolved by name in
+`resources/js/app.tsx` via `createInertiaApp`. The `component` you pass to
+`Inertia.render("Projects/Edit", ...)` maps to `Pages/Projects/Edit.tsx`, and
+the controller's `props` arrive as the component's props.
+
+```tsx
+// resources/js/app.tsx
+import { createInertiaApp } from '@inertiajs/react'
+import { createRoot } from 'react-dom/client'
+
+createInertiaApp({
+ resolve: name => {
+ const pages = import.meta.glob('./Pages/**/*.tsx', { eager: true })
+ return pages[`./Pages/${name}.tsx`]
+ },
+ setup({ el, App, props }) {
+ createRoot(el).render(
Owner: {project.owner_email}
+ > + ) +} +``` + +## Persistent layouts + +Assign a `layout` function on the page component so the layout instance is +**kept mounted** across Inertia visits (state, scroll position, etc. persist). + +```tsx +// resources/js/Pages/Welcome.tsx +import Layout from './Layout' + +const Welcome = ({ user }: { user: { name: string } }) => { + return ( + <> +Hello {user.name}, welcome to your first Inertia app!
+ > + ) +} + +Welcome.layout = (page: React.ReactNode) =>