Pages & discovery
Every .tsx file under modules/<name>/<name>/pages/ is an Inertia page, automatically discovered by make gen-pages and resolvable from server-side inertia.render(...) calls.
Page keys
The server identifies a page by a key: "<ModuleName>/<PageName>".
| File | Page key |
|---|---|
modules/orders/orders/pages/Browse.tsx | "Orders/Browse" |
modules/orders/orders/pages/Create.tsx | "Orders/Create" |
modules/orders/orders/pages/admin/Settings.tsx | "Orders/admin/Settings" |
modules/blog_posts/blog_posts/pages/Edit.tsx | "BlogPosts/Edit" |
host/client_app/pages/Landing.tsx | "Landing" (host-level, no namespace) |
<ModuleName>is the module'sModuleMeta.name— by convention the PascalCase of the module's package directory:blog_posts→BlogPosts,file_storage→FileStorage.<PageName>is the path underpages/minus.tsx. Subdirectories become slashes:admin/Settings.tsx→admin/Settings.- Host-level pages live under
host/client_app/pages/and use just the page name without a namespace prefix.
Rendering
# In an endpoint
return await inertia.render(
"Orders/Browse",
props={"orders": [...]},
)The key must resolve against the page map built from modules.generated.ts. Mismatches produce:
SM003(warning) — the.tsxfile exists, but noinertia.render()call in any module references it. The page is orphaned.SM004(warning) — aninertia.render("Orders/Unknown", ...)call exists, but there's no matching.tsxfile. You'll get a runtime error when a user hits that route.
Both fire at app boot.
The generation pipeline
make gen-pages (invoked automatically by make dev before starting Vite) runs smpy host gen-pages --host-dir=host/client_app (simple_module_hosting.manifest.write_module_pages_manifest). It:
- Discovers every installed module's page directory.
- Emits one
import.meta.globcall per module, keyed by the module'smeta.name. - Writes three files to
host/client_app/:modules.generated.ts— exportsmoduleGlobs(aRecord<ModuleName, Record<filePath, loader>>).modules.manifest.json— name → absolute pages dir, used by Vite and diagnostics.modules.generated.css—@sourceentries for wheel-installed module pages.
These files are regenerated on every make dev run (only rewritten when content changes) and are gitignored. Never hand-edit them.
// host/client_app/modules.generated.ts (generated)
export const moduleGlobs: Record<string, Record<string, () => Promise<PageModule>>> = {
"Orders": import.meta.glob<PageModule>("../../modules/orders/orders/pages/**/*.tsx"),
// ...
};host/client_app/pages.ts (hand-written) turns moduleGlobs plus the host's own ./pages/**/*.tsx glob into a { "<ModuleName>/<PageName>": loader } map and exports the resolvePage(name) used by Inertia. Vite resolves these imports normally at build time and HMR-watches them in dev.
Writing a page
A minimal page:
// modules/orders/orders/pages/Browse.tsx
import { useT } from "@simple-module-py/i18n";
import { SectionTitle } from "@simple-module-py/ui/components/SectionTitle";
import type { OrderOut } from "../contracts";
interface BrowseProps {
orders: OrderOut[];
}
export default function Browse({ orders }: BrowseProps) {
const { t } = useT();
return (
<>
<SectionTitle>{t("orders.browse.title")}</SectionTitle>
<ul>
{orders.map((o) => (
<li key={o.id}>
{o.customer_email} — {o.status}
</li>
))}
</ul>
</>
);
}export defaultis required — Inertia's resolver expects a default export.- Props are passed from the server; type them explicitly.
- Use
useT()from@simple-module-py/i18nfor translations andusePage()(with theSharedPropstype from@simple-module-py/ui) forauth/menus.
Subdirectories
Organize larger modules into subdirectories. The page key uses forward slashes:
modules/orders/orders/pages/
├── Browse.tsx
├── admin/
│ ├── Dashboard.tsx → "Orders/admin/Dashboard"
│ └── Settings.tsx → "Orders/admin/Settings"
└── customer/
└── Portal.tsx → "Orders/customer/Portal"Layouts
Shared layouts live in packages/ui/src/layouts/. Use them via JSX composition:
import { AuthenticatedLayout, PageShell } from "@simple-module-py/ui";
export default function Browse({ orders }) {
return (
<AuthenticatedLayout>
<PageShell title="Orders">
...
</PageShell>
</AuthenticatedLayout>
);
}Or use Inertia's persistent-layout pattern for layouts that survive navigation:
Browse.layout = (page: ReactNode) => <AuthenticatedLayout>{page}</AuthenticatedLayout>;The second form re-uses the layout instance across navigations — good for layouts with expensive setup or local state you want preserved.
Shared components
packages/ui is a shared shadcn-based component library. It's a proper npm workspace — import via its package name, not relative paths. Module pages import shadcn primitives and layouts from their subpaths:
// ✅
import { Button } from "@simple-module-py/ui/components/ui/button";
import { StatCard } from "@simple-module-py/ui/components/StatCard";
import { AuthenticatedLayout } from "@simple-module-py/ui/layouts/AuthenticatedLayout";
// ❌
import { Button } from "../../../../packages/ui/src/components/ui/button";The package's exports map and tsconfig paths resolve @simple-module-py/ui/* at build time.
Module-scoped CSS
If your module needs its own CSS (beyond Tailwind classes), put it in modules/<name>/<name>/pages/<name>.css and import it from the page:
import "./Browse.css";make gen-pages also emits modules.generated.css with Tailwind @source entries for wheel-installed module pages (in-repo modules are already covered by the static @source glob in host/client_app/styles.css).
Page-level TypeScript
Each module's tsconfig.json extends the host's; page files can import across module boundaries (from <other_module>/contracts) freely. Cross-module imports from anything other than contracts is a code smell — you're reaching into implementation details.
Testing pages
React tests use Vitest + Testing Library, configured in the repo-root vitest.config.ts + vitest.setup.ts and run via npm test. A minimal page test:
// packages/ui/src/components/StatCard.test.tsx
import { render, screen } from "@testing-library/react";
import Browse from "./Browse";
it("renders orders", () => {
render(<Browse orders={[{ id: 1, customer_email: "a@b.c", status: "pending" }]} />);
expect(screen.getByText(/a@b.c/)).toBeInTheDocument();
});The root vitest.config.ts only includes tests under packages/** and host/client_app/**, so place .test.tsx files there. For components that read shared props (useT, usePage), mock the source module with vi.mock("@simple-module-py/i18n", ...) / vi.mock("@inertiajs/react", ...) — see packages/ui/src/components/LocaleSwitcher.test.tsx.