Laravel × Next.jsでSSRアプリ開発

はじめに

現代のWebアプリケーション開発では、バックエンドの堅牢性とフロントエンドの優れたユーザー体験の両方が求められています。Laravel と Next.js の組み合わせは、こうした要求に応える理想的な選択肢の一つです。LaravelがPHPベースの堅牢なバックエンドフレームワークとして機能する一方、Next.jsはReactベースのフレームワークでサーバーサイドレンダリング(SSR)をはじめとする多くの機能を提供します。

この記事では、Laravel と Next.js を連携させてSSRアプリケーションを開発する方法について解説します。両フレームワークの強みを活かした構成から、実際の開発フローまで、実践的な内容をカバーしていきます。

なぜLaravel × Next.jsなのか?

Laravelの強み

  • 堅牢なMVCアーキテクチャ
  • 強力なORM(Eloquent)によるデータベース操作
  • ミドルウェア、認証、キャッシュなどの充実した機能
  • 豊富なエコシステムとパッケージ

Next.jsの強み

  • サーバーサイドレンダリング(SSR)
  • 静的サイト生成(SSG)
  • ファイルシステムベースのルーティング
  • APIルートによるサーバーレス関数
  • React Suspenseなどの最新機能のサポート

これらを組み合わせることで、Laravelの堅牢なバックエンドAPIと、Next.jsのSEOフレンドリーで高パフォーマンスなフロントエンドを持つアプリケーションを構築できます。

アーキテクチャ設計

Laravel と Next.js を組み合わせた典型的なアーキテクチャは次のようになります:

                   API リクエスト
                        ↓
+-----------------+    ↓     +-------------------+
|                 |    ↓     |                   |
|    Next.js      |←----------→     Laravel      |
|   (フロントエンド)  |    ↓     |    (バックエンド)   |
|                 |    ↓     |                   |
+-----------------+    ↓     +-------------------+
        ↑              ↓             ↓
        ↑              ↓             ↓
+---------------------------------------+
|                                       |
|              データベース               |
|                                       |
+---------------------------------------+

このアーキテクチャでは:

  1. Laravel: RESTful APIとしてデータを提供し、ビジネスロジックとデータベース操作を担当
  2. Next.js: フロントエンドのレンダリングとユーザーインタラクションを処理
  3. 通信: APIを介して両者が連携

環境構築

1. Laravelプロジェクトのセットアップ

まず、APIとして機能するLaravelプロジェクトを作成します:

composer create-project laravel/laravel laravel-nextjs-api
cd laravel-nextjs-api

CORSを有効にするため、以下のパッケージをインストールします:

composer require fruitcake/laravel-cors

config/cors.phpを編集して、Next.jsのオリジンからのリクエストを許可します:

<?php

return [
    'paths' => ['api/*'],
    'allowed_methods' => ['*'],
    'allowed_origins' => ['http://localhost:3000'], // Next.jsの開発サーバーのアドレス
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['*'],
    'exposed_headers' => [],
    'max_age' => 0,
    'supports_credentials' => true,
];

2. Next.jsプロジェクトのセットアップ

Next.jsプロジェクトを作成します:

npx create-next-app@latest nextjs-laravel-frontend
cd nextjs-laravel-frontend

APIリクエスト用のAxiosをインストールします:

npm install axios

.env.localファイルを作成して、LaravelのAPIエンドポイントを設定します:

NEXT_PUBLIC_API_URL=http://localhost:8000/api

APIとの連携

1. Laravelでの基本的なAPIの作成

例として、ArticleモデルとそのAPIを作成します:

php artisan make:model Article -m
php artisan make:controller Api/ArticleController --api

マイグレーションファイルを編集します:

// database/migrations/xxxx_xx_xx_create_articles_table.php
public function up()
{
    Schema::create('articles', function (Blueprint $table) {
        $table->id();
        $table->string('title');
        $table->text('content');
        $table->string('image')->nullable();
        $table->boolean('published')->default(false);
        $table->timestamps();
    });
}

