近年、マルチページアプリケーション(MPA)の人気は下火傾向にあり、Facebook、Twitter、YouTube、Githubなどの大手サービスはすでに、シングルページアプリケーション(SPA)技術を採用しています。

SPAは今流行りの技術で、クライアントサイドですべてをレンダリングし、ウェブアプリケーションを高速かつレスポンシブに操作することができます。その一方で、LaravelやDjangoのようなフレームワークを使用し、サーバーサイドレンダリング(SSR)アプリケーションを構築してきた開発者の中には、この流れを好ましく思わない人もいるでしょう。

しかしながら、Inertia.jsが登場したことにより、こうした状況は一変しました。

この記事では、Inertia.jsの基礎をご説明し、Laravel、Vue.js、Tailwind CSSと組み合わせたモダンなブログアプリの作成例を見ていきます。また、SPAのSEOを向上するヒントもご紹介します。

なお、Laravelを使い始めたばかりの方は、まずこちらの記事をご覧ください。

SPAが注目される理由

Inertiaを使用するべき理由を考える前に、まずはSPAの人気の秘密について触れておきます。

従来のサーバーサイドレンダリングよりもクライアントサイドレンダリングが好まれるのはなぜでしょうか。そして、Laravelのフルスタック開発者が、Bladeコンポーネントに別れを告げなければならない理由とは。

簡単に言えばユーザーとの関係性を深めるためには、スピードと応答性が重要だからです。

MPAの場合、ブラウザは常にバックエンドにリクエストを送信し、バックエンドはデータベースに対して多数のクエリを実行します。クエリはテータベースとサーバーに処理され、ブラウザに送信された後、ページがレンダリングされます。

ところがSPAでは、アプリケーションはすべてのデータを直接ページに読み込むため、ブラウザはクエリを送信する必要はなく、また新たなHTML要素のレンダリングにページを再読み込みする必要もありません。

このユーザー体験を格段に向上させる利便性を理由に、多くの大企業が自社サイトのシングルページアプリケーション化を推進しています。

しかし、Laravel開発者にとって、シングルページアプリケーションの作成は非常に複雑になります。と言うのも、Bladeテンプレートの代わりにVue.jsやReactを使わなければならず、時間と労力を削減できる多くのLaravelアセットを手放さなければならないためです。

そんな状況を打破するために登場したのが、Inertia.jsです。

Inertiaが優れている理由

Inertiaがリリースされるまでは、Laravel開発者がVueでウェブSPAを開発しようとすると、LaravelでAPIを設定してJSONデータを返し、AXIOSのようなものを使用して、Vueコンポーネントでデータを取得しなければなりませんでした。さらに、ルートの管理にはVue Routerなどが必要になり、Laravelのルーティングは、ミドルウェアやコントローラ同様、無用の長物となっていました。

ところが、Inertia.jsでは、従来のサーバーサイドのルーティングとコントローラを使用して、モダンなシングルページVue、React、Svelteアプリを構築することができます。InertiaはLaravel、Ruby on Rails、Django開発者向けに設計され、コントローラの作成、データベースのデータ取得、ビューのレンダリングなど、アプリの構築に従来のコーディング技術をそのまま使うことができます。

Inertia.jsを使えば、Laravel開発者はこれまで通り作業が行えます。

Inertiaの仕組み

LaravelとVueだけでSPAを構築すると、フロントエンドはJavaScriptのみを使用したページとなり、これだけではシングルページの操作性は得られません。リンクをクリックするたびに、ページの読み込みによってクライアントサイドのフレームワークが再起動するためです。

そこで、Inertiaの出番です。

Inertiaは、基本的にクライアントサイドのルーティングライブラリです。ページ全体を再読み込みすることなく、ページを移動できます。移動は標準的なアンカータグの軽量ラッパー、<Link>コンポーネントによって実現します。

Inertiaのリンクをクリックすると、Inertiaがそのクリックを遮断し、XHRにリダイレクトします。これによって、ブラウザでページが再読み込みされることがなくなり、シングルページとしての挙動が確保できます。

Inertia入門

Inertia.jsで作成したサンプルページ
Inertia.jsで作成したサンプルページ

例として、ブログアプリ「Kinsta Blog」を構築しながら、InertiaとLaravelとの統合方法をご説明していきます。この例では、バックエンドにLaravel、JavaScriptのフロントエンドにVue.js、スタイリングにTailwind CSSという組み合わせを使用します。

DevKinstaを使用すると、これからご紹介する手順をローカル環境で実際に試すことができます。DevKinstaは、開発者、デザイナー、代行業者向けの高機能ツールで、シングルページおよびマルチページのWordPressウェブアプリを構築することができます。WordPressは、Corcelパッケージを使ってLaravelと簡単に統合可能です。

前提条件

