はじめに
現代の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 |
| (フロントエンド) | ↓ | (バックエンド) |
| | ↓ | |
+-----------------+ ↓ +-------------------+
↑ ↓ ↓
↑ ↓ ↓
+---------------------------------------+
| |
| データベース |
| |
+---------------------------------------+
このアーキテクチャでは:
- Laravel: RESTful APIとしてデータを提供し、ビジネスロジックとデータベース操作を担当
- Next.js: フロントエンドのレンダリングとユーザーインタラクションを処理
- 通信: 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 の組み合わせは、以下のようなメリットがあります:
- Laravel – 堅牢なバックエンドAPIとビジネスロジックの実装
- Next.js – SEOフレンドリーなSSRと優れたユーザー体験
- スケーラビリティ – フロントエンドとバックエンドの分離による独立したスケーリング
- 開発効率 – 各フレームワークの強みを活かした効率的な開発
この組み合わせは特に以下のようなプロジェクトに適しています:
- SEOが重要なコンテンツ重視のウェブサイト
- 複雑なデータ操作が必要なアプリケーション
- スケーラビリティが重要なサービス
- 既存のLaravelプロジェクトのフロントエンドを近代化したい場合
Laravel × Next.js のアーキテクチャは学習コストがかかる場合もありますが、両者の強みを組み合わせることで、パフォーマンスが高く、拡張性のあるWebアプリケーションを構築することができます。
参考文献:
- Laravel 公式ドキュメント: https://laravel.com/docs
- Next.js 公式ドキュメント: https://nextjs.org/docs
- Laravel Sanctum: https://laravel.com/docs/sanctum
コメント