【実録】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 (
求人原稿 AI改善ツール
{result && (
改善後の原稿
{result.improved_text}
改善ポイント
{result.improvement_points.map((point, i) => (
- {point}
))}
)}
);
}
ハマったポイントと解決策
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を作る際の現時点でのベストプラクティスだと感じています。
ドメイン知識をプロンプトに落とし込むことが、AIプロダクトの本質的な価値になります。