Next.js App Router 完全ガイド:パフォーマンスとSEOを最適化する実装方法
Next.js App Router 完全ガイド:パフォーマンスとSEOを最適化する実装方法
はじめに
Next.js 13 で導入された App Router は、React Server Components(RSC)を基盤とした新しいルーティングシステムです。本記事では、App Router を使用した実践的な開発手法と、パフォーマンス・SEO を最大化するベストプラクティスを詳しく解説します。
App Router の基本概念
ディレクトリ構造
app/
├── layout.tsx # ルートレイアウト
├── page.tsx # ホームページ
├── loading.tsx # ローディングUI
├── error.tsx # エラーハンドリング
├── not-found.tsx # 404ページ
├── global.css # グローバルスタイル
├── blog/
│ ├── layout.tsx # ブログレイアウト
│ ├── page.tsx # ブログ一覧
│ └── [slug]/
│ ├── page.tsx # ブログ詳細
│ └── loading.tsx
└── api/
└── posts/
└── route.ts # APIルート
ファイルの役割
ファイル名 | 役割 | 説明 |
---|---|---|
layout.tsx |
レイアウト | 子ページで共有されるUI |
page.tsx |
ページ | ルートのUIを定義 |
loading.tsx |
ローディング | 自動的なローディング状態 |
error.tsx |
エラー境界 | エラーハンドリング |
template.tsx |
テンプレート | レイアウトに似ているが、新しいインスタンスを作成 |
Server Components と Client Components
Server Components の実装
// app/posts/page.tsx
import { Metadata } from 'next'
import { getPosts } from '@/lib/api'
import PostList from './PostList'
export const metadata: Metadata = {
title: 'ブログ記事一覧',
description: 'Next.js、React、TypeScriptに関する技術記事',
openGraph: {
title: 'ブログ記事一覧',
description: 'Next.js、React、TypeScriptに関する技術記事',
type: 'website',
},
}
// Server Component - データフェッチングを直接実行
export default async function PostsPage() {
// サーバーサイドでデータを取得
const posts = await getPosts()
// ビルド時にHTMLが生成される
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">最新の記事</h1>
{/* Client Component にデータを渡す */}
<PostList posts={posts} />
</div>
)
}
// lib/api.ts
import { cache } from 'react'
// React の cache 関数でリクエストをメモ化
export const getPosts = cache(async () => {
const res = await fetch('https://api.example.com/posts', {
next: {
revalidate: 3600, // 1時間ごとに再検証
},
})
if (!res.ok) {
throw new Error('Failed to fetch posts')
}
return res.json()
})
Client Components の実装
// app/posts/PostList.tsx
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import type { Post } from '@/types'
interface PostListProps {
posts: Post[]
}
export default function PostList({ posts }: PostListProps) {
const [searchTerm, setSearchTerm] = useState('')
const [isPending, startTransition] = useTransition()
const router = useRouter()
const filteredPosts = posts.filter(post =>
post.title.toLowerCase().includes(searchTerm.toLowerCase())
)
const handlePostClick = (slug: string) => {
startTransition(() => {
router.push(`/blog/${slug}`)
})
}
return (
<div>
<input
type="search"
placeholder="記事を検索..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full p-3 mb-6 border rounded-lg"
/>
<div className={`grid gap-6 ${isPending ? 'opacity-50' : ''}`}>
{filteredPosts.map((post) => (
<article
key={post.id}
onClick={() => handlePostClick(post.slug)}
className="p-6 bg-white rounded-lg shadow-md cursor-pointer hover:shadow-lg transition-shadow"
>
<h2 className="text-2xl font-semibold mb-2">{post.title}</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<time className="text-sm text-gray-500">
{new Date(post.publishedAt).toLocaleDateString('ja-JP')}
</time>
</article>
))}
</div>
</div>
)
}
高度なルーティングパターン
並列ルーティング(Parallel Routes)
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
notifications,
}: {
children: React.ReactNode
analytics: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="grid grid-cols-12 gap-6">
<div className="col-span-8">{children}</div>
<div className="col-span-4 space-y-6">
<div className="bg-white p-4 rounded-lg shadow">
{analytics}
</div>
<div className="bg-white p-4 rounded-lg shadow">
{notifications}
</div>
</div>
</div>
)
}
// app/dashboard/@analytics/page.tsx
export default async function Analytics() {
const data = await fetchAnalytics()
return (
<div>
<h2 className="text-xl font-bold mb-4">アナリティクス</h2>
<div className="space-y-2">
<p>訪問者数: {data.visitors}</p>
<p>ページビュー: {data.pageViews}</p>
<p>直帰率: {data.bounceRate}%</p>
</div>
</div>
)
}
// app/dashboard/@notifications/page.tsx
export default async function Notifications() {
const notifications = await fetchNotifications()
return (
<div>
<h2 className="text-xl font-bold mb-4">通知</h2>
<ul className="space-y-2">
{notifications.map((notification) => (
<li key={notification.id} className="p-2 bg-gray-50 rounded">
{notification.message}
</li>
))}
</ul>
</div>
)
}
インターセプトルーティング(Intercepting Routes)
// app/photos/[id]/page.tsx
export default function PhotoPage({ params }: { params: { id: string } }) {
return (
<div className="container mx-auto p-8">
<h1>写真詳細ページ</h1>
<PhotoDetail id={params.id} />
</div>
)
}
// app/photos/@modal/(..)photos/[id]/page.tsx
// (..) は一つ上の階層を示す
export default function PhotoModal({ params }: { params: { id: string } }) {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white p-8 rounded-lg max-w-4xl max-h-[90vh] overflow-auto">
<PhotoDetail id={params.id} />
</div>
</div>
)
}
// app/photos/layout.tsx
export default function PhotosLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
データフェッチングの最適化
ストリーミングとサスペンス
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart, LatestInvoices, CardsSkeleton } from '@/components/dashboard'
export default function DashboardPage() {
return (
<main>
<h1 className="mb-4 text-xl md:text-2xl">ダッシュボード</h1>
{/* 各コンポーネントを個別にストリーミング */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<Suspense fallback={<CardsSkeleton />}>
<CardWrapper />
</Suspense>
</div>
<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
<Suspense fallback={<RevenueChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<LatestInvoicesSkeleton />}>
<LatestInvoices />
</Suspense>
</div>
</main>
)
}
// components/dashboard/RevenueChart.tsx
async function RevenueChart() {
const revenue = await fetchRevenue() // 3秒かかる処理
return (
<div className="w-full md:col-span-4">
<h2 className="mb-4 text-xl md:text-2xl">月次収益</h2>
{/* グラフの実装 */}
</div>
)
}
// データの並列フェッチング
async function CardWrapper() {
// Promise.all で並列実行
const [
numberOfInvoices,
numberOfCustomers,
totalPaidInvoices,
totalPendingInvoices,
] = await Promise.all([
fetchNumberOfInvoices(),
fetchNumberOfCustomers(),
fetchTotalPaidInvoices(),
fetchTotalPendingInvoices(),
])
return (
<>
<Card title="回収済み" value={totalPaidInvoices} type="collected" />
<Card title="保留中" value={totalPendingInvoices} type="pending" />
<Card title="請求書総数" value={numberOfInvoices} type="invoices" />
<Card title="顧客総数" value={numberOfCustomers} type="customers" />
</>
)
}
Server Actions の実装
// app/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/auth'
const FormSchema = z.object({
id: z.string(),
customerId: z.string({
invalid_type_error: '顧客を選択してください',
}),
amount: z.coerce
.number()
.gt(0, { message: '0円より大きい金額を入力してください' }),
status: z.enum(['pending', 'paid'], {
invalid_type_error: 'ステータスを選択してください',
}),
date: z.string(),
})
const CreateInvoice = FormSchema.omit({ id: true, date: true })
export type State = {
errors?: {
customerId?: string[]
amount?: string[]
status?: string[]
}
message?: string | null
}
export async function createInvoice(prevState: State, formData: FormData) {
// 認証チェック
const session = await auth()
if (!session?.user) {
return {
message: '認証が必要です',
}
}
// バリデーション
const validatedFields = CreateInvoice.safeParse({
customerId: formData.get('customerId'),
amount: formData.get('amount'),
status: formData.get('status'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '入力内容にエラーがあります',
}
}
const { customerId, amount, status } = validatedFields.data
const amountInCents = amount * 100
const date = new Date().toISOString().split('T')[0]
try {
await sql`
INSERT INTO invoices (customer_id, amount, status, date)
VALUES (${customerId}, ${amountInCents}, ${status}, ${date})
`
} catch (error) {
return {
message: 'データベースエラー: 請求書の作成に失敗しました',
}
}
// キャッシュの再検証
revalidatePath('/dashboard/invoices')
// リダイレクト
redirect('/dashboard/invoices')
}
// app/dashboard/invoices/create/page.tsx
import Form from '@/components/invoices/create-form'
import Breadcrumbs from '@/components/invoices/breadcrumbs'
import { fetchCustomers } from '@/lib/data'
export default async function Page() {
const customers = await fetchCustomers()
return (
<main>
<Breadcrumbs
breadcrumbs={[
{ label: '請求書', href: '/dashboard/invoices' },
{
label: '請求書作成',
href: '/dashboard/invoices/create',
active: true,
},
]}
/>
<Form customers={customers} />
</main>
)
}
// components/invoices/create-form.tsx
'use client'
import { useFormState } from 'react-dom'
import { createInvoice } from '@/app/actions'
export default function Form({ customers }: { customers: Customer[] }) {
const initialState = { message: null, errors: {} }
const [state, dispatch] = useFormState(createInvoice, initialState)
return (
<form action={dispatch}>
<div className="rounded-md bg-gray-50 p-4 md:p-6">
{/* 顧客選択 */}
<div className="mb-4">
<label htmlFor="customer" className="mb-2 block text-sm font-medium">
顧客を選択
</label>
<select
id="customer"
name="customerId"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm"
defaultValue=""
aria-describedby="customer-error"
>
<option value="" disabled>
顧客を選択してください
</option>
{customers.map((customer) => (
<option key={customer.id} value={customer.id}>
{customer.name}
</option>
))}
</select>
{state.errors?.customerId && (
<div id="customer-error" className="mt-2 text-sm text-red-500">
{state.errors.customerId.map((error: string) => (
<p key={error}>{error}</p>
))}
</div>
)}
</div>
{/* 金額入力 */}
<div className="mb-4">
<label htmlFor="amount" className="mb-2 block text-sm font-medium">
金額
</label>
<input
id="amount"
name="amount"
type="number"
step="0.01"
placeholder="金額を入力"
className="peer block w-full rounded-md border border-gray-200 py-2 pl-10 text-sm"
aria-describedby="amount-error"
/>
{state.errors?.amount && (
<div id="amount-error" className="mt-2 text-sm text-red-500">
{state.errors.amount.map((error: string) => (
<p key={error}>{error}</p>
))}
</div>
)}
</div>
{/* ステータス選択 */}
<fieldset>
<legend className="mb-2 block text-sm font-medium">
請求書のステータス
</legend>
<div className="rounded-md border border-gray-200 bg-white px-[14px] py-3">
<div className="flex gap-4">
<div className="flex items-center">
<input
id="pending"
name="status"
type="radio"
value="pending"
className="h-4 w-4 cursor-pointer border-gray-300"
aria-describedby="status-error"
/>
<label
htmlFor="pending"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-600"
>
保留中
</label>
</div>
<div className="flex items-center">
<input
id="paid"
name="status"
type="radio"
value="paid"
className="h-4 w-4 cursor-pointer border-gray-300"
/>
<label
htmlFor="paid"
className="ml-2 flex cursor-pointer items-center gap-1.5 rounded-full bg-green-500 px-3 py-1.5 text-xs font-medium text-white"
>
支払済み
</label>
</div>
</div>
</div>
{state.errors?.status && (
<div id="status-error" className="mt-2 text-sm text-red-500">
{state.errors.status.map((error: string) => (
<p key={error}>{error}</p>
))}
</div>
)}
</fieldset>
{state.message && (
<div className="mt-2 text-sm text-red-500">{state.message}</div>
)}
</div>
<div className="mt-6 flex justify-end gap-4">
<Link
href="/dashboard/invoices"
className="flex h-10 items-center rounded-lg bg-gray-100 px-4 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-200"
>
キャンセル
</Link>
<Button type="submit">請求書を作成</Button>
</div>
</form>
)
}
SEO最適化
動的メタデータ
// app/blog/[slug]/page.tsx
import { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { getPost, getAllPosts } from '@/lib/api'
interface PageProps {
params: { slug: string }
}
// 動的なメタデータ生成
export async function generateMetadata(
{ params }: PageProps
): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) {
return {
title: '記事が見つかりません',
}
}
const publishedTime = new Date(post.publishedAt).toISOString()
const modifiedTime = new Date(post.updatedAt || post.publishedAt).toISOString()
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime,
modifiedTime,
authors: [post.author.name],
images: [
{
url: post.ogImage || '/og-image.jpg',
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.ogImage || '/og-image.jpg'],
},
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
},
}
}
// 静的パスの生成
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function BlogPost({ params }: PageProps) {
const post = await getPost(params.slug)
if (!post) {
notFound()
}
// 構造化データ
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
image: post.ogImage,
datePublished: post.publishedAt,
dateModified: post.updatedAt || post.publishedAt,
author: {
'@type': 'Person',
name: post.author.name,
},
publisher: {
'@type': 'Organization',
name: 'エンハンスド株式会社',
logo: {
'@type': 'ImageObject',
url: 'https://example.com/logo.png',
},
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article className="container mx-auto px-4 py-8 max-w-4xl">
<header className="mb-8">
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
<div className="flex items-center gap-4 text-gray-600">
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('ja-JP')}
</time>
<span>•</span>
<span>{post.author.name}</span>
</div>
</header>
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</>
)
}
sitemap.xml と robots.txt
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/api'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://example.com'
// 静的ページ
const staticPages = [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily' as const,
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly' as const,
priority: 0.8,
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.9,
},
]
// 動的ページ(ブログ記事)
const posts = await getAllPosts()
const blogPages = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt || post.publishedAt),
changeFrequency: 'monthly' as const,
priority: 0.7,
}))
return [...staticPages, ...blogPages]
}
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://example.com'
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/dashboard/'],
},
{
userAgent: 'Googlebot',
allow: '/',
},
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
}
}
パフォーマンス最適化
画像の最適化
// components/OptimizedImage.tsx
import Image from 'next/image'
interface OptimizedImageProps {
src: string
alt: string
width: number
height: number
priority?: boolean
}
export default function OptimizedImage({
src,
alt,
width,
height,
priority = false,
}: OptimizedImageProps) {
return (
<Image
src={src}
alt={alt}
width={width}
height={height}
priority={priority}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="rounded-lg"
placeholder="blur"
blurDataURL={`data:image/svg+xml;base64,${toBase64(shimmer(width, height))}`}
/>
)
}
// Shimmer effect for placeholder
const shimmer = (w: number, h: number) => `
<svg width="${w}" height="${h}" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="g">
<stop stop-color="#f0f0f0" offset="20%" />
<stop stop-color="#e0e0e0" offset="50%" />
<stop stop-color="#f0f0f0" offset="70%" />
</linearGradient>
</defs>
<rect width="${w}" height="${h}" fill="#f0f0f0" />
<rect id="r" width="${w}" height="${h}" fill="url(#g)" />
<animate xlink:href="#r" attributeName="x" from="-${w}" to="${w}" dur="1s" repeatCount="indefinite" />
</svg>
`
const toBase64 = (str: string) =>
typeof window === 'undefined'
? Buffer.from(str).toString('base64')
: window.btoa(str)
フォントの最適化
// app/layout.tsx
import { Inter, Noto_Sans_JP } from 'next/font/google'
// 英語フォント
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
// 日本語フォント
const notoSansJP = Noto_Sans_JP({
weight: ['400', '500', '700', '900'],
subsets: ['latin'],
display: 'swap',
variable: '--font-noto-sans-jp',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ja" className={`${inter.variable} ${notoSansJP.variable}`}>
<body className="font-sans">
{children}
</body>
</html>
)
}
// globals.css
@layer base {
:root {
--font-inter: 'Inter', sans-serif;
--font-noto-sans-jp: 'Noto Sans JP', sans-serif;
}
body {
font-family: var(--font-noto-sans-jp), var(--font-inter);
}
/* 英数字のみ Inter を使用 */
.font-inter {
font-family: var(--font-inter);
}
}
バンドルサイズの最適化
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
// 実験的機能の有効化
experimental: {
optimizePackageImports: ['@/components/ui'],
},
// 画像最適化の設定
images: {
formats: ['image/avif', 'image/webp'],
domains: ['images.example.com'],
},
// SWC minifier の設定
swcMinify: true,
// モジュールのエイリアス設定
webpack: (config) => {
config.resolve.alias = {
...config.resolve.alias,
'@': path.resolve(__dirname, 'src'),
}
// バンドル分析
if (process.env.ANALYZE === 'true') {
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
config.plugins.push(
new BundleAnalyzerPlugin({
analyzerMode: 'static',
reportFilename: './analyze.html',
openAnalyzer: true,
})
)
}
return config
},
}
module.exports = nextConfig
// Dynamic imports for code splitting
// components/DynamicChart.tsx
import dynamic from 'next/dynamic'
const Chart = dynamic(() => import('./Chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // クライアントサイドのみでレンダリング
})
export default function DynamicChart({ data }: { data: ChartData }) {
return <Chart data={data} />
}
エラーハンドリングとロギング
グローバルエラーハンドリング
// app/error.tsx
'use client'
import { useEffect } from 'react'
import { captureException } from '@sentry/nextjs'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// エラーをロギングサービスに送信
captureException(error)
}, [error])
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h2 className="mb-4 text-2xl font-bold">エラーが発生しました</h2>
<p className="mb-8 text-gray-600">
申し訳ございません。予期しないエラーが発生しました。
</p>
<button
onClick={reset}
className="rounded-lg bg-blue-500 px-4 py-2 text-white hover:bg-blue-600"
>
もう一度試す
</button>
</div>
)
}
// app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<h2 className="mb-4 text-2xl font-bold">
アプリケーションエラー
</h2>
<p className="mb-8">
システムエラーが発生しました。しばらくしてからもう一度お試しください。
</p>
<button
onClick={reset}
className="rounded bg-blue-500 px-4 py-2 text-white"
>
再読み込み
</button>
</div>
</div>
</body>
</html>
)
}
カスタムロギング
// lib/logger.ts
import pino from 'pino'
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: {
target: 'pino-pretty',
options: {
colorize: true,
},
},
})
// Server Component でのロギング
export function logServerAction(action: string, data?: any) {
logger.info({
type: 'server_action',
action,
data,
timestamp: new Date().toISOString(),
})
}
// API Route でのロギング
export function logApiRequest(
method: string,
path: string,
statusCode: number,
duration: number
) {
logger.info({
type: 'api_request',
method,
path,
statusCode,
duration,
timestamp: new Date().toISOString(),
})
}
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { logApiRequest } from '@/lib/logger'
export function middleware(request: NextRequest) {
const start = Date.now()
// レスポンスの処理
const response = NextResponse.next()
// APIリクエストのロギング
if (request.nextUrl.pathname.startsWith('/api')) {
const duration = Date.now() - start
logApiRequest(
request.method,
request.nextUrl.pathname,
response.status,
duration
)
}
return response
}
export const config = {
matcher: ['/api/:path*'],
}
まとめ
Next.js App Router は、React Server Components の力を最大限に活用し、高速でSEOに優れたWebアプリケーションを構築するための強力なフレームワークです。
重要なポイント:
- Server Components でデータフェッチングを最適化
- Client Components は必要最小限に
- ストリーミングとサスペンスで UX を向上
- Server Actions で フォーム処理を簡潔に
- 適切なキャッシング戦略でパフォーマンスを最大化
エンハンスド株式会社では、Next.js App Router を活用した高性能なWebアプリケーション開発を支援しています。お気軽にお問い合わせください。
タグ: #NextJS #AppRouter #React #ServerComponents #Performance #SEO
執筆者: エンハンスド株式会社 フロントエンド開発部
公開日: 2024年12月20日