【実録】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改善ツール

'; // 既存コンテンツの後ろにフォームを追加 content.appendChild(form); // フォーム送信 document.getElementById('cf-form').addEventListener('submit', function(e) { e.preventDefault(); var btn = document.getElementById('cf-submit-btn'); var msg = document.getElementById('cf-message'); btn.disabled = true; btn.textContent = '\u9001\u4fe1\u4e2d...'; msg.textContent = ''; msg.className = 'cf-message'; var fd = new FormData(e.target); var data = { name: fd.get('name'), email: fd.get('email'), type: fd.get('type'), message: fd.get('message') }; fetch('/wp-json/custom/v1/contact', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data) }) .then(function(r) { return r.json().then(function(d) { return {ok: r.ok, data: d}; }); }) .then(function(res) { if (res.ok && res.data.success) { msg.textContent = res.data.message; msg.className = 'cf-message cf-success'; e.target.reset(); } else { msg.textContent = res.data.error || '\u9001\u4fe1\u306b\u5931\u6557\u3057\u307e\u3057\u305f'; msg.className = 'cf-message cf-error'; } btn.disabled = false; btn.textContent = '\u9001\u4fe1\u3059\u308b'; }) .catch(function() { msg.textContent = '\u901a\u4fe1\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f'; msg.className = 'cf-message cf-error'; btn.disabled = false; btn.textContent = '\u9001\u4fe1\u3059\u308b'; }); }); })();