これからご紹介する操作には、以下の知識が必要になります。

  • Laravelの基礎知識(インストール、データベース、データベースマイグレーション、Eloquentモデル、コントローラ、ルーティング)
  • jsの基礎知識(インストール、構造、フォーム)

不安が残る方は、Laravelの学習コンテンツ(有料・無料)をご覧ください。

ステップ1. 主要な要素のインストール

今回はあくまでInertia.jsの使用方法が主題になるため、以下のセットアップは完了しているものとして進めていきます。

  1. 新規にインストールしたLaravel 9プロジェクトkinsta-blog
  2. LaravelプロジェクトにインストールしたTailwind CSS CLI
  3. 画像ファイル「kinsta-logo.png」(https://kinsta.com/press/からKinsta logo packをダウンロードして解凍し、kinsta-logo2.pngをpublic/imagesディレクトリにkinsta-log.pngとしてコピーしてください)
  4. 以下、kinsta-blog/resources/views内に作成した、ブログのトップページ表示用と記事表示用の2つのBladeコンポーネント/resources/views/index.blade.php
    <!DOCTYPE html>
    <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
    
        <title>Kinsta Blog</title>
      </head>
    
      <body>
        <header>
          <h1>Kinsta Blog</h1>
        </header>
    
        <main>
          <h2>Read our latest articles</h2>
    
          <section>
            <article>
              <div>
                <img src="/images/kinsta-logo.png" alt="Article thumbnail" />
              </div>
    
              <h3>Title for the blog</h3>
              <p>
                Lorem, ipsum dolor sit amet consectetur adipisicing elit. Illum rem
                itaque error vel perferendis aliquam numquam dignissimos, expedita
                perspiciatis consectetur!
              </p>
    
              <a href="#">Read more</a>
            </article>
          </section>
        </main>
    
        <footer>
          <h2>Join our Newsletter</h2>
    
          <input type="email" />
        </footer>
      </body>
    </html>

    /resources/views/show.blade.php:

    <!DOCTYPE html>
    <html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
      <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
    
        <title>Kinsta Blog</title>
      </head>
    
      <body>
        <main>
          <article>
            <div>
              <img src="/images/kinsta-logo.png" alt="Article thumbnail" />
            </div>
    
            <h1>Title for the blog</h1>
    
            <p>Article content goes here</p>
          </article>
        </main>
    
        <footer>
          <h2>Join our Newsletter</h2>
    
          <input type="email" />
        </footer>
      </body>
    </html>
  5. プロジェクトに接続された、MySQLローカルデータベースkinsta_blog .env
    DB_CONNECTION=mysql
    DB_HOST=127.0.0.1
    DB_PORT=3306
    DB_DATABASE=kinsta_blog
    DB_USERNAME=root
    DB_PASSWORD=
  6. 記事(Article)のモデル、マイグレーション、ファクトリapp/Models/Article.php
    <?php
    
    namespace AppModels;
    
    use Illuminate\Database\Eloquent\Factories\HasFactory;
    use Illuminate\Database\Eloquent\Model;
    
    class Article extends Model
    {
        use HasFactory;
    
        protected $fillable = ['title', 'excerpt', 'body'];
    }

    database/migrations/create_articles_table.php

    <?php
    
    use Illuminate\Database\Migrations\Migration;
    use Illuminate\Database\Schema\Blueprint;
    use Illuminate\Support\Facades\Schema;
    
    return new class extends Migration
    {
    
        public function up()
        {
            Schema::create('articles', function (Blueprint $table) {
                $table->id();
                $table->string('title');
                $table->text('excerpt');
                $table->text('body');
                $table->timestamps();
            });
        }
    
        public function down()
        {
            Schema::dropIfExists('articles');
        }
    };

    database/factories/ArticleFactory.php

    <?php
    
    namespace DatabaseFactories;
    
    use Illuminate\Database\Eloquent\Factories\Factory;
    
    class ArticleFactory extends Factory
    {
    
        public function definition()
        {
            return [
                'title' => $this->faker->sentence(6),
                'excerpt' => $this->faker->paragraph(4),
                'body' => $this->faker->paragraph(15),
            ];
        }
    }

以上を用意したら、早速Inertia.jsを導入しましょう。

ステップ2. Inertiaのインストール

Inertiaのインストール作業は、大きくサーバーサイド(Laravel)とクライアントサイド(VueJs)の2段階に分かれます。

Laravel9ではデフォルトでViteを使用するため、Inertiaの公式インストールドキュメントは少し古いものですが、併せてご紹介します。

1. サーバーサイド

まず最初にInertiaのサーバーサイドアダプターをインストールします。ターミナルで以下のComposerコマンドを実行してください。

composer require inertiajs/inertia-laravel

次にルートテンプレートをセットアップします。ルートテンプレートは、CSSとJSファイルのロードに使用される単一のBradeファイルです。またJavaScriptアプリケーションの起動に使用されるInertiaルートにもなります。

今回は、執筆時点で最新版のLaravel 9 v9.3.1を使用するため、Viteを有効化します。/resources/views/app.blade.phpのタグ内にViteを記述してください。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <!-- Fetch project name dynamically -->
    <title inertia>{{ config('app.name', 'Laravel') }}</title>

    <!-- Scripts -->
    @vite('resources/js/app.js') @inertiaHead
  </head>

  <body class="font-sans antialiased">
    @inertia
  </body>
</html>

<title>タグにinertia属性を追加して、プロジェクトのタイトルを動的に取得していることに注意してください。

また<head>に、@viteディレクティブを挿入しています。これで、Viteはアプリを実装しCSSをインポートしたJavaScriptメインファイルのパスを検知できます。Viteは、JavaScriptとCSSの開発を支援するツールで、ローカル環境での開発中にページを更新することなく、フロントエンドの変更を確認することができます。

次にHandleInertiaRequestsミドルウェアを作成し、プロジェクトに公開します。プロジェクトのルートディレクトリで、以下のターミナルコマンドを実行してください。

php artisan inertia:middleware

完了したら、app/Http/Kernel.phpを開き、ウェブミドルウェアの最後の項目としてHandleInertiaRequestsを登録します。

'web' => [
    // ...
    App\Http\Middleware\HandleInertiaRequests::class,
],

2. クライアントサイド

次に、サーバーサイドと同じように、フロントエンドのVue.js 3の依存関係をインストールします。

npm install @inertiajs/inertia @inertiajs/inertia-vue3
// or
yarn add @inertiajs/inertia @inertiajs/inertia-vue3

続いてVue.js 3をインストールします。

npm install vue@next

メインのJavaScriptファイルを更新し、Vue.js 3、Vite、LaravelでInertia.jsを初期化します。

resources/js/app.js

import "./bootstrap";
import "../css/app.css";

import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/inertia-vue3";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";

createInertiaApp({
  title: (title) => `${title} - ${appName}`,
  resolve: (name) =>
    resolvePageComponent(
      `./Pages/${name}.vue`,
      import.meta.glob("./Pages/**/*.vue")
    ),
  setup({ el, app, props, plugin }) {
    return createApp({ render: () => h(app, props) })
      .use(plugin)
      .mount(el);
  },
});

上のコードでは、LaravelのプラグインresolvePageComponentを使用して、ディレクトリ./Pages/$name.vueからコンポーネントを解決するように指示しています。後ほど、このディレクトリにInertiaコンポーネントを保存します。このプラグインによって、ディレクトリから自動でコンポーネントを読み込むことができます。

あとは、vitejs/plugin-vueをインストールするだけです。

npm i @vitejs/plugin-vue

そして、vite.config.jsファイルを更新します。

import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
  plugins: [
    laravel({
      input: ["resources/css/app.css", "resources/js/app.js"],
      refresh: true,
    }),
    vue({
      template: {
        transformAssetUrls: {
          base: null,
          includeAbsolute: false,
        },
      },
    }),
  ],
});

