Upstash Redis と Upstash Workflow で作るスケーラブルなニュースレターアプリ
この記事では、ユーザーが購読登録を行い、ニュースレターの受信頻度を選択できるニュースレターアプリを構築します。購読データの保存には Upstash Redis を使用し、データの保存・ウェルカムメールの送信・ユーザーの設定に基づいたニュースレターの定期配信といった一連の処理の管理には Upstash Workflow を活用します。
開発の動機
まず前提として、サーバーレス環境は非常に優れています。高いスケーラビリティを備え、コスト面でも有利です。しかし一方で、実行時間の制限など、いくつかの制約も存在します。特に、長時間の実行が必要なタスクを扱う場合、この制限が大きな障害になることがあります。
そこで役立つのが Upstash Workflow です。Upstash Workflow を使えば、必要なだけ長く実行し続けられる永続的なワークフローを作成できます。もうサーバーレス関数のタイムアウトを心配する必要はありません。
Upstash Workflow を利用することで得られる主な機能は以下の通りです。
- サーバーレス関数のタイムアウトを気にしない:ワークフローは必要なだけ長く実行できます。
- 自動リカバリー:何らかの問題でワークフローが途中で失敗しても、自動的に復旧します。
- 自動リトライ:ワークフロー内のステップが失敗した場合、自動的に再試行されます。
- リアルタイム監視:Upstash Console からワークフローをリアルタイムで監視できます。
前提条件
- Next.js アプリケーションに関する基礎知識
- Redis および QStash のトークンを取得するための Upstash アカウント
- デプロイ用の Vercel アカウント
- ローカル開発用の ngrok(推奨)
プロジェクトのセットアップ
まずは create-next-app を使って、新しい Next.js プロジェクトを作成しましょう。
npx create-next-app@latest --typescript newsletter-app
cd newsletter-app
次に、Upstash QStash および Redis サービスと連携するために必要な依存パッケージを追加します。
npm install @upstash/qstash @upstash/redis
ディレクトリ構成
コードに入る前に、プロジェクト全体の構成を簡単に確認しておきましょう。
src/app/:アプリケーションの主要なコンポーネントとページを配置します。src/app/api/:購読・購読解除・ワークフロー処理のための API ルートを配置します。src/components/:購読フォームおよび購読解除フォームのコンポーネントを格納します。src/lib/:Redis 操作やメール送信のためのユーティリティ関数を配置します。src/types/:TypeScript の型定義を管理します。
環境変数
プロジェクトのルートに .env ファイルを作成し、以下の環境変数を追加してください。
QSTASH_TOKEN=
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
EMAIL_SERVICE_URL=
NEXT_PUBLIC_BASE_URL=
- QSTASH_TOKEN:Upstash Console から取得できる QStash のトークンです。
- UPSTASH_REDIS_REST_URL と UPSTASH_REDIS_REST_TOKEN:Upstash Console から取得できる Upstash Redis の認証情報です。
- EMAIL_SERVICE_URL:メール送信 API のエンドポイントです。
- NEXT_PUBLIC_BASE_URL:デプロイしたアプリケーションのベース URL(例:
https://your-app.vercel.app)です。
また、ngrok の URL を使ったローカル開発のために、.env ファイルに UPSTASH_WORKFLOW_URL 変数を設定することもできます。ngrok を使ったワークフローのローカル開発方法について詳しくは、Upstash の公式ドキュメントをご参照ください。
なお、UPSTASH_WORKFLOW_URL 環境変数はローカル開発時にのみ必要です。本番環境では baseUrl パラメータが自動的に設定されるため、省略可能です。
プロジェクトの実装
購読フォームコンポーネント
SubscriptionForm コンポーネントでは、ユーザーがメールアドレスを入力し、ニュースレターの受信頻度を選択できます。フォームが送信されると、フォームデータとともに /api/subscribe へ POST リクエストが送信されます。
src/components/SubscriptionForm.tsx
"use client";
import React, { useState } from "react";
export default function SubscriptionForm() {
const [frequency, setFrequency] = useState("daily");
const [showCustomFrequency, setShowCustomFrequency] = useState(false);
const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false);
// Handle frequency selection
const handleFrequencyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setFrequency(value);
setShowCustomFrequency(value === "custom");
};
// Handle form submission
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setMessage("");
setIsError(false);
const formData = new FormData(e.currentTarget);
try {
const response = await fetch("/api/subscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(Object.fromEntries(formData.entries())),
});
const result = await response.json();
if (!response.ok) {
setIsError(true);
setMessage(result.error || "An error occurred during subscription.");
} else {
setIsError(false);
setMessage(result.message || "Subscription successful!");
}
} catch (error) {
console.error("An unexpected error occurred:", error);
setIsError(true);
setMessage("An unexpected error occurred.");
}
};
// Render the form
return (
<form className="flex flex-col gap-4 text-gray-700" onSubmit={handleSubmit}>
<input
type="email"
name="email"
placeholder="Your Email"
required
className="border p-2 rounded"
/>
<select
name="frequency"
value={frequency}
onChange={handleFrequencyChange}
required
className="border p-2 rounded text-gray-700"
>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="custom">Custom Amount of Days</option>
</select>
{showCustomFrequency && (
<input
type="number"
name="customFrequency"
placeholder="Enter number of days"
min="1"
className="border p-2 rounded text-gray-700"
required
/>
)}
<button type="submit" className="bg-blue-500 text-white p-2 rounded">
Subscribe
</button>
{message && (
<p className={`mt-2 ${isError ? "text-red-500" : "text-green-500"}`}>
{message}
</p>
)}
</form>
);
}
購読解除フォームコンポーネント
UnsubscribeForm コンポーネントでは、ユーザーがメールアドレスを入力してニュースレターの購読を解除できます。フォーム送信時には、メールアドレスのデータとともに /api/unsubscribe へ POST リクエストが送信されます。また、メール内の購読解除リンクをクリックした場合には、メールアドレス欄が自動的に入力される仕組みになっています。
src/components/UnsubscribeForm.tsx
"use client";
import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
const UnsubscribeForm = () => {
const searchParams = useSearchParams();
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
const [isError, setIsError] = useState(false);
// Pre-fill email from query parameter
useEffect(() => {
const emailParam = searchParams.get("email");
if (emailParam) {
setEmail(emailParam);
}
}, [searchParams]);
// Handle form submission
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setMessage("");
setIsError(false);
try {
const response = await fetch("/api/unsubscribe", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.ok) {
setIsError(false);
setMessage("You have been unsubscribed successfully.");
} else {
setIsError(true);
setMessage(data.error || "Something went wrong. Please try again.");
}
} catch (error) {
console.error("Error unsubscribing:", error);
setIsError(true);
setMessage("An unexpected error occurred. Please try again.");
}
};
// Render the form
return (
<form className="flex flex-col gap-4 text-gray-700" onSubmit={handleSubmit}>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Your Email"
required
className="border p-2 rounded"
/>
<button
type="submit"
className="bg-red-500 hover:bg-red-700 text-white p-2 rounded"
>
Unsubscribe
</button>
{message && (
<p className={`mt-2 ${isError ? "text-red-500" : "text-green-500"}`}>
{message}
</p>
)}
</form>
);
};
export default function UnsubscribePage() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UnsubscribeForm />
</Suspense>
);
}
Redis へのデータ保存
ユーザーの購読データの保存には、Upstash Redis を使用します。
Upstash Redis を利用するには、まず Upstash Console 上で Redis データベースを作成し、REST URL とトークンを取得する必要があります。詳細については、Upstash の公式ドキュメントをご確認ください。
redis.ts には、Redis クライアントおよび Redis とやり取りするためのヘルパー関数を定義します。
src/lib/redis.ts
import { Redis } from "@upstash/redis";
export const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
export async function getUserFrequency(email: string): Promise<number | null> {
const data = await redis.get(`user:${email}`);
console.log("User data:", data);
if (!data) return null;
const parsed = JSON.parse(JSON.stringify(data));
return parsed.frequency;
}
export async function removeUser(email: string): Promise<void> {
await redis.del(`user:${email}`);
}
export async function checkSubscription(email: string): Promise<boolean> {
return (await getUserFrequency(email)) !== null;
}
メール送信関数
メールの送信には、以前の記事「QStash Python SDK による Email Scheduler の作成」で開発した独自のメール API を使用します。
src/lib/email.ts
export async function sendEmail(message: string, email: string) {
console.log(`Sending email to ${email}`);
const url = process.env.EMAIL_SERVICE_URL;
const payload = {
to_email: email,
subject: "Upstash Newsletter",
content: message,
};
if (!url) {
console.error("EMAIL_SERVICE_URL is not defined.");
return;
}
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
console.error("Failed to send email:", await response.text());
}
}
型定義
さらに、購読データ用の型定義も用意します。
src/types/index.ts
export type SubscriptionData = {
email: string;
frequency: string;
customFrequency?: string;
};
購読 API ルート
次に、購読リクエストを処理する API ルートを作成します。ユーザーが購読フォームを送信すると、このエンドポイントが既に購読済みかどうかをチェックし、ユーザーが選択した頻度に応じてメール送信を担当するワークフローをキューに追加します。
src/app/api/subscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { checkSubscription } from "@/lib/redis";
export const POST = async (request: NextRequest) => {
try {
const { email, frequency: freq, customFrequency } = await request.json();
console.log("Email:", email);
console.log("Frequency:", freq);
console.log("Custom Frequency:", customFrequency);
if (!email || !freq) {
console.error("Email and frequency are required.");
return NextResponse.json(
{ error: "Email and frequency are required." },
{ status: 400 }
);
}
let frequency = freq;
if (frequency === "custom") {
if (!customFrequency) {
console.error("Custom frequency days are required.");
return NextResponse.json(
{ error: "Custom frequency days are required." },
{ status: 400 }
);
}
frequency = customFrequency;
}
if (frequency === "daily") {
frequency = "1";
} else if (frequency === "weekly") {
frequency = "7";
} else if (frequency === "monthly") {
frequency = "30";
}
const frequencyNumber = Number(frequency);
if (isNaN(frequencyNumber) || frequencyNumber <= 0) {
console.error("Invalid frequency value.");
return NextResponse.json(
{ error: "Invalid frequency value." },
{ status: 400 }
);
}
const exists = await checkSubscription(email);
if (exists) {
console.error("Email is already subscribed.");
return NextResponse.json(
{ error: "Email is already subscribed." },
{ status: 400 }
);
}
console.log("Subscription successful!");
console.log("Enqueue the workflow");
// Enqueue the workflow
await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/workflow`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.QSTASH_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
frequency: frequencyNumber,
}),
})
.then((response) => {
if (!response.ok) {
console.error("Failed to enqueue workflow:", response.statusText);
return NextResponse.json(
{ error: "Failed to enqueue workflow." },
{ status: 500 }
);
} else {
console.log("Workflow enqueued successfully");
}
})
.catch((error) => {
console.error("Error enqueuing workflow:", error);
return NextResponse.json(
{ error: "Error enqueuing workflow." },
{ status: 500 }
);
});
return NextResponse.json({ message: "Subscription successful!" });
} catch (error) {
console.error("Error occurred:", error);
return NextResponse.json(
{ error: "An error occurred during subscription." },
{ status: 500 }
);
}
};
購読解除 API ルート
購読ルートがある以上、購読解除ルートも必要です。リクエストを受け取ると、ユーザーが購読中かどうかを確認し、Redis から該当データを削除します。同時に、購読解除の確認メールも送信します。
src/app/api/unsubscribe/route.ts
import { NextRequest, NextResponse } from "next/server";
import { redis } from "@/lib/redis";
import { sendEmail } from "@/lib/email";
export const POST = async (request: NextRequest) => {
try {
const { email } = await request.json();
if (!email) {
return NextResponse.json(
{ error: "Email is required." },
{ status: 400 }
);
}
const userExists = await redis.exists(`user:${email}`);
if (!userExists) {
return NextResponse.json(
{ error: "Email is not subscribed." },
{ status: 400 }
);
}
// Remove the user from Redis
await redis.del(`user:${email}`);
// Send an email to confirm unsubscription
await sendEmail(
"You have been unsubscribed from Upstash Newsletter.",
email
);
return NextResponse.json({ message: "You have been unsubscribed." });
} catch (error) {
console.error("Unsubscribe error:", error);
return NextResponse.json(
{ error: "An error occurred. Please try again." },
{ status: 500 }
);
}
};
ワークフロー API ルート
さて、ここからが本題です。指定された間隔でニュースレターを配信するワークフローを処理する API ルートを作成します。
このワークフローは以下の手順を実行します。
- ユーザーの購読データを Redis に保存する。
- ウェルカムメールを送信する。
- ループ処理に入る。
- 指定された頻度の期間だけ待機する。
- ユーザーがまだ購読中かどうかを確認する。
- ニュースレターのメールを送信する。
- 無限ループを避けるため、あらかじめ設定した回数分のニュースレター送信が完了するまで繰り返す。
src/app/api/workflow/route.ts
以下は、あるユーザーが購読登録を行い、ニュースレターを 1 回受信した後、購読を解除するまでの一連のワークフロー完了例です。

ワークフローには、Upstash Console からアクセスして監視することができます。
メインページコンポーネント
続いて、アプリケーションのメインページを設定しましょう。このページには購読フォームと、購読解除ページへのリンクを含めます。
src/app/page.tsx
import SubscriptionForm from "@/components/SubscriptionForm";
import Link from "next/link";
export default function Home() {
return (
<main className="flex flex-col items-center justify-center min-h-screen p-4">
<h1 className="text-3xl font-bold mb-6">
Subscribe to Upstash Newsletter
</h1>
{/* Subscription Form */}
<SubscriptionForm />
{/* Unsubscribe Link */}
<div className="mt-8">
<p className="text-gray-600">
Already subscribed and want to unsubscribe?
<Link
href="/unsubscribe"
className="text-red-500 hover:text-red-700 font-bold ml-2"
>
Click here to unsubscribe
</Link>
</p>
</div>
</main>
);
}
購読解除ページコンポーネント
最後に、購読解除ページを作成します。
src/app/unsubscribe/page.tsx
import UnsubscribePage from "@/components/UnsubscribeForm";
export default function UnsubscribeHome() {
return (
<main className="flex flex-col items-center justify-center min-h-screen p-4">
<h1 className="text-3xl font-bold mb-6">
Unsubscribe from Upstash Newsletter
</h1>
{/* Unsubscribe Form */}
<UnsubscribePage />
</main>
);
}
まとめ
以上で完成です!サーバーレス関数のタイムアウトを一切気にすることなく、シンプルなニュースレターアプリを構築できました。
このプロジェクトの完全なソースコードは GitHub で公開されており、実際のデモもご覧いただけます。
Upstash Workflow について詳しく知りたい方は、Upstash の公式ドキュメントをご参照ください。
ご質問がある場合は、Discord でお気軽にお問い合わせください。また、Upstash Blog では他にも多数のチュートリアルやユースケースを紹介していますので、ぜひチェックしてみてください。
-
Vercel EdgeとUpstash RedisでNext.jsにレート制限を実装する方法
Vercel Edge Middlewareと@upstash/ratelimitライブラリを活用して、Webアプリケーションにレート制限(レートリミット)を実装する方法を解説します。@upstash/ratelimitは、バックエンドでRedisを使用してレート制限データの保存・管理を行うライブラリです。 Vercel Edgeを使うメリット Vercel Edgeは、ユーザーに最も近いロケーションで計算処理を実行するコンピューティングプラットフォームです。本記事では、リクエストがバックエンドに到達する前に処理を割り込ませるVercel Edge Middlewareを使用します。レート制限
-
React Server Componentsで実装するリアルタイム閲覧数カウンターの作り方
このチュートリアルでは、React Server Components(RSC)の仕組みを理解した上で、その知識を活かしてアプリにシンプルな閲覧数カウンターを実装していきます。すぐに実装に取り掛かりたい方は、「プロジェクトのセットアップ」までスキップしてください。 React Server Componentsを理解する React Server Componentsがどのように動作するのかを理解するために、まずクライアントサイドレンダリング(CSR)とサーバーサイドレンダリング(SSR)について簡単におさらいしましょう。 クライアントサイドレンダリング(CSR) CSRでは、レンダリング処理