フロントエンドガイド
Guren は Inertia.js と React を組み合わせ、単一ページアプリの体験を提供します。コントローラーは Inertia レスポンスを返し、フロントエンドは resources/js/pages/ 配下の React コンポーネントを描画します。
フロントエンドガイド
Guren は Inertia.js と React を組み合わせ、単一ページアプリの体験を提供します。コントローラーは Inertia レスポンスを返し、フロントエンドは resources/js/pages/ 配下の React コンポーネントを描画します。
プロジェクト構成
resources/js/app.tsx: Inertia アプリのブートストラップとグローバルプロバイダー登録。resources/js/ssr.tsx: SSR 有効時にバックエンドが利用するサーバーレンダラーをエクスポート。resources/js/pages/: コントローラー応答に対応する React コンポーネント。resources/js/components/: 共有 UI コンポーネント(推奨)。resources/css/app.css: Tailwind など CSS のエントリーポイント。
ページコンポーネント
ファイル名は .guren/pages.gen.ts に自動生成される page definitions と対応します。Props は各ページコンポーネントで interface Props として定義し、codegen で抽出されます。
// Controller
return this.inertia(pages.posts.Index, {
data,
pagination,
})
// resources/js/pages/posts/Index.tsx
import type { PageProps } from '@guren/inertia-client/contracts'
import { Head, Link } from '@inertiajs/react'
import { pages } from '@/.guren/pages.gen'
type Props = PageProps<typeof pages.posts.Index>
export default function Index({ data, pagination }: Props) {
return (
<>
<Head title="Posts" />
<div className="space-y-4">
{data.map((post) => (
<article key={post.id} className="rounded border border-slate-200 p-4">
<h2 className="text-lg font-semibold">{post.title}</h2>
<p className="text-slate-600">{post.excerpt}</p>
<Link className="text-blue-600 underline" href={`/posts/${post.id}`}>
Read more
</Link>
</article>
))}
</div>
<p className="mt-4 text-sm text-slate-500">{pagination.meta.total} posts</p>
</>
)
}
TypeScript で props を型付けするとコンパイル時の安全性が高まります。
レイアウトと共有 UI
ナビゲーションや共通 UI を保つため、ページをレイアウトコンポーネントでラップします。
// resources/js/components/Layout.tsx
export function Layout({ children }: React.PropsWithChildren) {
return (
<div className="min-h-screen bg-slate-50">
<header className="border-b bg-white">
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-4">
<a href="/">Guren</a>
</div>
</header>
<main className="mx-auto max-w-4xl px-6 py-10">{children}</main>
</div>
)
}
// resources/js/pages/posts/Index.tsx
import { Layout } from '@/resources/js/components/Layout'
export default function Index({ posts }: Props) {
return (
<Layout>
{/* page content */}
</Layout>
)
}
フォームとナビゲーション
Inertia のヘルパーでクライアントナビゲーションとフォーム送信を行います。
<Link href="/posts/new">Create Post</Link>でページ遷移してもフルリロードされません。const form = useForm({ title: '', body: '' })でフォーム状態を管理。form.post('/posts')で送信。
バリデーションエラーはコントローラーから返し、クライアント側で form.errors を参照します。
アセットとスタイル
スキャフォールドには Tailwind CSS が設定済みです。resources/css/app.css を編集するか、好みの CSS フレームワークを追加してください。画像やフォントなどの追加アセットは public/ 配下に置きます。
favicon とドキュメント head
本番の HTML はサーバー側で組み立てられ、public/index.html は読み込まれません。そのため同ファイルに <link> を書いてもブラウザには届きません。サイト全体の head マークアップは setInertiaDocument() で登録します。スキャフォールドでは src/app.ts からプレースホルダーの public/favicon.svg をリンク済みです。
import { setInertiaDocument } from '@guren/core'
setInertiaDocument({
head: '<link rel="icon" type="image/svg+xml" href="/favicon.svg" />',
})
マークアップはそのまま出力されるため、開発者が記述した文字列のみを渡してください。public/ 直下のファイルは Bun ランタイムが配信します。Node ベースのデプロイでは CDN から配信してください。
ブラウザがドキュメントとして描画する形式のファイル (.html、.htm、.svg、.xhtml、.xml) には、Content-Disposition: attachment と X-Content-Type-Options: nosniff が付きます。URL を直接開いてもダウンロードになり、スクリプトが自サイトのオリジンで実行されることはありません。画像・スクリプト・スタイルシート・フォントは影響を受けません。<img src="/logo.svg">、CSS の url()、<link rel="icon"> はいずれも従来どおり読み込まれます。Content-Disposition が決めるのは「遷移するかダウンロードするか」だけだからです。ただし <iframe> や <object> への埋め込みは遷移にあたるため、その形での描画は止まります。ユーザー由来のファイルを一切置かない public ディレクトリであれば、rootPublicAssets: { inlineDocuments: true } と inlineDocuments: true でルート系統ごとに無効化できます。それ以外の場合はコントローラーから返してください。
この方針は、アプリより先にプラットフォーム側が public/ を配信するデプロイ先にも引き継がれます。Cloudflare・Vercel・Lambda の各プラグインがビルド時にプラットフォームへ同じ方針を宣言するため、ローカルでダウンロードになるファイルは本番でもダウンロードになります。ただしこの宣言はフレームワークが算出する content type ではなく拡張子を基準にしており、inlineDocuments は届きません。プラグインが読むのはビルド済みディレクトリであって、ルート設定ではないからです。意図的に無効化しているアプリは、プラットフォーム側で取り消してください。生成された .cloudflare/assets/_headers からルールを削除する、.vercel/output/config.json から handle: "hit" のルートを削除する、CDK スタックから CloudFront Function の関連付けを外す、のいずれかの処理をビルドの後段に挟みます。生成物はビルドのたびに作り直されるので、毎回実行する必要があります。
サーバーサイドレンダリング
各アプリには既定で resources/js/ssr.tsx が入り、@guren/inertia-client の renderInertiaServer() を呼び出します。autoConfigureInertiaAssets(app, { importMeta }) でブートすると、Guren は次を自動で処理します。
- 開発時は
bun run devと一緒に Vite dev サーバーを自動起動して管理します。 VITE_DEV_SERVER_URLは、既に起動している外部 Vite dev サーバーへ明示的に向けたい場合だけ使います。- 本番ではビルド済みクライアントマニフェスト (
public/assets/.vite/manifest.json) を検出し、GUREN_INERTIA_ENTRY/GUREN_INERTIA_STYLESを設定。 - SSR マニフェスト (
public/assets/.vite/ssr-manifest.json) を検出し、GUREN_INERTIA_SSR_ENTRY/GUREN_INERTIA_SSR_MANIFESTを設定してサーバーレンダリングを可能にします。
必要なアセットを生成するには codegen を含む標準 build を実行します。
bun run build
コンポーネント解決をカスタムしたい場合は resources/js/ssr.tsx を編集し、renderInertiaServer() に別の resolve を渡します。autoConfigureInertiaAssets を使わない場合は、configureInertiaAssets を呼ぶ前に必要な環境変数を自前でセットしてください。
型安全
Guren はコントローラーとページコンポーネント間のエンドツーエンド型安全を自動 codegen パイプラインで実現します。
型の流れ
flowchart LR
Page["ページコンポーネント<br/>resources/js/pages/posts/Show.tsx<br/>interface Props { post }"]
Codegen["codegen<br/>.guren/pages.gen.ts<br/>PagePropsMap / PageContract"]
Controller["コントローラー<br/>PostController.show()<br/>this.inertia(pages.posts.Show, { post })"]
Page -- "Props を抽出" --> Codegen
Codegen -- "Props の型を供給" --> Controller
- ページコンポーネントで Props を定義 — 各ページが受け取るデータを
interface Propsで宣言します:
// resources/js/pages/posts/Show.tsx
import type { PostResourceData } from '@/app/Http/Resources/PostResource'
interface Props {
post: PostResourceData
}
export default function Show({ post }: Props) {
return <h1>{post.title}</h1>
}
- codegen が Props を抽出 —
bun run codegen(bun run dev時にも自動実行)がすべてのページコンポーネントをスキャンし、interface Propsを抽出して.guren/pages.gen.tsに書き出します:
// .guren/pages.gen.ts(自動生成)
export interface PagePropsMap {
'posts/Show': { post: PostResourceData }
}
export const pages = {
posts: {
Show: defineGeneratedPage<'posts/Show', PagePropsMap['posts/Show']>(...)
}
}
- コントローラーが型チェックされる — コントローラーが
this.inertia(pages.posts.Show, { ... })を呼ぶと、TypeScript が第二引数をPageContractの Props 型と照合します。プロパティの不足や型の不一致はコンパイルエラーになります:
// app/Http/Controllers/PostController.ts
import { pages } from '@/.guren/pages.gen'
export default class PostController extends Controller {
async show() {
const post = await Post.findOrFail(id)
// ✅ 型チェック済み: { post } は Show.tsx の Props と一致する必要がある
return this.inertia(pages.posts.Show, { post: new PostResource(post).toJSON() })
}
}
Props でのローカル型の使用
Props はローカルに定義した型を参照できます。codegen が自動的に収集します:
type Author = { id: number; name: string }
interface Props {
post: { title: string; author: Author }
}
Author と Props の両方が pages.gen.ts に抽出されます。
Props でのインポート型の使用
Resource ファイルなど外部モジュールからインポートした型も追跡されます:
import type { PostResourceData } from '@/app/Http/Resources/PostResource'
interface Props {
post: PostResourceData
}
codegen がインポートパスを書き換え、pages.gen.ts から同じ型を参照できるようにします。
Tips
- バックエンドとフロントエンドで型を共有するため、モデルから Drizzle 推論型を再エクスポートします(例:
export type PostRecord = typeof posts.$inferSelect)。 - 長い相対パスの代わりに
@/エイリアス(プロジェクトルート)を使います。サーバー側は tsconfig のpaths、フロントエンドビルドは Guren Vite プラグインが解決します。 - Props を追加・変更した後は
bun run codegenを実行してpages.gen.tsを更新してください。
ホットリロード
bun run dev を実行すると Bun が自動で Vite dev サーバーを起動するため、TSX の変更は即時リロードされます。
バックエンドもリロードされます。dev:server は bun --hot bin/serve.ts を実行するので、コントローラー・ルート・モデルへの変更は再起動なしで次のリクエストから反映されます。ルートを追加した場合は codegen が走ってもう一度リロードが入り、そこで落ち着きます。ただしプロセス内に持っている状態はリロードをまたぎません。メモリドライバーのセッションとキャッシュは空の状態で作り直され、モジュールレベルの変数も初期化されます。Redis やデータベースなどプロセス外のストアは影響を受けないため、セッションをプロセス外に置いていればサインインしたままです。
このデフォルトより前に作ったプロジェクトでは、自分でフラグを足してください。
"dev:server": "bun --hot bin/serve.ts"
その際は @guren/cli も最新にしてください。古いバージョンは codegen のたびに .guren/ 配下の生成ファイルを内容が同じでも書き直します。コントローラーはそのファイルを import しているため、書き直しが次のリロードを呼び、無限ループになります。
ワークフローを調整したい場合は @guren/core/runtime の startViteDevServer() を使って自前で Vite を制御できます。
これらのパターンでページとコンポーネントを構成すれば、React と Inertia だけでミニマムなボイラープレートの SPA 体験を得られます。