最後のステップは、依存関係のインストールとファイルのコンパイルです。

npm install

npm run dev

これで、Vue.js 3とViteを使用したLaravel 9アプリケーションが完成しました。次は動作を実装していきましょう。

Inertiaページの作成

ステップ1の冒頭で、トップページと記事を表示する2つのBladeファイル(indexshow)について触れました。

Inertiaの使用中に必要なBladeファイルはapp.blade.phpだけですが、すでに一度Inertiaのインストール時に使用しています。ではこの2つのBladeファイルはどうなるのかと疑問を抱いている方もいるはずです。

この2つのBladeファイルは、bladeコンポーネントからInertia.jsコンポーネントに変換します。

Inertiaにより、アプリケーションの各ページには、自身のコントローラとJavaScriptコンポーネントがあり、APIを使用せずに、ページに必要なデータのみを取得することができます。InertiaページはJavaScriptコンポーネント、この例ではVue.jsコンポーネントです。特別な点は特にないため、HTMLのコンテンツはすべて<template>タグで囲み、JavaScript関連はすべて<script>タグで囲みます。

「Pages」フォルダを作成してファイルを移動、つまり./resources/js/Pages内に、index.blade.phpshow.blade.phpを移動します。次にファイル拡張子を「.blade.php」から「.vue」に変更し、名前の最初の文字を大文字にして、中身を標準のVue.jsコンポーネントに変換します。<html><head><body>タグは、メインルートのbladeコンポーネントにすでに含まれているため除外します。

resources/js/Pages/Index.vue

<script setup>
  //
</script>

