👩‍💻

【Next.js】App Router × Route Handlersで実現する無限スクロール

に公開

はじめに

株式会社ASSIGNでWebフロント開発を担当している中水です。

先日、ASSIGN Webアプリのマイエージェント機能でNext.jsのRoute Handlersを使った無限スクロール機能を実装しました。
本記事では、無限スクロールの実装方法と、クライアントサイドでのデータフェッチの手法を解説します。

要件

初期表示で20件のデータを取得し、画面下部までスクロールした際に、さらに20件ずつ追加でデータを取得・表示します。

環境

  • Next.js v14(App Router)
  • TypeScript

実装を進める上での課題と対応方針

前提

ASSIGN Webアプリでは、パフォーマンスとセキュリティを両立するため、サーバーコンポーネントでのフェッチをデータ取得の基本方針としています。特に、認証情報はhttpOnlyクッキーなどの仕組みでクライアントのJavaScriptから参照できない形で保持し、機密情報の露出を防ぐ方針です。
一方で、無限スクロールのように、ユーザーのスクロール操作に応じて任意のタイミングで追加データを取得するケースでは、クライアント側からのフェッチが不可欠です。

技術的な課題

まず、サーバーコンポーネントはDOMイベント(例: スクロール)を直接扱えないという制約があります。ユーザーの操作に応じて「今このタイミングで追加のデータを取得する」といった動的フェッチは、サーバー側だけでは完結しません。
次に、クライアント側でフェッチを行う場合のセキュリティと運用面の考慮です。以下の点を明確にしておく必要があります。

  • APIを直接呼び出す際の認可・認証処理
  • APIキーや認証トークンの安全な扱い
  • キャッシュ戦略やエラーハンドリングの整理

無限スクロールのようにリクエスト回数が増えやすい機能では、これらの検討が体験と安全性の両面に直結します。
したがって、クライアントで動的フェッチを行いつつも、機密情報はクライアントに露出させない仕組みが必要です。

対応方針

この2点(クライアント起点の動的フェッチ、トークンはクライアントに露出させない)を両立するため、クライアントコンポーネント内でRoute Handlersを経由してデータをフェッチする方針を採用しました。
Route Handlersを利用することで以下のメリットが得られます。

  • httpOnlyクッキーに保持されたアクセストークンをクライアントへ露出させずに扱える。
  • スクロール到達をトリガーに高頻度・任意タイミングでGETを繰り返すユースケースと相性がよい。
  • 認証付与・レスポンス整形・レート制御などのネットワーク境界の処理をRoute Handlersに集約でき、UI側はfetch発火に専念できる。

Route Handlersを使わない場合との比較

別案として、クライアントから直接API呼び出す方式も考えられます。
しかし、httpOnlyクッキーはJavaScriptから参照できないため、Authorizationヘッダーを安全に付与できません。これを回避してトークンをlocalStorageや非httpOnlyクッキーに置くと、XSSによる窃取リスクが高まります。
さらにAPIのURLやキー/スコープをクライアントに含める必要が生じ、バンドルやDevTools経由で参照される可能性もあります。

これらを踏まえ、Route Handlersを採用する判断に至りました。

実装手順

以下では、無限スクロールを実現するためのサンプル実装を紹介します。
実際のプロダクトで利用する際は、認証・エラーハンドリング・キャッシュ戦略などをプロジェクト要件に合わせて調整してください。

処理フロー

  1. 初回表示(サーバー)
    サーバーコンポーネントで初期データを取得・描画
  2. クライアントでスクロール発火を検知
    IntersectionObserverで監視対象が画面内に入ったら追加取得を開始
  3. クライアントからRoute Handlers(/api/...)をfetch
    クライアントはトークンを保持せず、API境界であるRoute Handlersにのみリクエスト
  4. Route Handlersがcookies()からトークン取得し、APIにトークンを付与してリクエスト
    認証付与やレスポンス整形などの境界処理はRoute Handlersに集約
  5. レスポンスをクライアントに返却
    受け取ったデータを状態にマージし、リストを追描画

1. API(Route Handlers)