モデルを編集します:

// app/Models/Article.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Article extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'content',
        'image',
        'published'
    ];
}

コントローラーを実装します:

// app/Http/Controllers/Api/ArticleController.php
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\Article;
use Illuminate\Http\Request;

class ArticleController extends Controller
{
    public function index()
    {
        $articles = Article::where('published', true)
            ->orderBy('created_at', 'desc')
            ->get();
            
        return response()->json($articles);
    }

    public function show($id)
    {
        $article = Article::findOrFail($id);
        
        return response()->json($article);
    }
    
    // その他のCRUD操作...
}

APIルートを設定します:

// routes/api.php
use App\Http\Controllers\Api\ArticleController;

Route::get('/articles', [ArticleController::class, 'index']);
Route::get('/articles/{id}', [ArticleController::class, 'show']);
// その他のルート...

2. Next.jsでのAPI連携

APIクライアントの設定ファイルを作成します:

// lib/api.js
import axios from 'axios';

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  headers: {
    'Content-Type': 'application/json',
  },
  withCredentials: true,
});

export default api;

記事一覧を取得する関数を作成します:

// lib/articles.js
import api from './api';

export async function getArticles() {
  try {
    const response = await api.get('/articles');
    return response.data;
  } catch (error) {
    console.error('記事の取得に失敗しました:', error);
    return [];
  }
}

export async function getArticle(id) {
  try {
    const response = await api.get(`/articles/${id}`);
    return response.data;
  } catch (error) {
    console.error(`ID: ${id} の記事の取得に失敗しました:`, error);
    return null;
  }
}

Next.jsでのSSR実装

1. 記事一覧ページ

// pages/articles/index.js
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { getArticles } from '../../lib/articles';

export default function ArticleList({ initialArticles }) {
  const [articles, setArticles] = useState(initialArticles);

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-6">記事一覧</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {articles.map((article) => (
          <div key={article.id} className="border rounded-lg overflow-hidden shadow-lg">
            {article.image && (
              <img 
                src={article.image} 
                alt={article.title} 
                className="w-full h-48 object-cover"
              />
            )}
            <div className="p-4">
              <h2 className="text-xl font-semibold mb-2">{article.title}</h2>
              <p className="text-gray-600 mb-4">
                {article.content.substring(0, 150)}...
              </p>
              <Link href={`/articles/${article.id}`}>
                <a className="text-blue-500 hover:underline">続きを読む</a>
              </Link>
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

// サーバーサイドでデータを取得
export async function getServerSideProps() {
  const articles = await getArticles();
  
  return {
    props: {
      initialArticles: articles,
    },
  };
}

2. 記事詳細ページ

// pages/articles/[id].js
import { useRouter } from 'next/router';
import Link from 'next/link';
import { getArticle } from '../../lib/articles';

export default function ArticleDetail({ article }) {
  const router = useRouter();

  // 記事が見つからない場合の処理
  if (router.isFallback) {
    return <div className="container mx-auto py-8">読み込み中...</div>;
  }

  if (!article) {
    return <div className="container mx-auto py-8">記事が見つかりませんでした。</div>;
  }

  return (
    <div className="container mx-auto py-8">
      <Link href="/articles">
        <a className="text-blue-500 hover:underline mb-4 block">← 記事一覧に戻る</a>
      </Link>
      
      {article.image && (
        <img 
          src={article.image} 
          alt={article.title} 
          className="w-full max-h-96 object-cover mb-6 rounded-lg"
        />
      )}
      
      <h1 className="text-4xl font-bold mb-4">{article.title}</h1>
      <div className="text-gray-500 mb-6">
        {new Date(article.created_at).toLocaleDateString('ja-JP')}
      </div>
      
      <div className="prose max-w-none">
        {article.content.split('\n').map((paragraph, index) => (
          <p key={index} className="mb-4">{paragraph}</p>
        ))}
      </div>
    </div>
  );
}

// サーバーサイドでデータを取得
export async function getServerSideProps({ params }) {
  const article = await getArticle(params.id);
  
  if (!article) {
    return {
      notFound: true, // 404ページを表示
    };
  }
  
  return {
    props: {
      article,
    },
  };
}

認証の実装

Laravel SanctumやFortifyを使ったAPIベースの認証を実装できます。

1. Laravel側での設定

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

Kernel.phpにミドルウェアグループを追加:

// app/Http/Kernel.php
'api' => [
    \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
    'throttle:api',
    \Illuminate\Routing\Middleware\SubstituteBindings::class,
],

認証用のAPIルートを追加:

// routes/api.php
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

Route::post('/login', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
    ]);

    $user = User::where('email', $request->email)->first();

    if (! $user || ! Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['提供された認証情報は正しくありません。'],
        ]);
    }

    return response()->json([
        'user' => $user,
        'token' => $user->createToken('auth-token')->plainTextToken,
    ]);
});

Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Route::post('/logout', function (Request $request) {
    if ($request->user()) {
        $request->user()->tokens()->delete();
    }
    
    return response()->json(['message' => 'ログアウトしました']);
})->middleware('auth:sanctum');