<template>
  <header>
    <h1>Kinsta Blog</h1>
  </header>

  <main>
    <h2>Read our latest articles</h2>

    <section>
      <article>
        <div>
          <img src="/images/kinsta-logo.png" alt="Article thumbnail" />
        </div>

        <h3>Title for the blog</h3>
        <p>
          Lorem, ipsum dolor sit amet consectetur adipisicing elit. Illum rem
          itaque error vel perferendis aliquam numquam dignissimos, expedita
          perspiciatis consectetur!
        </p>

        <a href="#">Read more</a>
      </article>
    </section>
  </main>

  <footer>
    <h2>Join our Newsletter</h2>

    <input type="email" />
  </footer>
</template>

resources/js/Pages/Show.vue

<script setup>
  //
</script>

<template>
  <header>
    <h1>Welcome to Kinsta Blog</h1>
  </header>

  <main>
    <article>
      <h1>Title for the blog</h1>

      <p>Article content goes here</p>
    </article>
  </main>

  <footer>
    <h2>Join our Newsletter</h2>

    <input type="email" />
  </footer>
</template>

各コンポーネントにヘッダーとフッターをコピー&ペーストするのは、あまり効率的とは言えません。そこで、永続コンポーネントを保存するInertiaの基本レイアウトを作成します。

「Layouts」フォルダを/resources/js下に作成し、その中に「KinstaLayout.vue」ファイルを作成します。このレイアウトでラップされたコンポーネントが中に埋め込まれるよう、このファイルには、ヘッダーとフッター、<slot />を含むmainを格納します。ファイルは以下のようになります。

resources/js/Layouts/KinstaLayout.vue

<script setup></script>

<template>
    <header>
    <h1>Kinsta Blog</h1>
  </header>

  <main>
        <slot />
  </main>

  <footer>
    <h2>Join our Newsletter</h2>

    <input type="email" />
  </footer>

</template>

次にこのレイアウトをページにインポートして、すべてのHTMLコンテンツをラップします。このコンポーネントは、以下の通りです。

Index.vue

<script setup>
import KinstaLayout from "../Layouts/KinstaLayout.vue";
</script>

<template>
  <KinstaLayout>
    <section>
      <h2>Read our latest articles</h2>
      <article>
        <div>
          <img src="/images/kinsta-logo.png" alt="Article thumbnail" />
        </div>

        <h3>Title for the blog</h3>
        <p>
          Lorem, ipsum dolor sit amet consectetur adipisicing elit. Illum rem
          itaque error vel perferendis aliquam numquam dignissimos, expedita
          perspiciatis consectetur!
        </p>

        <a href="#">Read more</a>
      </article>
    </section>
  </KinstaLayout>
 </template>

Show.vue

<script setup>
 import KinstaLayout from "../Layouts/KinstaLayout.vue";
</script>

<template>
  <KinstaLayout>
    <article>
      <h1>Title for the blog</h1>

      <p>Article content goes here</p>
    </article>
  </KinstaLayout>
</template>

LaravelルートとInertiaレンダー

ステップ1で挙げたArticleFactoryファイルを使用して、データベースにいくつかの記事をシーディングします。

database/seeders/databaseSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Article;
use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        Article::factory(10)->create();
    }
}

以下のターミナルコマンドを実行して、テーブルをマイグレーションし、ファクトリからダミーデータをシーディングします。

php artisan migrate:fresh --seed

これで、データベースに10件のダミー記事が作成されました。Laravelのルーティングを使用して、これをビューに渡します。Inertiaでビューをレンダリングしているため、これまでのルートの書き方と少し変わります。「routes/web.php」に最初のLaravel Inertiaルートを作成し、「/resources/js/Pages/Index.vue」からトップページのビューを返します。

routes/web.php

<?php

use App\Models\Article;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/', function () {
    return Inertia::render('Index', [
        'articles' => Article::latest()->get()
    ]);
})->name('home');

Inertiaをインポートし、Laravelヘルパーのview()ではなく、Inertia::renderを使用してビューを返していることに注意してください。またInertiaは、ルートで言及したファイル名をデフォルトで、「resources/js」のPagesフォルダから探します。

Indexファイルでは取得したデータをpropとして設定し、v-forでループして結果を表示します。scriptタグの中で、渡されたデータをpropとして定義します。Inertiaには期待するデータの種類のみが必要で、ここでは記事の配列を含むarticlesオブジェクトを指定します。

resources/js/Pages/Index.vue

<script setup>
import KinstaLayout from "../Layouts/KinstaLayout.vue";

  defineProps({
    articles: Object,
  });
</script>

なお、今回はVue.js 3のComposition APIのsetup形式を使用しているため、propとして定義するだけで十分で、返す必要はありません。Options APIを使用している場合は、これを返してください。

以下、ループを作ります。