Route Handlersを使って、無限スクロール用のAPIエンドポイントを実装します。

route.ts
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const page = searchParams.get('page') || '1';
  const limit = searchParams.get('limit') || '20';

  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${limit}`,
    );

    if (!response.ok) {
      throw new Error('Failed to fetch data');
    }

    const posts = await response.json();
    const totalCount = response.headers.get('x-total-count') || '100';

    return NextResponse.json({
      posts,
      totalCount: parseInt(totalCount, 10),
      currentPage: parseInt(page, 10),
      hasMore:
        parseInt(page, 10) * parseInt(limit, 10) < parseInt(totalCount, 10),
    });
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
    return NextResponse.json(
      { 
        error: 'Failed to fetch posts',
        details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
      },
      { status: 500 },
    );
  }
}

2. Hooks

無限スクロールのコアロジックをカスタムフックとして実装します。
IntersectionObserver を使い、監視対象(loaderRef)が画面内に入ったタイミングで追加データをフェッチする仕組みです。

useInfiniteScroll.ts
import { useCallback, useEffect, useRef, useState } from 'react';

export function useInfiniteScroll({
  initialPosts,
  initialTotalCount,
  initialHasMore,
}) {
  const [posts, setPosts] = useState(initialPosts);
  const [page, setPage] = useState(2);
  const [isLoading, setIsLoading] = useState(false);
  const [hasMore, setHasMore] = useState(initialHasMore);
  const [totalCount, setTotalCount] = useState(initialTotalCount);
  const loaderRef = useRef<HTMLDivElement | null>(null);

  const POSTS_PER_PAGE = 20;

  const loadMorePosts = useCallback(async () => {
    if (isLoading || !hasMore) return;

    setIsLoading(true);

    try {
      const response = await fetch(
        `/infinity-scroll/api?page=${page}&limit=${POSTS_PER_PAGE}`,
      );

      if (!response.ok) {
        throw new Error('Failed to fetch posts');
      }

      const data = await response.json();

      setPosts(prevPosts => [...prevPosts, ...data.posts]);
      setHasMore(data.hasMore);
      setTotalCount(data.totalCount);
      setPage(prevPage => prevPage + 1);
    } catch (error) {
      const _errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
      if (process.env.NODE_ENV === 'development') {
        console.error(_errorMessage);
      }
      setHasMore(false);
    } finally {
      setIsLoading(false);
    }
  }, [page, isLoading, hasMore]);

  useEffect(() => {
    if (!hasMore || isLoading) return;

    const observer = new IntersectionObserver(
      entries => {
        const target = entries[0];
        if (target && target.isIntersecting) {
          loadMorePosts();
        }
      },
      { threshold: 0.1 },
    );

    const loaderElement = loaderRef.current;
    if (loaderElement) {
      observer.observe(loaderElement);
    }

    return () => {
      if (loaderElement) {
        observer.unobserve(loaderElement);
      }
    };
  }, [hasMore, isLoading, loadMorePosts]);

  return {
    posts,
    isLoading,
    hasMore,
    totalCount,
    loaderRef,
  };
}

3. UIコンポーネント

LoadMoreTrigger(監視トリガー)

スクロール監視用のトリガー要素を実装します。
高さ1pxのdiv要素を配置し、この要素がスクロールで画面に入ったら新しいデータを読み込むようにします。

LoadMoreTrigger.tsx
export default function LoadMoreTrigger({ triggerRef }) {
  return <div ref={triggerRef} className="h-1" />;
}

PostCard(UI)

取得したデータを表示するためのUIコンポーネントを実装します。

PostCard.tsx
import type { Post } from '@/app/infinity-scroll/types';

type Props = {
  post: Post;
};

export default function PostCard({ post }: Props) {
  return (
    <div className="p-4 border border-gray-200 rounded-[6px] bg-white shadow-sm hover:shadow-md transition-shadow">
      <h3 className="text-[16px] font-semibold text-[#242424] mb-2">
        {post.title}
      </h3>
      <p className="text-[14px] text-[#666666] line-clamp-3">{post.body}</p>
      <div className="mt-3 text-[12px] text-[#999999]">
        User ID: {post.userId} • Post ID: {post.id}
      </div>
    </div>
  );
}

InfiniteScrollDemo(表示・状態制御)

無限スクロールのメインUIを実装します。
リスト描画、トリガーの設置、ローディング表示/全件表示メッセージなどの状態管理を担います。

InfiniteScrollDemo.tsx
'use client';

import LoadMoreTrigger from '@/app/infinity-scroll/components/LoadMoreTrigger';
import PostCard from '@/app/infinity-scroll/components/PostCard';
import { useInfiniteScroll } from '@/app/infinity-scroll/hooks/useInfiniteScroll';
import LoadingIndicator from '@/components/LoadingIndicator';

export default function InfiniteScrollDemo({
  initialPosts,
  totalCount,
  hasInitialMore,
}) {
  const { posts, isLoading, hasMore, loaderRef } = useInfiniteScroll({
    initialPosts,
    initialTotalCount: totalCount,
    initialHasMore: hasInitialMore,
  });

  return (
    <div className="max-w-4xl mx-auto p-4">
      <div className="mb-6">
        <h1 className="text-[24px] font-bold text-[#242424] mb-2">
          無限スクロールデモ
        </h1>
        <p className="text-[14px] text-[#666666]">
          JSONPlaceholderを使用した無限スクロール実装のデモページです
        </p>
        {totalCount > 0 && (
          <div className="mt-2 text-[12px] text-[#999999]">{totalCount}件 • 表示中: {posts.length}</div>
        )}
      </div>

      <div className="flex flex-col gap-4">
        {posts.map(post => (
          <PostCard key={post.id} post={post} />
        ))}
      </div>

      {hasMore && !isLoading ? (
        <LoadMoreTrigger triggerRef={loaderRef} />
      ) : null}

      {isLoading && posts.length > 0 ? (
        <div className="py-4">
          <LoadingIndicator />
        </div>
      ) : null}

      {!hasMore && posts.length > 0 && (
        <div className="py-8 text-center text-[14px] text-[#999999]">
          すべての投稿を表示しました
        </div>
      )}
    </div>
  );
}

4. ユーティリティ

初回データ取得のためのユーティリティ関数を実装します。

fetchPosts.ts
import type { PostsResponse } from '@/app/infinity-scroll/types';

export async function fetchPosts(
  page: number = 1,
  limit: number = 20,
): Promise<PostsResponse> {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${page}&_limit=${limit}`,
    { cache: 'no-store' },
  );

  if (!response.ok) {
    throw new Error('Failed to fetch posts');
  }

  const posts = await response.json();
  const totalCount = parseInt(
    response.headers.get('x-total-count') || '100',
    10,
  );

  return {
    posts,
    totalCount,
    currentPage: page,
    hasMore: page * limit < totalCount,
  };
}

5. ページ(Server Component)

処理フローの「1. 初回表示(サーバー)」に対応するコンポーネントです。初期データはサーバーコンポーネントで取得・描画し、その後の追加取得はクライアントからRoute Handlers経由で行います。

page.tsx
import InfiniteScrollDemo from '@/app/infinity-scroll/components/InfiniteScrollDemo';
import { fetchPosts } from '@/app/infinity-scroll/utils/fetchPosts';

export default async function InfinityScrollPage() {
  const initialData = await fetchPosts(1, 20);

  return (
    <InfiniteScrollDemo
      initialPosts={initialData.posts}
      totalCount={initialData.totalCount}
      hasInitialMore={initialData.hasMore}
    />
  );
}

おわりに

Next.js App Router環境でRoute Handlersを使った無限スクロール実装について解説しました。
同じように無限スクロールを実装しようとしている方に対して、少しでもこの記事が参考になれば嬉しいです。

参考

https://nextjs.org/docs/14/app/building-your-application/data-fetching/fetching-caching-and-revalidating#fetching-data-on-the-client-with-route-handlers
https://nextjs.org/docs/14/app/building-your-application/routing/route-handlers

ASSIGN

Discussion