AIオウンドメディアを1人で18サイト、9言語展開で運用し、年間6,570記事を自動生成しているFuturistic Imagination LLC 代表の佐藤琢也です。Next.js + Gemini API + Vercel Cronで毎日記事を自動生成するシステムを自社で運用していますが、「なんかこれって、もっとスマートにできないかな?」と常に探求しています。
特に最近、ブログ記事一覧のような無限スクロールUIにおいて、従来の取得方法だとVercelのRate Limitに引っかかったり、APIの呼び出し回数が無駄に増えたりして、パフォーマンスとコストの両面で課題を感じていました。
そこで、「弊社自身が使っている状態を作らないと顧客に刺さらない」という哲学のもと、Upstash RedisとVercel Edge Functionsを組み合わせた、無限スクロールキャッシュの新しい実装を試しました。今回は、その具体的な実装と成果について、皆さんにお伝えしたいと思います。
この記事を読めば、以下のようなことがわかります。
- 無限スクロールにおけるパフォーマンス課題の根本原因と、キャッシュ戦略の重要性
- Upstash RedisとVercel Edge Functionsを組み合わせた強力なキャッシュ戦略
- 具体的なコード例で学ぶ、キャッシュ実装のステップ
- Edge FunctionsでRedisクライアントを扱う際の注意点とベストプラクティス
- AI自動生成メディアでこのキャッシュ戦略がどれだけ効果を発揮したか
🚀 無限スクロールの「なんかこれって〜じゃない?」問題
僕の運営するAIオウンドメディアでは、数千から数万にも及ぶ記事が生成されます。これをユーザーに表示する際、ほとんどのサイトでは「もっと読む」ボタンやスクロールに合わせて次のページをロードする無限スクロールが採用されていますよね。
従来の無限スクロールの実装では、ユーザーがスクロールするたびにAPIを呼び出し、データベースからデータを取得することが一般的です。しかし、これにはいくつかの問題があります。
- Rate Limit問題: Vercelなどのプラットフォームでは、Serverless Functionsの呼び出し回数に制限があります。特に大量のコンテンツを持つサイトや、急なアクセス増があった場合にRate Limitに引っかかり、ユーザー体験が悪化することがあります。
- パフォーマンスボトルネック: データベースへの頻繁なアクセスは、レイテンシの原因となります。特に海外からのアクセスや、データセンターが遠い場合に顕著です。
- コスト増: API呼び出し回数やデータベースの読み取り回数が増えるほど、クラウドサービスの利用料は高くなります。僕のように年間6,570記事を自動生成し続けるとなると、この積み重ねは無視できません。
「〇〇って切り替えたから、この作業はもう不要かな?」という思考の通り、僕らは常に無駄をなくし、効率を最大化したい。そこで、この無限スクロールにおけるリクエストの無駄を徹底的に排除するために、キャッシュの導入を考えました。
🛠 Upstash RedisとVercel Edge Functionsの組み合わせが最強な理由
今回採用したのは、Upstash RedisとVercel Edge Functionsです。この組み合わせがなぜ最強なのか、その理由を説明します。
Upstash Redis: サーバレスフレンドリーなRedis
Upstashは、サーバレス環境に特化したRedisサービスです。従来のRedisインスタンスのように常に接続を維持する必要がなく、必要な時にだけ接続を確立する「サーバレスファースト」な設計が特徴です。
- 低レイテンシ: 世界中のCDNエッジに近く、どこからでも高速にアクセスできます。
- オートスケーリング: アクセス量に応じて自動でスケーリングするため、運用負荷がありません。
- コスト効率: 従量課金制で、使った分だけ支払うため、僕のように大量のAIメディアを運用する際にもコストを最適化できます。
Vercel Edge Functions: 超高速なエッジロジック
Vercel Edge Functionsは、ユーザーに最も近いVercelのCDNエッジで実行される関数です。Node.jsではなくWeb標準APIに基づいているため、非常に軽量で高速に動作します。
- グローバルな高速性: ユーザーリクエストが地理的に最も近いエッジロケーションで処理されるため、APIレイテンシを大幅に削減できます。
- 軽量な実行環境: Node.jsのコールドスタート問題がなく、すぐに実行されます。これはキャッシュの有無を判断し、適切なレスポンスを返すようなロジックに最適です。
この2つを組み合わせることで、「無限スクロールのページネーション情報」のような頻繁にアクセスされるが更新頻度が低いデータを、ユーザーに最も近いエッジでキャッシュし、高速に提供できるようになります。
💡 実装ステップ:無限スクロールキャッシュを構築する
実際にどう実装したのか、TypeScriptでの具体的なコードを交えながら解説します。
1. Upstash Redisの準備
まず、UpstashのダッシュボードでRedisデータベースを作成し、REST_API_URL と REST_API_TOKEN を取得します。これらをVercelの環境変数に設定しておきます。
# .env.local
UPSTASH_REDIS_REST_API_URL="https://xxx.upstash.io"
UPSTASH_REDIS_REST_API_TOKEN="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
2. Edge FunctionsでRedisクライアントを初期化する
Vercel Edge FunctionsはNode.js環境ではないため、従来のNode.js向けRedisクライアント(ioredisなど)は直接使えません。UpstashはHTTPベースのREST APIも提供しており、これを@upstash/redisというSDKがラップしてくれています。
pages/api/posts.ts (Edge FunctionとしてデプロイされるAPIルート)
import { Redis } from '@upstash/redis/edge';
import type { NextRequest } from 'next/server';
// Edge Functions内でRedisクライアントを初期化
// 環境変数はVercelのダッシュボードで設定しておく
const redis = Redis.fromEnv();
// このAPIルートをEdge Functionsとして実行するように設定
export const config = {
runtime: 'edge',
};
export default async function handler(req: NextRequest) {
const { searchParams } = new URL(req.url);
const page = searchParams.get('page') || '1';
const limit = searchParams.get('limit') || '10';
const cacheKey = `posts_page_${page}_limit_${limit}`;
try {
// 1. キャッシュの確認
const cachedPosts = await redis.get<string>(cacheKey);
if (cachedPosts) {
console.log(`Cache hit for ${cacheKey}`);
return new Response(cachedPosts, {
status: 200,
headers: {
'Content-Type': 'application/json',
'X-Cache': 'HIT', // キャッシュヒットを示すカスタムヘッダー
},
});
}
console.log(`Cache miss for ${cacheKey}. Fetching from DB...`);
// 2. キャッシュがない場合、データベースからデータを取得(今回はモックデータ)
// 実際にはRDBやHeadless CMSからデータを取得する
const start = (parseInt(page) - 1) * parseInt(limit);
const end = start + parseInt(limit);
// モックデータとして、実際のブログ記事を想定
const allPosts = Array.from({ length: 100 }, (_, i) => ({
id: `post-${i + 1}`,
title: `AIが生成した記事 ${i + 1} のタイトル`,
slug: `ai-generated-article-${i + 1}`,
publishedAt: new Date(Date.now() - i * 3600 * 1000).toISOString(),
contentPreview: `これはAIが自動生成した記事 ${i + 1} のプレビューです。コンテンツは毎日自動リライトされています。`,
}));
const posts = allPosts.slice(start, end);
// 3. 取得したデータをJSON形式に変換
const postsJson = JSON.stringify(posts);
// 4. Redisにキャッシュとして保存(有効期限を設定)
// 頻繁に更新されない一覧ページなら、数分〜数時間程度の有効期限が適切
await redis.set(cacheKey, postsJson, { ex: 60 * 5 }); // 5分間キャッシュ
return new Response(postsJson, {
status: 200,
headers: {
'Content-Type': 'application/json',
'X-Cache': 'MISS', // キャッシュミスを示すカスタムヘッダー
},
});
} catch (error) {
console.error('Error fetching posts:', error);
return new Response(JSON.stringify({ error: 'Internal Server Error' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
コードのポイント
-
@upstash/redis/edgeの使用: Edge Functionsに対応したRedisクライアントをインポートします。 -
Redis.fromEnv(): 環境変数からRedisの接続情報を取得し、クライアントを初期化します。 -
export const config = { runtime: 'edge' }: これを記述することで、このAPIルートがEdge Functionsとしてデプロイされます。ここが非常に重要です。 -
cacheKeyの設計:posts_page_${page}_limit_${limit}のように、クエリパラメータに応じて一意のキャッシュキーを生成します。 -
redis.get()とredis.set(): キャッシュの読み込みと書き込みを行います。setメソッドのexオプションで有効期限(秒単位)を設定しています。無限スクロールではページネーション情報が変わるたびに新しいキーが生成されるため、有効期限は短めでも問題ありませんが、更新頻度やデータ鮮度の要件に合わせて調整します。 -
X-Cacheヘッダー: キャッシュヒット/ミスをデバッグしやすいように、カスタムヘッダーを追加しています。
3. フロントエンド(Next.jsコンポーネント)での利用
フロントエンド側では、これまで通りfetch APIを使ってこのAPIルートを呼び出すだけです。SWRやReact Queryなどのデータフェッチライブラリを使えば、無限スクロールの実装がより簡潔になります。
// components/InfiniteScrollPosts.tsx (Next.jsのReactコンポーネント)
import React, { useState, useEffect, useRef, useCallback } from 'react';
interface Post {
id: string;
title: string;
slug: string;
publishedAt: string;
contentPreview: string;
}
const InfiniteScrollPosts: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observer = useRef<IntersectionObserver>();
const fetchPosts = useCallback(async (pageNum: number) => {
setLoading(true);
try {
const res = await fetch(`/api/posts?page=${pageNum}&limit=10`);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const newPosts: Post[] = await res.json();
setPosts((prevPosts) => [...prevPosts, ...newPosts]);
setHasMore(newPosts.length > 0); // データが空ならもうデータがないと判断
} catch (error) {
console.error('Failed to fetch posts:', error);
setHasMore(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchPosts(page);
}, [page, fetchPosts]);
// Intersection Observerを使って無限スクロールを実装
const lastPostElementRef = useCallback(
(node: HTMLDivElement | null) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
setPage((prevPage) => prevPage + 1);
}
});
if (node) observer.current.observe(node);
},
[loading, hasMore]
);
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-6 text-gray-800">最新のAI生成記事</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map((post, index) => (
<div
key={post.id}
className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden"
ref={index === posts.length - 1 ? lastPostElementRef : null} // 最後の要素にrefを渡す
>
<div className="p-6">
<h2 className="text-xl font-semibold mb-2 text-blue-600 hover:text-blue-800">
<a href={`/articles/${post.slug}`}>{post.title}</a>
</h2>
<p className="text-gray-600 text-sm mb-3">公開日: {new Date(post.publishedAt).toLocaleDateString()}</p>
<p className="text-gray-700 leading-relaxed text-sm">
{post.contentPreview.length > 100 ? post.contentPreview.substring(0, 100) + '...' : post.contentPreview}
</p>
</div>
</div>
))}
</div>
{loading && (
<div className="text-center py-8 text-gray-500">
<p>記事をロード中...</p>
</div>
)}
{!hasMore && !loading && posts.length > 0 && (
<div className="text-center py-8 text-gray-500">
<p>すべての記事を読み込みました。</p>
</div>
)}
{!hasMore && !loading && posts.length === 0 && (
<div className="text-center py-8 text-gray-500">
<p>記事が見つかりませんでした。</p>
</div>
)}
</div>
);
};
export default InfiniteScrollPosts;
このコンポーネントを、例えばpages/index.tsxなどで呼び出せば、無限スクロールのページが表示されます。
✅ 成果と考察:AIメディア運用で感じたインパクト
この実装を僕の運営するAIオウンドメディアに導入した結果、目に見えてパフォーマンスが向上しました。
- Vercel Serverless Functionの呼び出し回数削減: 一度キャッシュされたページネーションデータは、5分間はRedisから直接返されるため、バックエンドのAPI呼び出し回数が大幅に減少しました。特にユーザーが何度もページを再訪したり、短時間にスクロールを繰り返したりする場合に効果絶大です。
- API応答速度の向上: キャッシュヒット時のAPI応答速度は、従来のデータベースからのデータ取得と比較して数倍高速になりました。エッジロケーションからデータが返されるため、ユーザーはよりサクサクとした体験を得られます。
- コスト削減の可能性: Serverless Functionの呼び出し回数が減ることで、Vercelやデータベースのコスト削減にも繋がります。AI自動生成記事の増加に伴い、スケールするシステムにおいて、このキャッシュ戦略は非常に有効だと感じています。
- 開発効率の向上: 「なるほどね」「よしお願いします」の精神で、一度システムを組んでしまえば、その後はキャッシュが自動的に効くため、開発メンバーがパフォーマンスチューニングに神経質になる必要が減り、より「コアな創造的思考」に集中できる環境を体現できています。
僕らは「嘘のないビジネス設計」を掲げているので、誇大広告のようなことは言いませんが、このUpstash RedisとVercel Edge Functionsの組み合わせは、まさに「自動化による圧倒的生産性の体現者」として、僕らの事業を力強く支えてくれることを実証しました。
🤔 注意点とベストプラクティス
-
キャッシュ無効化の戦略: 記事が更新されたり、新しい記事が追加されたりした際に、どうやってキャッシュを無効化するかを考慮する必要があります。今回は有効期限 (
ex) でシンプルに実装しましたが、redis.del()を使って明示的にキャッシュを削除する仕組み(例えば、記事更新APIからRedisを叩くなど)も検討すべきです。 - データの一貫性: キャッシュの有効期限とデータの鮮度のバランスは非常に重要です。リアルタイム性を求められるデータには向かないかもしれませんが、ブログ記事一覧のような比較的更新頻度が低いデータには最適です。
- エラーハンドリング: Redisがダウンした場合や、ネットワークエラーが発生した場合に備え、適切にエラーハンドリングを行い、フォールバックとしてデータベースから直接データを取得するロジックも考慮しておきましょう。
- Vercel環境変数とシークレット: UpstashのAPIキーなどはVercelのシークレットとして設定し、安全に管理してください。
まとめ
Upstash RedisとVercel Edge Functionsを組み合わせた無限スクロールキャッシュ戦略は、AIオウンドメディアのような大量のコンテンツを扱うNext.jsアプリケーションにおいて、パフォーマンスとコストの両面で大きなメリットをもたらします。
「なんかこれって〜じゃない?」と現状に疑問を持ち、常に改善を追求する姿勢が、このような効率的でスマートなシステムを生み出す原動力です。僕らは、この「自社実証ファースト」の哲学に基づき、これからも最先端のテクノロジーをビジネスと社会に還元していきます。
今回紹介したような、Next.js、AIを活用した自動化システムの構築や、WordPressからNext.jsへの移行、SNS自動化、Gemini APIパイプライン構築などの開発代行も行っています。ご興味があれば、ぜひ一度お問い合わせください。
→ Futuristic Imagination LLC サービス紹介
また、転職・副業・キャリアに関するShorts動画を毎日配信中ですので、こちらもぜひご覧ください。