<template>
  <KinstaLayout>
    <h2>Read our latest articles</h2>

    <section>
      // Looping over articles
      <article v-for="article in articles":key="article.id">
        <div>
          <img src="/images/kinsta-logo.png" alt="Article thumbnail" />
        </div>

        <h3>{{article.title}}</h3>
        <p>{{article.excerpt}}</p>

        <a href="#">Read more</a>
      </article>
    </section>
  </KinstaLayout>
</template>

npm run dev(Viteを使用しているので起動したまま)と、php artisan serveでLaravelの開発サーバーを起動し、ウェブサイトにアクセスすると、データベース内の10件の記事がすべて表示されるページが確認できます。

Google ChromeのVue DevTools拡張機能を使用すると、アプリケーションをデバッグすることができます。データがどのようにコンポーネントに渡されるかを見てみましょう。

Inertiaプロパティの検証
Inertiaプロパティの検証

「articles」は、記事の配列を含むpropオブジェクトとしてコンポーネントに渡されます。配列内の各記事もオブジェクトで、データベースから取得したデータに対応するプロパティを持ちます。つまり、LaravelからInertiaに転送されるデータは、すべてpropとして扱われます。

Inertia.jsでのTailwind CSSの利用

Tailwindはすでに開始時点でプロジェクトにインストールされているため、Inertiaコンポーネントを読み込むように指示するだけでOKです。tailwind.config.jsを以下のように編集します。

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./storage/framework/views/*.php",
    "./resources/views/**/*.blade.php",
    "./resources/js/**/*.vue",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

「resources/js/app.js」内でCSSファイルをインポートしたことを確認します。

import "../css/app.css";

これでコンポーネントをスタイリングする準備ができました。

resources/js/Pages/Index.vue

<script setup>
import KinstaLayout from "../Layouts/KinstaLayout.vue";

  defineProps({
    articles: Object,
  });
</script>

<template>
 <KinstaLayout>
    <h2 class="text-2xl font-bold py-10">Read our latest articles</h2>

    <section class="space-y-5 border-b-2 pb-10">
      <article
        v-for="article in articles"
         :key="article.id"
        class="flex justify-center items-center shadow-md bg-white rounded-xl p-4 mx-auto max-w-3xl"
      >

         <img
            src="/images/kinsta-logo.png"
            class="w-32 h-32 rounded-xl object-cover"
            alt=""
         />

        <div class="flex flex-col text-left justify-between pl-3 space-y-5">
          <h3
            class="text-xl font-semibold text-indigo-600 hover:text-indigo-800"
          >
            <a href="#">{{ article.title }}</a>
          </h3>
          <p>
           {{ article.excerpt }}
          </p>
          <a
            href="#"
            class="text-indigo-600 hover:text-indigo-800 w-fit self-end font-semibold"
            >Read more</a
          >
        </div>
      </article>
    </section>
 </KinstaLayout>
</template>

resources/js/Layouts/KinstaLayout.vue

<script setup></script>

<template>
    <header
        class="bg-gradient-to-r from-blue-700 via-indigo-700 to-blue-700 w-full text-center py-4"
    >
        <h1 class="text-white font-bold text-4xl">Kinsta Blog</h1>
    </header>

    <main class="container mx-auto text-center">
        <slot />
    </main>

    <footer
        class="bg-gradient-to-b from-transparent to-gray-300 w-full text-center mt-5 py-10 mx-auto"
    >
        <h2 class="font-bold text-xl pb-5">Join our Newsletter</h2>

        <input
            class="rounded-xl w-80 h-12 px-3 py-2 shadow-md"
            type="email"
            placeholder="Write your email.."
        />
    </footer>
</template>

ブラウザを見ると、ViteがすでにTailwindでページを更新していることがわかります。

Inertiaプロパティのレンダリング
Inertiaプロパティのレンダリング

Inertiaリンク

データベース内のすべての記事を表示するトップページができたら、次に個々の記事を表示する別のルートを作成します。新たにルートを作成し、URLに「id」ワイルドカードを設定します。

routes/web.php

<?php

use App\Models\Article;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('/', function () {
    return Inertia::render('Index', [
        'articles' => Article::latest()->get()
    ]);
})->name('home');

Route::get('/posts/{article:id}', function (Article $article) {
    return Inertia::render('Show', [
        'article' => $article
    ]);
})->name('article.show');

「Article」モデルをインポートし、InertiaコンポーネントShow.vueを返す新しいルートを追加しています。また、Laravelのルートモデルバインディングにより、Laravelが自動的に参照記事を取得しています。

あとはトップページのリンクをクリックした際、ページ全体を再読み込みすることなく、このルートにアクセスする方法が必要です。これは、Inertiaの優れた機能<Link>で実現できます。冒頭で説明したように、Inertiaは、標準的なアンカータグ<a>のラッパーとして<Link>を使用します。このラッパーは、シームレスなページ移動が目的です。<Link>タグは、<GET>リクエストを実行するアンカータグとして振る舞いますが、同時に<button><form>としても動作します。この例でどのように適用できるかを見ていきましょう。

