🌟

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) からインポートして使用します。

設定:

  1. Slack Incoming Webhook: Slack で Incoming Webhook を作成し、Webhook URL を取得します。
  2. Supabase 環境変数: Supabase プロジェクトの Settings > Functions > slack-contact-notificationSLACK_WEBHOOK_URL という名前の Secret を設定し、取得した URL を値として保存します。
  3. Supabase Database Webhook: Supabase ダッシュボードの Database > Webhooks で新しい Webhook を作成します。
    • Name: Send Contact to Slack など分かりやすい名前をつけます。
    • Table: contacts を選択します。
    • Events: INSERT を選択します。
    • Trigger: After Insert を選択します。
    • Function: 作成した slack-contact-notification Function を選択します。

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 ヘッダーをレスポンスに追加します。これにより、クライアントは現在のレートリミット状況を把握できます。
  • successfalse の場合、HTTP 429 レスポンスを返します。
  • successtrue の場合、NextResponse.next() を返して API Route の処理に進みます。
  • エラーハンドリング: レートリミット処理中にエラーが発生した場合、リクエストをブロックせず通過させることで、KV の一時的な問題でユーザーが影響を受けるのを防ぎます。

設定:

  1. Vercel KV: Vercel ダッシュボードの Storage タブから Vercel KV データベースを作成し、プロジェクトに接続します。これにより、必要な環境変数 (KV_URL, KV_REST_API_URL, KV_REST_API_TOKEN, KV_REST_API_READ_ONLY_TOKEN) が自動的に設定されます。
  2. ライブラリインストール: 必要なライブラリをインストールします。
    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