Vercel + Next.js + Supabase + Vercel KV で作るコンタクトフォームと API レートリミット実装
この記事では、Next.js (App Router) で構築された Web アプリケーションに、コンタクトフォームを実装し、Supabase Functions を利用して Slack 通知を行い、さらに Vercel KV (Redis) を使って API エンドポイントにレートリミットを導入する手順を解説します。
対象読者:
- Next.js, Vercel, Supabase を使用している、またはこれから使用したい開発者
- Web アプリケーションにコンタクトフォームを実装したい方
- API の不正利用を防ぐためにレートリミットを導入したい方
使用技術スタック:
- フレームワーク: Next.js (App Router)
- UI: Shadcn UI, Tailwind CSS, Framer Motion
- バックエンド (BaaS): Supabase (Database, Functions)
- ホスティング: Vercel
- レートリミット: Vercel KV (Redis), @upstash/ratelimit
- 言語: TypeScript, Deno (for Supabase Functions)
1. コンタクトフォームのフロントエンド実装
まずは、ユーザーが情報を入力するためのコンタクトフォームを React コンポーネントとして作成します。ここでは Shadcn UI を利用してスタイリングし、Framer Motion で簡単なアニメーションを追加します。
components/contact-form.tsx
:
"use client"
import type React from "react"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { X, Terminal } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
interface ContactFormProps {
onClose: () => void
email: string // Optional: Display contact email
}
export default function ContactForm({ onClose, email }: ContactFormProps) {
const [formState, setFormState] = useState({ name: "", email: "", message: "" })
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormState((prev) => ({ ...prev, [name]: value }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsSubmitting(true)
setError(null)
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formState),
});
// Check for rate limiting response first
if (response.status === 429) {
throw new Error('Too many requests. Please try again later.');
}
const result = await response.json();
if (!response.ok) {
// Use error message from API if available
throw new Error(result.error || 'Failed to send message.');
}
setIsSubmitted(true)
setTimeout(() => {
onClose()
// Reset form state if needed after closing
setIsSubmitted(false);
setFormState({ name: "", email: "", message: "" });
}, 3000) // Close after 3 seconds
} catch (err) {
console.error('Submission error:', err);
setError(err instanceof Error ? err.message : 'An unexpected error occurred.');
} finally {
setIsSubmitting(false)
}
}
// Full JSX for the form, modal, error/success messages:
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4"
onClick={onClose} // Close modal on background click
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", damping: 25, stiffness: 300 }}
className="relative w-full max-w-md rounded-xl bg-black p-6 shadow-2xl ring-1 ring-white/10"
onClick={(e) => e.stopPropagation()} // Prevent closing modal when clicking inside
>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4 text-gray-400 hover:text-white"
onClick={onClose}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
<div className="mb-6">
<h2 className="text-2xl font-bold">Get in touch</h2>
<p className="text-gray-400">We'd love to hear from you</p>
</div>
{/* Error message display */}
{error && (
<Alert variant="destructive" className="mb-4">
<Terminal className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{!isSubmitted ? (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name Input */}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name" name="name" placeholder="Your name"
value={formState.name} onChange={handleChange} required
className="border-gray-800 bg-gray-950 focus-visible:ring-primary"
disabled={isSubmitting}
/>
</div>
{/* Email Input */}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email" name="email" type="email" placeholder="Your email"
value={formState.email} onChange={handleChange} required
className="border-gray-800 bg-gray-950 focus-visible:ring-primary"
disabled={isSubmitting}
/>
</div>
{/* Message Textarea */}
<div className="space-y-2">
<Label htmlFor="message">Message</Label>
<Textarea
id="message" name="message" placeholder="Your message"
value={formState.message} onChange={handleChange} required
className="min-h-[120px] border-gray-800 bg-gray-950 focus-visible:ring-primary"
disabled={isSubmitting}
/>
</div>
{/* Submit Button */}
<div className="pt-2">
<Button type="submit" className="w-full" disabled={isSubmitting}>
{isSubmitting ? "Sending..." : "Send Message"}
</Button>
</div>
{/* Optional: Direct email link */}
{email && (
<div className="text-center text-sm text-gray-400">
Or email us directly at{" "}
<a href={`mailto:${email}`} className="text-primary hover:underline">
{email}
</a>
</div>
)}
</form>
) : (
// Success Message
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center space-y-4 py-8 text-center"
>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<svg className="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 className="text-xl font-medium">Message Sent!</h3>
<p className="text-gray-400">Thank you for reaching out. We'll get back to you soon.</p>
</motion.div>
)}
</motion.div>
</motion.div>
</AnimatePresence>
)
}
ポイント:
-
useState
でフォームの入力値 (formState
)、送信中の状態 (isSubmitting
)、送信完了状態 (isSubmitted
)、エラーメッセージ (error
) を管理します。 -
handleSubmit
関数でフォーム送信イベントを処理します。 -
fetch
API を使用して/api/contact
エンドポイントに POST リクエストを送信します。 -
レートリミット対応: レスポンスステータスが
429
の場合、専用のエラーメッセージを表示します。 - レスポンスのステータスコード (
response.ok
) を確認し、エラーがあれば API からのエラーメッセージ (result.error
) を優先して表示します。 - 送信成功時には成功メッセージを表示し、一定時間後にフォームを閉じ (
onClose
)、フォームの状態をリセットします。 - 送信中はボタンを無効化し、「Sending...」のようなフィードバックを表示します。
2. API Route の実装
次に、フロントエンドから送信されたデータを受け取り、Supabase データベースに保存するための API Route を作成します。
app/api/contact/route.ts
:
import { supabase } from '@/lib/supabaseClient'; // Supabase client instance
import { NextResponse } from 'next/server';
// Optional: Use Zod for robust validation
// import { z } from 'zod';
// const contactSchema = z.object({
// name: z.string().min(1, { message: "Name is required." }),
// email: z.string().email({ message: "Invalid email format." }),
// message: z.string().min(1, { message: "Message is required." }),
// });
export async function POST(request: Request) {
try {
const body = await request.json();
// --- Basic Validation (Replace with Zod for better validation) ---
const { name, email, message } = body;
if (!name || !email || !message) {
return NextResponse.json({ error: 'Name, email, and message are required.' }, { status: 400 });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json({ error: 'Invalid email format.' }, { status: 400 });
}
// --- End Basic Validation ---
/* --- Zod Validation Example ---
const parseResult = contactSchema.safeParse(body);
if (!parseResult.success) {
// Combine errors into a single message or return structured errors
const errors = parseResult.error.errors.map(e => e.message).join(', ');
return NextResponse.json({ error: errors }, { status: 400 });
}
const { name, email, message } = parseResult.data;
*/
// Insert data into Supabase 'contacts' table
const { data, error: supabaseError } = await supabase
.from('contacts') // Ensure this table exists in your Supabase project
.insert([{ name, email, message }])
.select(); // Optionally return the inserted data
if (supabaseError) {
console.error('Supabase error:', supabaseError);
// Provide a generic error message to the client
return NextResponse.json({ error: 'Failed to save contact information. Please try again later.' }, { status: 500 });
}
// Note: Slack notification is handled by Supabase DB Webhook in this setup.
// If not using webhooks, trigger notification logic here.
return NextResponse.json({ message: 'Contact information saved successfully.', data }, { status: 201 });
} catch (error) {
console.error('API error:', error);
// Handle JSON parsing errors or other unexpected errors
const errorMessage = error instanceof Error ? error.message : 'An unexpected error occurred';
return NextResponse.json({ error: 'Internal Server Error', details: errorMessage }, { status: 500 });
}
}
ポイント:
-
request.json()
でリクエストボディからデータを取得します。 - バリデーション: 基本的なチェックを行いますが、本番環境では Zod などのライブラリを使用してより堅牢なバリデーションを行うことを強く推奨します(コメントアウトされた Zod の例を参照)。
-
supabase.from('contacts').insert(...)
を使用して、Supabase のcontacts
テーブルにデータを挿入します。(事前に Supabase プロジェクトでcontacts
テーブルを作成し、RLS ポリシーを設定しておく必要があります) - エラーハンドリングを行い、クライアントには具体的なエラー詳細ではなく、一般的なエラーメッセージを返すようにします。
- 注意: この実装では、Slack 通知は後述する Supabase Database Webhook で行います。
3. Supabase Functions による Slack 通知
コンタクトフォームから新しい問い合わせがあった際に、Slack に通知を送信する機能を Supabase Functions (Edge Function) で実装します。この Function は、Supabase Database Webhook によって contacts
テーブルに新しいレコードが挿入されたときに自動的にトリガーされます。
supabase/functions/_shared/cors.ts
(共有 CORS ヘッダー):
export const corsHeaders = {
'Access-Control-Allow-Origin': '*', // Adjust for production environments
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
};
注意: Access-Control-Allow-Origin
は本番環境では特定のオリジンに制限してください。
supabase/functions/slack-contact-notification/index.ts
:
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts';
import { corsHeaders } from '../_shared/cors.ts'; // Shared CORS headers
// Get Slack Webhook URL from environment variables
const SLACK_WEBHOOK_URL = Deno.env.get('SLACK_WEBHOOK_URL');
if (!SLACK_WEBHOOK_URL) {
console.error('CRITICAL: SLACK_WEBHOOK_URL environment variable is not set. Slack notifications will fail.');
}
serve(async (req: Request) => {
// Handle CORS preflight requests
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
try {
// Get data sent from the Supabase Database Webhook
const payload = await req.json();
// console.log('Received payload:', JSON.stringify(payload, null, 2)); // Log for debugging
// Ensure it's an INSERT event for the 'contacts' table
if (payload.type !== 'INSERT' || payload.table !== 'contacts') {
console.log(`Skipping event: type=${payload.type}, table=${payload.table}`);
return new Response(JSON.stringify({ message: 'Skipping non-insert event for contacts' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200,
});
}
const newContact = payload.record;
// Validate received data (essential fields)
if (!newContact || !newContact.name || !newContact.email || !newContact.message) {
console.error('Invalid contact data received in webhook:', newContact);
// Return 200 OK to Supabase webhook to prevent retries for bad data
return new Response(JSON.stringify({ error: 'Invalid contact data received.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200, // Acknowledge receipt
});
}
// Create the Slack message
const slackMessage = {
text: `🔔 New Contact Form Submission!\n*Name:* ${newContact.name}\n*Email:* ${newContact.email}\n*Message:*\n${newContact.message}`,
// Use Block Kit for better formatting: https://api.slack.com/block-kit
// blocks: [ ... ]
};
// Send notification only if Webhook URL is set
if (SLACK_WEBHOOK_URL) {
const slackResponse = await fetch(SLACK_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(slackMessage),
});
if (!slackResponse.ok) {
const errorBody = await slackResponse.text();
console.error(`Failed to send Slack notification: ${slackResponse.status} ${slackResponse.statusText}`, errorBody);
// Return 500 to potentially trigger retries if configured, or just log
return new Response(JSON.stringify({ error: 'Failed to send Slack notification.', details: errorBody }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 502, // Bad Gateway might be appropriate
});
}
console.log('Slack notification sent successfully for contact ID:', newContact.id); // Log success
} else {
console.warn('SLACK_WEBHOOK_URL is not set. Skipping Slack notification for contact ID:', newContact.id);
}
// Acknowledge successful processing of the webhook
return new Response(JSON.stringify({ message: 'Webhook processed successfully.' }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 200,
});
} catch (error) {
console.error('Error processing webhook:', error);
// Return 500 for unexpected errors
return new Response(JSON.stringify({ error: 'Internal Server Error', details: error.message }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' }, status: 500,
});
}
});
ポイント:
- Deno ランタイムで動作する Edge Function です。
-
Deno.env.get('SLACK_WEBHOOK_URL')
で環境変数から Slack の Incoming Webhook URL を取得します。設定されていない場合はエラーログを出力します。 - Supabase Database Webhook から送られてくるペイロード (
payload
) を解析します。 -
payload.type === 'INSERT'
かつpayload.table === 'contacts'
であることを確認し、それ以外のイベントは無視します。 -
payload.record
に含まれる新しい連絡先情報を使って Slack メッセージを作成します。 -
fetch
API を使って Slack Webhook URL に POST リクエストを送信します。 -
エラーハンドリング: Slack への送信失敗時や予期せぬエラー発生時に適切なログを出力し、ステータスコードを返します。不正なデータの場合は
200
を返し、Supabase 側でのリトライを防ぎます。 - CORS ヘッダーは共有ファイル (
../_shared/cors.ts
) からインポートして使用します。
設定:
- Slack Incoming Webhook: Slack で Incoming Webhook を作成し、Webhook URL を取得します。
-
Supabase 環境変数: Supabase プロジェクトの Settings > Functions >
slack-contact-notification
でSLACK_WEBHOOK_URL
という名前の Secret を設定し、取得した URL を値として保存します。 -
Supabase Database Webhook: Supabase ダッシュボードの Database > Webhooks で新しい Webhook を作成します。
-
Name:
Send Contact to Slack
など分かりやすい名前をつけます。 -
Table:
contacts
を選択します。 -
Events:
INSERT
を選択します。 -
Trigger:
After Insert
を選択します。 -
Function: 作成した
slack-contact-notification
Function を選択します。
-
Name:
4. Vercel KV (Redis) による API レートリミット
コンタクトフォーム API (/api/contact
) が短時間に大量のリクエストを受け付けないように、Next.js Middleware と Vercel KV (Upstash Redis を利用) を使ってレートリミットを実装します。
middleware.ts
:
import { NextRequest, NextResponse } from 'next/server'
import { Ratelimit } from '@upstash/ratelimit'
// Use @vercel/kv for seamless integration with Vercel KV
import { kv } from '@vercel/kv'
// Initialize the rate limiter using @vercel/kv
// Example: Allow 5 requests per 60 seconds from the same IP address
const ratelimit = new Ratelimit({
redis: kv, // Use the Vercel KV instance
limiter: Ratelimit.slidingWindow(5, '60 s'), // 5 requests per 60 seconds
analytics: true, // Enable analytics on Vercel KV dashboard (optional)
prefix: process.env.RATELIMIT_PREFIX || 'contact_form_ratelimit', // Optional prefix for Redis keys
});
// Define which paths this middleware should apply to
export const config = {
// Apply middleware only to the POST method of /api/contact
matcher: '/api/contact',
}
export default async function middleware(request: NextRequest) {
// Only apply rate limiting to POST requests
if (request.method !== 'POST') {
return NextResponse.next()
}
// Use IP address as the identifier for rate limiting
// Vercel automatically provides the correct IP via 'x-forwarded-for' in production
// Fallback to request.ip or a default for local development
const identifier = request.headers.get('x-forwarded-for') ?? request.ip ?? '127.0.0.1'
try {
// Check if the request exceeds the rate limit for the identifier
const { success, limit, remaining, reset } = await ratelimit.limit(identifier)
// Create a base response object to modify headers
const response = NextResponse.next()
// Set rate limit headers on the response (useful for clients)
response.headers.set('X-RateLimit-Limit', limit.toString())
response.headers.set('X-RateLimit-Remaining', remaining.toString())
response.headers.set('X-RateLimit-Reset', reset.toString()) // Reset time in Unix timestamp (seconds)
// If the limit is exceeded, return a 429 Too Many Requests response
if (!success) {
console.warn(`Rate limit exceeded for identifier: ${identifier}`);
// Return a JSON response for consistency, or just text
return new NextResponse(
JSON.stringify({ error: 'Too Many Requests', message: 'Rate limit exceeded. Please try again later.' }),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': remaining.toString(),
'X-RateLimit-Reset': reset.toString(),
},
}
);
}
// If within the limit, allow the request to proceed and include rate limit headers
return response;
} catch (error) {
console.error("Error in rate limiting middleware:", error);
// In case of an error during rate limiting (e.g., KV connection issue),
// allow the request to proceed to avoid blocking legitimate users.
// Consider adding monitoring for such errors.
return NextResponse.next();
}
}
ポイント:
-
@upstash/ratelimit
と@vercel/kv
ライブラリを使用します。@vercel/kv
は Vercel KV との連携を簡素化します。 -
kv
インスタンスを直接Ratelimit
に渡します。Vercel KV の接続情報は Vercel 環境で自動的に利用可能になります。 -
Ratelimit
インスタンスを作成し、limiter
オプションでレートリミットのルール(例:Ratelimit.slidingWindow(5, '60 s')
で 60 秒間に 5 リクエストまで)を設定します。 -
config.matcher
でミドルウェアが適用されるパスを指定します。 - リクエストヘッダーの
x-forwarded-for
(Vercel 環境) またはrequest.ip
から IP アドレスを取得し、レートリミットの識別子 (identifier
) として使用します。 -
ratelimit.limit(identifier)
を呼び出して制限をチェックします。 -
レスポンスヘッダー: 制限を超えていなくても、
X-RateLimit-Limit
,X-RateLimit-Remaining
,X-RateLimit-Reset
ヘッダーをレスポンスに追加します。これにより、クライアントは現在のレートリミット状況を把握できます。 -
success
がfalse
の場合、HTTP 429 レスポンスを返します。 -
success
がtrue
の場合、NextResponse.next()
を返して API Route の処理に進みます。 - エラーハンドリング: レートリミット処理中にエラーが発生した場合、リクエストをブロックせず通過させることで、KV の一時的な問題でユーザーが影響を受けるのを防ぎます。
設定:
-
Vercel KV: Vercel ダッシュボードの Storage タブから Vercel KV データベースを作成し、プロジェクトに接続します。これにより、必要な環境変数 (
KV_URL
,KV_REST_API_URL
,KV_REST_API_TOKEN
,KV_REST_API_READ_ONLY_TOKEN
) が自動的に設定されます。 -
ライブラリインストール: 必要なライブラリをインストールします。
npm install @upstash/ratelimit @vercel/kv
5. Vercel へのデプロイと環境変数
アプリケーションを Vercel にデプロイする際には、以下の環境変数を Vercel プロジェクトの Settings > Environment Variables で設定する必要があります。
-
Supabase Client (Next.js アプリケーション用):
-
NEXT_PUBLIC_SUPABASE_URL
: Supabase プロジェクトの URL (Public) -
NEXT_PUBLIC_SUPABASE_ANON_KEY
: Supabase プロジェクトの Anon Key (Public)
-
-
Vercel KV (レートリミット用):
-
KV_URL
,KV_REST_API_URL
,KV_REST_API_TOKEN
,KV_REST_API_READ_ONLY_TOKEN
: Vercel KV 接続時に自動設定されます。
-
-
その他 (オプション):
-
RATELIMIT_PREFIX
: (オプション) レートリミット用の Redis キープレフィックス。
-
Supabase Functions 用の Secret:
-
Slack 通知用:
-
SLACK_WEBHOOK_URL
: Slack Incoming Webhook の URL。これは Supabase ダッシュボード (Project Settings > Functions >slack-contact-notification
Function > Secrets) で設定します。Vercel の環境変数ではありません。
-
これらの環境変数と Secret を正しく設定した後、通常通り Vercel にデプロイします。
まとめ
このガイドでは、Next.js アプリケーションにコンタクトフォームを追加し、Supabase と Vercel の機能を活用して Slack 通知と API レートリミットを実装する方法を解説しました。
- フロントエンド: Shadcn UI と React でインタラクティブなフォームを作成し、レートリミットエラーもハンドリング。
- バックエンド API: Next.js API Route でデータを受け取り、バリデーション後に Supabase に保存。
-
通知: Supabase Functions と Database Webhook で
contacts
テーブルへの挿入をトリガーに、リアルタイムな Slack 通知を実現。 -
セキュリティ: Next.js Middleware と Vercel KV で
/api/contact
エンドポイントへの POST リクエストに IP ベースのレートリミットを適用。
これらの技術を組み合わせることで、モダンでスケーラブル、かつ安全な Web アプリケーション機能を構築できます。さらなる改善点としては、より詳細なエラーハンドリング、Zod を利用した厳密な入力バリデーション、エンドツーエンドテストの追加などが考えられます。
Discussion