Index.vueの中で、Inertiaから<Link>をインポートし、アンカータグ<a><Link>タグで置き換えます。href属性には、記事の閲覧用に作成したルートURLを設定します。

<script setup>
import KinstaLayout from "../Layouts/KinstaLayout.vue";
import { Link } from "@inertiajs/inertia-vue3";

defineProps({
    articles: Object,
});
</script>

<template>
    <KinstaLayout>
        <section class="space-y-5 border-b-2 pb-10">
            <h2 class="text-2xl font-bold pt-10 mx-auto text-center">
                Read our latest articles
            </h2>

            <article
                v-for="article in articles"
                :key="article.id"
                class="flex justify-center items-center shadow-md bg-white rounded-xl p-4 mx-auto max-w-3xl"
            >
                <img
                    src="/images/kinsta-logo.png"
                    class="w-32 h-32 rounded-xl object-cover"
                    alt=""
                />

                <div
                    class="flex flex-col text-left justify-between pl-3 space-y-5"
                >
                    <h3
                        class="text-xl font-semibold text-indigo-600 hover:text-indigo-800"
                    >
                        <Link :href="'/posts/' + article.id">{{
                            article.title
                        }}</Link>
                    </h3>
                    <p>
                        {{ article.excerpt }}
                    </p>
                    <Link
                        :href="'/posts/' + article.id"
                        class="text-indigo-600 hover:text-indigo-800 w-fit self-end font-semibold"
                        >Read more
                    </Link>
                </div>
            </article>
        </section>
    </KinstaLayout>
</template>

TailwindでShow.vueのスタイルを少し調整しましょう。また、「Article」オブジェクトを期待していることを通知し、propに設定します。

<script setup>
import KinstaLayout from "../Layouts/KinstaLayout.vue";

defineProps({
    article: Object,
});
</script>

<template>
    <KinstaLayout>
        <article class="mx-auto mt-10 flex justify-center max-w-5xl border-b-2">
            <img
                src="/images/kinsta-logo.png"
                class="w-80 h-80 rounded-xl mx-auto py-5"
                alt=""
            />
            <div class="text-left flex flex-col pt-5 pb-10 px-10">
                <h1 class="text-xl font-semibold mb-10">{{ article.title }}</h1>
                <p>{{ article.body }}</p>
            </div>
        </article>
    </KinstaLayout>
</template>

これで、記事のタイトルや「Read more」をクリックすると、ページを更新することなくShow.vueに移動できます。

動作するInertiaリンク
動作するInertiaリンク

この例では、<Link>をアンカータグとして使用し、ルートにGETリクエストを送信してデータを取得しています。<Link>POSTPUTPATCHDELETEにも使用可能です。

routes/web.php

<Link href="/logout" method="post" as="button" type="button">Logout</Link>

LaravelでInertiaを使用するヒント

Laravel、Inertia、Tailwind CSSを使ったSPAの構築例をご紹介しましたが、Inertiaの実力はこれだけではありません。ここからは、開発者とアプリケーション利用者の両方に有用なInertiaテクニックを解説します。

URLの生成

Laravelのルートに名前を追加しましたが、使用していないことにお気づきかもしれません。Inertiaを使用すれば、手作業で完全なルートを組み立てる代わりに、コンポーネント内で名前付きルートを使用することができます。

これには、プロジェクトにZiggyパッケージをインストールします。

composer require tightenco/ziggy

次に「resources/js/app.js」を更新します。

import "./bootstrap";
import "../css/app.css";

import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/inertia-vue3";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { ZiggyVue } from "../../vendor/tightenco/ziggy/dist/vue.m";

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) =>
        resolvePageComponent(
            `./Pages/${name}.vue`,
            import.meta.glob("./Pages/**/*.vue")
        ),
    setup({ el, app, props, plugin }) {
        return createApp({ render: () => h(app, props) })
            .use(plugin)
            .use(ZiggyVue, Ziggy)
            .mount(el);
    },
});

「/resources/views/app.blade.php」のheadを@routesディレクティブで更新します。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">

<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- Fetch project name dynamically -->
    <title inertia>{{ config('app.name', 'Laravel') }}</title>

    <!-- Scripts -->
    @routes
    @vite('resources/js/app.js')
    @inertiaHead
</head>

<body class="font-sans antialiased">
    @inertia
</body>

</html>

以下2つのターミナルコマンドを実行して、NPMパッケージをリフレッシュします。

npm install && npm run dev

このパッケージを使用すると、Inertiaコンポーネント内で名前付きルートを使用できます。Index.vueを開いて古いルートを削除し、ルート名に置き換え、コントローラ内と同じように通常の方法でデータを渡します。

この部分を

<Link :href="'/posts/' + article.id">
   {{ article.title }}
