【実録】Next.js + SupabaseでAI求人ブラッシュアップSaaSを作った話

なぜこのSaaSを作ったか

求人広告の運用をしていた時期に、常に感じていた課題がありました。求人原稿の品質が応募数に直結するのに、書く側(企業の採用担当者)は求人のプロではありません。「業務内容」「募集背景」「職場環境」の書き方1つで応募率が2〜3倍変わることを肌で知っていたので、「AIに求人原稿を改善させるツールがあれば売れる」と確信しました。

週末と夜の時間を使って約2ヶ月で完成させた「求人ブラッシュアップSaaS」の開発記録をまとめます。

技術スタック

役割 採用技術 理由
フロントエンド Next.js 14 (App Router) Vercelへのデプロイが最速
バックエンド/DB Supabase 認証・DB・ストレージが一括で揃う
AIエンジン OpenAI API (GPT-4o) 文章品質が高く、日本語も安定
UI Tailwind CSS + shadcn/ui 実装速度重視
デプロイ Vercel Next.jsと相性最良

アーキテクチャ概要

ユーザー(ブラウザ)
    ↓ ログイン(Supabase Auth)
Next.js App Router
    ↓ 求人原稿を入力
Server Actions
    ↓ OpenAI API呼び出し
GPT-4o
    ↓ 改善された原稿を返却
Supabase(改善履歴を保存)
    ↓
ユーザーに結果表示

Supabaseのセットアップ

まずSupabaseでプロジェクトを作成し、テーブルを定義します。

-- 求人改善履歴テーブル
create table job_improvements (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users(id) on delete cascade,
  original_text text not null,
  improved_text text not null,
  improvement_points text[],
  created_at timestamptz default now()
);

-- RLS(Row Level Security)を有効化
alter table job_improvements enable row level security;

-- 自分のデータだけ読み書き可能にする
create policy "Users can manage own records"
  on job_improvements
  for all
  using (auth.uid() = user_id);

Next.jsでの実装

Server Actionsで求人改善APIを呼び出す

// app/actions/improve-job.ts
"use server";

import { createServerClient } from "@/lib/supabase/server";
import OpenAI from "openai";

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

const SYSTEM_PROMPT = `あなたは求人広告の専門家です。
入力された求人原稿を分析し、以下の観点で改善した文章を出力してください。
- 応募者の視点で魅力的に書かれているか
- 具体的な数字・事例が含まれているか
- 職場の雰囲気・カルチャーが伝わるか
- 応募へのハードルが適切に設定されているか

改善後の文章と、改善したポイントをJSON形式で返してください。`;

export async function improveJobPosting(originalText: string) {
  const supabase = createServerClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) throw new Error("認証が必要です");

  const completion = await openai.chat.completions.create({
    model: "gpt-4o",
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      { role: "user", content: originalText },
    ],
    response_format: { type: "json_object" },
  });

  const result = JSON.parse(completion.choices[0].message.content ?? "{}");

  // Supabaseに保存
  await supabase.from("job_improvements").insert({
    user_id: user.id,
    original_text: originalText,
    improved_text: result.improved_text,
    improvement_points: result.improvement_points,
  });

  return result;
}

入力フォームのコンポーネント

// app/improve/page.tsx
"use client";

import { useState } from "react";
import { improveJobPosting } from "@/app/actions/improve-job";

export default function ImprovePage() {
  const [original, setOriginal] = useState("");
  const [result, setResult] = useState<{ improved_text: string; improvement_points: string[] } | null>(null);
  const [loading, setLoading] = useState(false);

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    setLoading(true);
    try {
      const data = await improveJobPosting(original);
      setResult(data);
    } catch (err) {
      console.error(err);
    } finally {
      setLoading(false);
    }
  }

  return (
    <main className="max-w-3xl mx-auto p-6">
      <h1 className="text-2xl font-bold mb-6">求人原稿 AI改善ツール</h1>
      <form onSubmit={handleSubmit}>
        <textarea
          className="w-full h-48 border rounded p-3"
          placeholder="改善したい求人原稿を貼り付けてください"
          value={original}
          onChange={(e) => setOriginal(e.target.value)}
        />
        <button
          type="submit"
          disabled={loading || !original}
          className="mt-3 bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
        >
          {loading ? "改善中..." : "AIで改善する"}
        </button>
      </form>

      {result && (
        <div className="mt-8">
          <h2 className="text-xl font-semibold mb-3">改善後の原稿</h2>
          <div className="bg-gray-50 border rounded p-4 whitespace-pre-wrap">
            {result.improved_text}
          </div>
          <h3 className="text-lg font-semibold mt-6 mb-2">改善ポイント</h3>
          <ul className="list-disc list-inside space-y-1">
            {result.improvement_points.map((point, i) => (
              <li key={i}>{point}</li>
            ))}
          </ul>
        </div>
      )}
    </main>
  );
}

ハマったポイントと解決策

SupabaseのRLSが効かない

Server Actionsから呼び出す場合、createServerClientを使わないとRLSが正しく機能しないことがありました。クライアントサイドのSupabaseインスタンスとサーバーサイドのインスタンスは明確に分ける必要があります。

GPT-4oのレスポンス形式

response_format: { type: "json_object" }を指定しても、JSONのキー名が安定しないことがありました。システムプロンプトに「必ず以下のキーを使ったJSONで返してください」と明示することで解決しました。

Vercelのタイムアウト

無料プランだとServer Actionsのタイムアウトが10秒のため、長い原稿でGPT-4oが時間を使いすぎると失敗しました。入力文字数の上限を設けるか、Pro planへの移行が必要です。

開発して気づいたこと

求人広告業界の知識がそのままプロダクトの強みになりました。「どういう観点で改善すべきか」というドメイン知識をプロンプトに組み込んだことで、汎用的なGPTラッパーとの差別化ができました。

個人開発でSaaSを作るなら、「自分が実業務で困っていた課題」から着手するのが一番早いと実感しています。

まとめ

Next.js + Supabase + OpenAI APIの組み合わせは、個人開発でAI SaaSを作る際の現時点でのベストプラクティスだと感じています。

  • Supabase: 認証・DB・RLSが数分で揃う
  • Next.js Server Actions: API Routeを書かずにサーバー処理が実装できる
  • OpenAI API: response_format: json_objectでJSON出力を安定させる

ドメイン知識をプロンプトに落とし込むことが、AIプロダクトの本質的な価値になります。