はじめに
個人ポートフォリオサイトの認証システムを NextAuth.js → Clerk に移行し、
さらに Gemini APIが死んでも、自宅PCのローカルLLM(LM Studio)が自動でブログ記事を書いてくれる 仕組みを構築しました。
最終的には、Windows Task Scheduler で毎朝8時に自動実行されるようにして、
完全なる「寝てる間にブログが更新される」システムが完成しました。
この記事では、その全体像と具体的な実装を解説します。
🎯 今回やったこと(全体像)
┌─────────────────────────────────────────────────────┐
│ Before │
│ 認証: NextAuth.js (Google OAuth 設定地獄) │
│ 記事: Gemini API のみ (無料枠尽きたら終了) │
│ 運用: 手動 │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ After │
│ 認証: Clerk (OAuth設定ほぼゼロ) │
│ 記事: Gemini → LM Studio フォールバック │
│ 運用: Task Scheduler で毎朝8時に自動生成 │
└─────────────────────────────────────────────────────┘
1️⃣ NextAuth → Clerk 移行
なぜ移行したか
NextAuth.jsでGoogle OAuthを設定していましたが、redirect_uri_mismatch エラーが頻発。
GCPコンソールでRedirect URIを正しく設定しても、環境(localhost / Vercel)ごとに挙動が違い、デバッグに時間を取られていました。
Clerkなら、ダッシュボードでGoogleを有効にするだけ。 Redirect URIの設定すら不要です。
変更したファイル
| ファイル | 変更内容 |
|---|---|
src/proxy.ts |
✨ clerkMiddleware でルート保護 |
src/middleware.ts |
🗑️ 旧 NextAuth ミドルウェア削除 |
src/components/Providers.tsx |
SessionProvider → ClerkProvider
|
src/app/login/page.tsx |
Clerk <SignIn> コンポーネント |
src/app/admin/page.tsx |
useUser() + <UserButton />
|
src/app/api/admin/*/route.ts |
auth() + clerkClient()
|
src/app/api/auth/[...nextauth]/ |
🗑️ 削除 |
ミドルウェアの設計ポイント
最初の失敗として、ミドルウェアで管理者メールチェックもやろうとしました。
しかし Clerkの sessionClaims にはデフォルトでメールが含まれません。
// ❌ NG: sessionClaims.email は undefined
export default clerkMiddleware(async (auth, req) => {
const { sessionClaims } = await auth();
const email = (sessionClaims as any)?.email; // → undefined!
});
正解は「ミドルウェアは認証のみ、管理者チェックはページ/API側」 です:
// ✅ proxy.ts - 認証チェックのみ
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
const isProtectedRoute = createRouteMatcher(["/members(.*)", "/admin(.*)"]);
export default clerkMiddleware(async (auth, req) => {
if (isProtectedRoute(req)) {
const { userId } = await auth();
if (!userId) {
return NextResponse.redirect(new URL("/login", req.url));
}
}
});
// ✅ admin/page.tsx - ページ側で管理者チェック
const { user } = useUser();
const userEmail = user?.emailAddresses?.[0]?.emailAddress || "";
const adminEmail = process.env.NEXT_PUBLIC_ADMIN_EMAIL || "";
if (adminEmail && userEmail !== adminEmail) {
return <AccessDenied />;
}
APIルートの認証
// 旧: NextAuth
import { getServerSession } from "next-auth";
const session = await getServerSession(authOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// 新: Clerk
import { auth, clerkClient } from "@clerk/nextjs/server";
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
// 管理者チェック(APIルート側)
const client = await clerkClient();
const user = await client.users.getUser(userId);
const email = user.emailAddresses?.[0]?.emailAddress || "";
if (email !== process.env.ADMIN_EMAIL) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
必要な環境変数
# Clerk(Vercel + .env.local)
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxxxx
CLERK_SECRET_KEY=sk_test_xxxxx
# 管理者メール(Vercel + .env.local)
ADMIN_EMAIL=your-admin@example.com
NEXT_PUBLIC_ADMIN_EMAIL=your-admin@example.com
⚠️ APIキーは必ず
.env.localに置き、.gitignoreで除外してください。
2️⃣ ローカルLLMフォールバック
問題:Gemini無料枠が枯渇
gemini-2.0-flash → gemini-2.0-flash-lite の順に試しましたが、
どちらも 429 Too Many Requests で全滅。無料枠のリミットに到達していました。
解決:LM Studio をフォールバック先に
自宅PCで LM Studio を起動し、
gemma-3-4b(Google製 4Bモデル)をフォールバック先にしました。
article-generator.ts のフォールバックチェーン:
① Gemini API (gemini-2.0-flash-lite)
↓ 429エラー or タイムアウト
② LM Studio (localhost:1234)
↓ 接続不可(Vercel上では到達不能)
③ エラーレポート
実装(フォールバック部分)
// article-generator.ts
let responseContent = '';
let usedProvider = '';
// Strategy 1: Gemini API
try {
responseContent = await generateWithGemini(prompt);
usedProvider = 'Gemini';
} catch (e) {
console.warn(`⚠️ Gemini failed: ${e.message}`);
}
// Strategy 2: Local LM Studio (fallback)
if (!responseContent) {
try {
responseContent = await generateWithLocalLLM(prompt);
usedProvider = 'Local LLM';
} catch (e) {
console.warn(`⚠️ Local LLM failed: ${e.message}`);
}
}
if (!responseContent) {
throw new Error('All AI providers failed.');
}
ローカルLLM呼び出し
LM StudioはOpenAI互換APIを提供するので、fetch で簡単に呼べます:
async function generateWithLocalLLM(prompt: string): Promise<string> {
const url = process.env.LM_STUDIO_URL || "http://127.0.0.1:1234";
const model = process.env.LM_STUDIO_MODEL || "google/gemma-3-4b";
const response = await fetch(`${url}/v1/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [
{ role: "system", content: "..." },
{ role: "user", content: prompt }
],
temperature: 0.7,
max_tokens: 4096,
}),
});
const data = await response.json();
return data.choices?.[0]?.message?.content || "";
}
3️⃣ 3ステップ生成方式(小さなモデル向け)
4Bモデルに「タイトル、本文、翻訳、タグを全部JSONで返して」と言うと、
JSONが壊れたり、途中で切れたりします。
そこで 3ステップに分割 しました:
Step 1: 日本語の記事本文を生成(Markdown出力のみ)
Step 2: タイトル・要約・タグを生成(構造化テキスト)
Step 3: 英語翻訳を生成(Markdown出力のみ)
各ステップのプロンプトは 短くシンプル にし、4Bモデルでも安定して出力できるようにしています。
// Step 1: 本文のみ(JSON不要)
const contentJa = await callLMStudio(
"あなたは技術ブログの執筆者です。日本語でMarkdown記事を書いてください。",
`テーマ: ${theme}\n\n記事本文だけを書いてください。`,
3000
);
// Step 2: メタデータ(行ベースのパース)
const metaResponse = await callLMStudio(
"ブログ記事のタイトルと要約を考えてください。",
`以下のフォーマットで回答:\nTITLE_JA: ...\nEXCERPT_JA: ...\nTAGS: ...`,
500
);
// Step 3: 翻訳
const contentEn = await callLMStudio(
"Translate the following to English.",
contentJa,
3000
);
ポイント: JSON出力を要求しない。行ベースの構造化テキストなら、パースが遥かに安定する。
4️⃣ Task Scheduler で完全自動化
最後のピースは Windows Task Scheduler です。
バッチファイル
@echo off
chcp 65001 >nul
:: ログ設定
set LOGFILE=%~dp0logs\generate_%date:~0,4%%date:~5,2%%date:~8,2%.log
if not exist "%~dp0logs" mkdir "%~dp0logs"
:: LM Studio の起動チェック
curl -s http://127.0.0.1:1234/v1/models >nul 2>&1
if %errorlevel% neq 0 (
echo LM Studio が起動していません >> "%LOGFILE%"
exit /b 1
)
:: 記事生成
cd /d "C:\path\to\your\next_app"
call npx tsx scripts/generate-article-local.ts >> "%LOGFILE%" 2>&1
タスク登録(1コマンド)
schtasks /create /tn "DailyArticleGenerator" /tr "C:\path\to\scripts\daily-generate.bat" /sc daily /st 08:00 /f
これで 毎朝8時 に自動実行されます。
前提条件
| 条件 | 詳細 |
|---|---|
| LM Studio | PC起動時に自動起動設定推奨 |
| PC | スリープしていないこと |
| DB接続 |
.env.local にNeon DBのURLが設定済み |
🏗️ 最終アーキテクチャ
┌──────────────────────────────────────────────────────┐
│ Vercel (クラウド) │
│ │
│ Next.js App Router │
│ ├─ 認証: Clerk (proxy.ts) │
│ ├─ 管理画面: /admin (メールベースの管理者チェック) │
│ ├─ API: /api/admin/* (auth() + clerkClient()) │
│ └─ 記事生成: Gemini API → (LM Studio fallback) │
│ │
│ Neon PostgreSQL ←──── Prisma ORM │
└──────────────────────────────────────────────────────┘
↑ DB書き込み
┌──────────────────────────────────────────────────────┐
│ ローカルPC (自宅) │
│ │
│ Windows Task Scheduler │
│ └─ 毎朝 8:00 │
│ └─ daily-generate.bat │
│ └─ npx tsx generate-article-local.ts │
│ └─ LM Studio (gemma-3-4b) │
│ ├─ Step 1: 日本語記事生成 │
│ ├─ Step 2: メタデータ生成 │
│ └─ Step 3: 英語翻訳 │
│ └─ Prisma → Neon DB に保存 │
└──────────────────────────────────────────────────────┘
💡 学んだこと
1. 認証は「マネージドサービス」が正義
NextAuth.jsは柔軟だけど、OAuthの設定が面倒。Clerkなら 5分で完了。
個人開発では「設定の少なさ」が正義です。
2. 無料APIは「いつか死ぬ」前提で設計する
Geminiの無料枠はある日突然尽きました。
ローカルLLMというフォールバックがあれば、コスト0でも運用が止まらない。
3. 小さなモデルは「小さなタスク」で活かす
4Bモデルに複雑なJSON生成を求めるのは酷。
タスクを分割すれば、小さなモデルでも十分に実用的な出力が得られます。
4. 自動化の最後のピースは「スケジューラ」
スクリプトを書いただけでは自動化とは呼べない。
Task Scheduler(Linux なら cron)で定期実行して初めて「完全自動化」です。
🎉 まとめ
| 項目 | Before | After |
|---|---|---|
| 認証 | NextAuth.js | Clerk |
| OAuth設定 | GCPコンソールで手動設定 | Clerkダッシュボードのみ |
| 記事生成AI | Gemini API のみ | Gemini + LM Studio |
| コスト | API依存 | ローカルLLMで無料運用可 |
| 運用 | 手動 | Task Scheduler で完全自動 |
「認証はClerk、AIはフォールバック付き、運用はスケジューラ」
── 個人開発の自動化、ここに完結。
次は LM Studio の PC起動時自動起動設定と、生成記事の品質モニタリングを考えています。