</Link>

以下で置き換えます。

<Link :href="route('article.show', article.id)">
   {{ article.title }}
</Link>

これで、変更後もこれまでと同じように動作しますが、開発作業を行いやすくなります。特に多くのパラメータを必要とするルートの場合に便利です。

プログレスインジケータ

プログレスインジケータは、Inertia.jsの素晴らしい機能の1つです。SPAのユーザー体験はインタラクティブであることから、常にリクエストが読み込み中かどうかをフィードバックすることで、アプリケーションに優れた付加価値を与えることができます。この機能は、Inertiaの別のライブラリで使用可能です。

「@inertiajs/progress」ライブラリは、NProgressのラッパーで、Inertiaのイベントに応じて条件付きでロードインジケータを表示します。このライブラリの裏側は置いておいて、早速動作を見てみましょう。

ライブラリは、以下のターミナルコマンドでインストールできます。

npm install @inertiajs/progress

インストール後、resources/js/app.jsでインポートします。

import "./bootstrap";
import "../css/app.css";

import { createApp, h } from "vue";
import { createInertiaApp } from "@inertiajs/inertia-vue3";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { ZiggyVue } from "../../vendor/tightenco/ziggy/dist/vue.m";
import { InertiaProgress } from "@inertiajs/progress";

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    resolve: (name) =>
        resolvePageComponent(
            `./Pages/${name}.vue`,
            import.meta.glob("./Pages/**/*.vue")
        ),
    setup({ el, app, props, plugin }) {
        return createApp({ render: () => h(app, props) })
            .use(plugin)
            .use(ZiggyVue, Ziggy)
            .mount(el);
    },
});

InertiaProgress.init({ color: "#000000", showSpinner: true });

すると、黒色のローディングバーとローディングスピナーが表示されます。色の変更等のその他の設定については、プログレスインジケータに関する公式ドキュメントを参照してください。

Inertiaプログレスインジケータ(右上)
Inertiaプログレスインジケータ(右上)

スクロール管理

別のページに移動する際、スクロール位置を保持したいことも。例えばコメントを受け付ける際、フォームが送信されると、コンポーネントにデータベースから新規コメントを読み込みますが、カーソル位置は変更したくありません。Inertiaはこうした処理を実現できます。

以下は、Index.vue<Link>タグに適用した例です。<Link>を使用して、別のページにリダイレクトする際にスクロール位置を保持するには、<Link>preserve-scroll属性を追加します。

<Link :href="route('article.show', article.id)" preserve-scroll>
  {{ article.title }}
</Link>

SEOのヒント

SPAが普及して以来、検索エンジン最適化(SEO)は常に課題として挙げられてきました。一般に知られているように、SPAではすべてがクライアントサイドでレンダリングされるため、検索エンジンによるウェブアプリケーションのクローリングが困難になり、結果として検索結果の上位に表示されにくくなるためです。しかし、FacebookやGithubなどの人気サービスは、SPAを採用しながら、効果的にSEOに力を入れています。

この事実は、SPAでもSEOの施策が打てることを意味します。Inertiaには、SPAのSEOを向上するツールがいくつか組み込まれています。

LaravelとViteによるInertia Vueサーバーサイドレンダリング

検索エンジンは、コンテンツ認識のため常にサイトのHTMLを探しています。しかしURLにHTMLがなければ、認識ができません。SPAの開発でページ上にあるものは、JavaScriptとJSONだけです。Inertiaには、アプリケーションに追加可能なサーバーサイドレンダリング(SSR)機能が導入されています。これにより、アプリケーションはサーバー上で最初の訪問ページを事前にレンダリングしておき、ブラウザにはレンダリングしたHTMLを送信することができます。ユーザーは、ページが完全に読み込まれる前に閲覧・操作でき、検索エンジンは、サイトをインデックスする時間を短縮することができます。

要約すると、Inertiaは、Node.jsサーバ上で動作しているかどうかを判別し、コンポーネント名、プロパティ、URL、アセットバージョンをHTMLにレンダリングします。そして、ユーザーと検索エンジンに対し、ページに含まれる実質的なすべてのコンテンツを配信します。

ただし、この記事ではLaravelを扱っています。LaravelはPHPフレームワークであり、Node.jsサーバー上では動作しないため、これだけではほとんど意味がありません。リクエストをNode.jsのサービスに転送し、サービスがページをレンダリングしてHTMLを返せば、Laravel VueアプリケーションのSEOをデフォルトで強化することができます。

まず、Vue.js SSR npmパッケージをインストールします。

npm install @vue/server-renderer

別の有用なInertia「NPM」パッケージが、シンプルな「HTTP」サーバーを提供します(インストール推奨)。

npm install @inertiajs/server