2. Next.js側での認証実装

認証関連のAPIクライアント関数を追加:

// lib/auth.js
import api from './api';
import Router from 'next/router';

export async function login(email, password) {
  try {
    const response = await api.post('/login', {
      email,
      password,
    });
    
    // トークンをlocalStorageに保存
    localStorage.setItem('auth-token', response.data.token);
    
    // 認証ヘッダーを設定
    api.defaults.headers.common['Authorization'] = `Bearer ${response.data.token}`;
    
    return response.data.user;
  } catch (error) {
    console.error('ログインに失敗しました:', error);
    throw error;
  }
}

export async function logout() {
  try {
    await api.post('/logout');
    localStorage.removeItem('auth-token');
    delete api.defaults.headers.common['Authorization'];
    Router.push('/login');
  } catch (error) {
    console.error('ログアウトに失敗しました:', error);
  }
}

export async function getUser() {
  try {
    const token = localStorage.getItem('auth-token');
    
    if (!token) {
      return null;
    }
    
    api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
    const response = await api.get('/user');
    return response.data;
  } catch (error) {
    console.error('ユーザー情報の取得に失敗しました:', error);
    localStorage.removeItem('auth-token');
    delete api.defaults.headers.common['Authorization'];
    return null;
  }
}

認証コンテキストの作成:

// contexts/AuthContext.js
import { createContext, useState, useContext, useEffect } from 'react';
import { getUser, login as authLogin, logout as authLogout } from '../lib/auth';

const AuthContext = createContext();

export function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // マウント時に認証状態を確認
    async function loadUserFromToken() {
      try {
        const userData = await getUser();
        setUser(userData);
      } catch (error) {
        console.error('認証の復元に失敗しました:', error);
      } finally {
        setLoading(false);
      }
    }
    
    loadUserFromToken();
  }, []);

  const login = async (email, password) => {
    const userData = await authLogin(email, password);
    setUser(userData);
    return userData;
  };

  const logout = async () => {
    await authLogout();
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ isAuthenticated: !!user, user, login, logout, loading }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  return useContext(AuthContext);
}

アプリケーションにAuthProviderを追加:

// pages/_app.js
import { AuthProvider } from '../contexts/AuthContext';
import '../styles/globals.css';

function MyApp({ Component, pageProps }) {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  );
}

export default MyApp;

ログインページの実装:

// pages/login.js
import { useState } from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../contexts/AuthContext';

