Guide/guides

フロントエンドガイド

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: attachmentX-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-clientrenderInertiaServer() を呼び出します。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
  1. ページコンポーネントで 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>
}
  1. codegen が Props を抽出bun run codegenbun 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']>(...)
  }
}
  1. コントローラーが型チェックされる — コントローラーが 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 }
}

AuthorProps の両方が 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:serverbun --hot bin/serve.ts を実行するので、コントローラー・ルート・モデルへの変更は再起動なしで次のリクエストから反映されます。ルートを追加した場合は codegen が走ってもう一度リロードが入り、そこで落ち着きます。ただしプロセス内に持っている状態はリロードをまたぎません。メモリドライバーのセッションとキャッシュは空の状態で作り直され、モジュールレベルの変数も初期化されます。Redis やデータベースなどプロセス外のストアは影響を受けないため、セッションをプロセス外に置いていればサインインしたままです。

このデフォルトより前に作ったプロジェクトでは、自分でフラグを足してください。

"dev:server": "bun --hot bin/serve.ts"

その際は @guren/cli も最新にしてください。古いバージョンは codegen のたびに .guren/ 配下の生成ファイルを内容が同じでも書き直します。コントローラーはそのファイルを import しているため、書き直しが次のリロードを呼び、無限ループになります。

ワークフローを調整したい場合は @guren/core/runtimestartViteDevServer() を使って自前で Vite を制御できます。

これらのパターンでページとコンポーネントを構成すれば、React と Inertia だけでミニマムなボイラープレートの SPA 体験を得られます。