次に、「resources/js/」に新しいファイルssr.jsを追加します。このファイルは、Inertiaのインストール時に作成したapp.jsと非常に似ていますが、ブラウザではなくNode.jsで実行されます。

import { createSSRApp, h } from "vue";
import { renderToString } from "@vue/server-renderer";
import { createInertiaApp } from "@inertiajs/inertia-vue3";
import createServer from "@inertiajs/server";
import { resolvePageComponent } from "laravel-vite-plugin/inertia-helpers";
import { ZiggyVue } from "../../vendor/tightenco/ziggy/dist/vue.m";

const appName = "Laravel";

createServer((page) =>
    createInertiaApp({
        page,
        render: renderToString,
        title: (title) => `${title} - ${appName}`,
        resolve: (name) =>
            resolvePageComponent(
                `./Pages/${name}.vue`,
                import.meta.glob("./Pages/**/*.vue")
            ),
        setup({ app, props, plugin }) {
            return createSSRApp({ render: () => h(app, props) })
                .use(plugin)
                .use(ZiggyVue, {
                    ...page.props.ziggy,
                    location: new URL(page.props.ziggy.location),
                });
        },
    })
);

なお、ssr.jsファイルにすべてを詰め込まないようご注意ください。このファイルは訪問者には表示されず、検索エンジンやブラウザに対してページ内のデータを提示するために存在しています。したがって、データとして重要なもの、またはデータを利用可能にするものだけを含めてください。

「InertiaのSSRサーバーは、デフォルトではポート13714で動作します。ただし、createServerメソッドに第2引数を指定して変更できます」(Inertia公式ドキュメントの日本語訳)

Inertia.jsのドキュメントには、Inertia SSRをViteに統合する方法が記載されていないため、以下ご説明します。vite.config.jsを開き、以下を貼り付けてください。

import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";
import vue from "@vitejs/plugin-vue";

export default defineConfig({
    plugins: [
        laravel({
            input: "resources/js/app.js",
            ssr: "resources/js/ssr.js",
        }),
        vue({
            template: {
                transformAssetUrls: {
                    base: null,
                    includeAbsolute: false,
                },
            },
        }),
    ],
});

次にpackage.jsonを開き、buildスクリプトを変更します。

"build": "vite build && vite build --ssr"

ここでnpm run buildを実行すると、Viteが本番用のSSRバンドルを構築します。詳しくは『Inertia SSRドキュメント』と『Vite SSRドキュメント』を参照してください。

タイトルとメタ

JavaScriptアプリケーションは、ドキュメントの<body>内でレンダリングされます。ドキュメントの<head>は範囲外のため、マークアップをレンダリングできません。Inertiaの<Head>コンポーネントはページの<title><meta>タグ、その他の<head>コンポーネントの設定に使用できます。

ページに<head>要素を追加するには、<Link>コンポーネントと同様にInertiaから<Head>をインポートします。

import { Head } from '@inertiajs/inertia-vue3'

<Head>
  <title>Kinsta Blog</title>
  <meta name="description" content="Kinsta blog for developers">
</Head>

また、すべてのページにグローバルタイトルを設定可能です。すでにapp.jsファイルに実装されているように、ページのタイトルの横にアプリケーション名が表示されます。

createInertiaApp({
    title: (title) => `${title} - ${appName}`,
    //
});

アプリケーションのトップページにタイトル付きで、<Head title="Home">とすると、<title>Home - My App</title>のようにレンダリングされます。

アプリケーションの監視

速度は、ウェブサイトのSEOを向上する上で最も重要な要素の1つです。WordPressサイトを運用されている場合は、Kinsta APMでアプリケーションの動きを常に監視し、パフォーマンスの問題を特定することができます。Kinsta APMは、Kinstaのすべてのホスティングプランに無料で付帯しています。

まとめ

Inertia.jsは現在、最も重要な技術の1つ。Laravelと組み合わせることで、PHPとJavaScriptで構築された最新のシングルページアプリケーションを作成することができます。Laravelの開発者であるTaylor Otwell氏は、Inertiaの可能性を見出し、InertiaとSSRをサポートした人気のスターターキット、Laravel BreezeとJetstreamを発表しています。

Laravelファンやプロの開発者にとって、Inertia.jsは間違いなく注目すべき技術です。今回は、シンプルなブログを数分で作成する例をご紹介しましたが、Inertiaについて学ぶことは、まだまだあります。今後もKinstaブログにてInertiaに関する解説記事を公開していく予定です。

Laravelについて、気になること、わからないことはありませんか?以下のコメント欄でお聞かせください。

Mostafa Said

Laravel、Inertia、JavaScriptフレームワークを得意とするフルスタック開発者。コードを書く以外の時間は、解説記事を執筆して知識を共有したり、ハッカソンに参加したり(何度か優勝経験あり)、技術の素晴らしさを伝えるべく他の人の学習をサポートしたりしている。