export default function Login() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const { login } = useAuth();
  const router = useRouter();

  const handleSubmit = async (e) => {
    e.preventDefault();
    setError('');
    
    try {
      await login(email, password);
      router.push('/dashboard');
    } catch (err) {
      setError('ログインに失敗しました。メールアドレスまたはパスワードが正しくありません。');
    }
  };

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full p-6 bg-white rounded-lg shadow-md">
        <h1 className="text-2xl font-bold text-center mb-6">ログイン</h1>
        
        {error && (
          <div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
            {error}
          </div>
        )}
        
        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
              メールアドレス
            </label>
            <input
              id="email"
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
              required
            />
          </div>
          
          <div className="mb-6">
            <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
              パスワード
            </label>
            <input
              id="password"
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
              required
            />
          </div>
          
          <div>
            <button
              type="submit"
              className="w-full bg-blue-500 text-white font-bold py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 hover:bg-blue-600"
            >
              ログイン
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}

パフォーマンス最適化

1. Laravel側でのAPIレスポンス最適化

  • キャッシュの導入
  • リソースクラスによるレスポンス整形
  • N+1問題の回避(Eager Loading)
// app/Http/Resources/ArticleResource.php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class ArticleResource extends JsonResource
{
    public function toArray($request)
    {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'content' => $this->content,
            'image' => $this->image,
            'published' => $this->published,
            'created_at' => $this->created_at,
            'updated_at' => $this->updated_at,
        ];
    }
}

コントローラーの修正:

// ArticleController.php
public function index()
{
    $articles = Article::where('published', true)
        ->orderBy('created_at', 'desc')
        ->get();
        
    return ArticleResource::collection($articles);
}

public function show($id)
{
    $article = Article::findOrFail($id);
    
    return new ArticleResource($article);
}

2. Next.jsでのパフォーマンス最適化

  • 画像最適化(Next/Image)
  • 動的インポート
  • ルートプリフェッチング

画像の最適化例:

import Image from 'next/image';

// 画像コンポーネントを修正
{article.image && (
  <div className="relative w-full h-48">
    <Image 
      src={article.image}
      alt={article.title}
      layout="fill"
      objectFit="cover"
    />
  </div>
)}

デプロイ

Laravel APIとNext.jsフロントエンドのデプロイ戦略には複数のオプションがあります:

1. 別々のサーバーにデプロイ

  • Laravel API: VPS、共有ホスティング、Laravel Forge、Laravel Vaporなど
  • Next.js: Vercel、Netlify、AWS Amplify、VPSなど

2. 同一サーバーにデプロイ

  • VPSまたは専用サーバーで両方のアプリケーションをホスティング
  • リバースプロキシを使用して適切なルーティングを設定

3. dockerを使用したデプロイ

Docker Composeを使用して、両方のアプリケーションを一緒にコンテナ化できます:

# docker-compose.yml
version: '3'
services:
  laravel-api:
    build:
      context: ./laravel-nextjs-api
    ports:
      - "8000:80"
    # 他の設定...

  nextjs-frontend:
    build:
      context: ./nextjs-laravel-frontend
    ports:
      - "3000:3000"
    environment:
      - NEXT_PUBLIC_API_URL=http://laravel-api/api
    # 他の設定...

まとめ

Laravel と Next.js の組み合わせは、以下のようなメリットがあります:

  1. Laravel – 堅牢なバックエンドAPIとビジネスロジックの実装
  2. Next.js – SEOフレンドリーなSSRと優れたユーザー体験
  3. スケーラビリティ – フロントエンドとバックエンドの分離による独立したスケーリング
  4. 開発効率 – 各フレームワークの強みを活かした効率的な開発

この組み合わせは特に以下のようなプロジェクトに適しています:

  • SEOが重要なコンテンツ重視のウェブサイト
  • 複雑なデータ操作が必要なアプリケーション
  • スケーラビリティが重要なサービス
  • 既存のLaravelプロジェクトのフロントエンドを近代化したい場合

Laravel × Next.js のアーキテクチャは学習コストがかかる場合もありますが、両者の強みを組み合わせることで、パフォーマンスが高く、拡張性のあるWebアプリケーションを構築することができます。


参考文献:

  • Laravel 公式ドキュメント: https://laravel.com/docs
  • Next.js 公式ドキュメント: https://nextjs.org/docs
  • Laravel Sanctum: https://laravel.com/docs/sanctum

コメント

タイトルとURLをコピーしました