【実録】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プロダクトの本質的な価値になります。