Vol. 01
2026.04.10
2026.03.11 ZENN // TECH

chatlog-converterを実際にドメイン公開してみた話

ORIGIN: ZENN.DEV READ ORIGINAL

Introduction: 個人SaaSのサブドメイン展開とBYOK戦略

2026年3月初週、個人開発しているSaaS(chatlog-converterのこと以下CLC)についてZenn記事にまとめた。それが俺の初めての記事だった。
それから約一週間以上、遂に形になった気がしたので世に出したいと思った次第である。

chatlog-converter↓

https://clc.ena-dri.dev/

今回の技術的テーマは、ユーザーが自身のAPIキーを持ち込む「BYOK(Bring Your Own Key)」方式の採用だ。
これにより、開発者側のAPIコストをゼロに抑えつつ、Stripeなどの複雑な課金管理をスキップして迅速にプロダクトをスケールさせる狙いがある。

対象読者は、Next.jsとVercel AI SDKを活用して、低コストかつセキュアにAIツールを公開したい開発者だ。
本稿では、ローカル環境の .env.local 依存から脱却し、サブドメインでの公開に至るまでのプロセスを記録する。

Struggle: セキュリティリスクとコスト設計のジレンマ

個人開発において最大の懸念は、APIコストの増大とAPIキーの管理リスクだ。当初はサーバー側でキーを保持する構成も検討したが、DBへの永続保存は漏洩時のリスクが大きく、暗号化の実装コストも馬鹿にならない。また、リテラシーが高い層をターゲットにする以上、モデルの選択肢を固定せず、ユーザーが自由に指定できる柔軟性も持たせたかった。

Vercelへのデプロイにあたっても、.env.local からBYOK方式へ移行する際、既存のロジックをどう壊さずに差し込むかが課題となった。特に、api/convert/route.ts に集約されているLLM呼び出し部分を、ステートレスかつセキュアに保ちながら、ユーザー入力のキーを反映させる設計に頭を悩ませた。

Insight: 非対称な永続化とプロバイダー自動判定

実装要件を整理する中で、「キーはセッション、モデルはキャッシュ」という非対称な永続化ポリシーに辿り着いた。APIキーは sessionStorage(タブを閉じると消去)、モデル名は localStorage(ブラウザキャッシュに永続)で管理する仕様だ。これにより、利便性を確保しつつセキュリティリスクを最小化できる。

また、Vercel AI SDKを利用する場合、モデル名の文字列からプロバイダーを自動判定する方式が極めて有効だと気づいた。モデル名のprefix(gpt-gemini-)をトリガーにプロバイダーを切り替えるロジックを組めば、サーバー側に一切のキーを保存せずとも、多様なAPIに対応可能になる。

Solution: ステートレスなBYOK実装とVercelデプロイ

プロバイダー自動判定(config.ts

モデル名のプレフィックスでプロバイダーを動的に切り替える。現時点でGeminiとOpenAIに対応しており、将来的なモデル追加もこの関数を拡張するだけでフロントエンドの変更は不要だ。
但し、製作者本人がGeminiでしかテストを行っていない為、基本はGeminiを使って頂きたい。

import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";

export function getLlmModel(modelId: string, apiKey: string) {
    if (modelId.startsWith("gemini-")) {
        const google = createGoogleGenerativeAI({ apiKey });
        const modelName = modelId.startsWith("models/")
            ? modelId
            : `models/${modelId}`;
        return google(modelName);
    }

    if (modelId.startsWith("gpt-")) {
        const openai = createOpenAI({ apiKey });
        return openai(modelId);
    }

    throw new Error(`Unsupported model provider for model: ${modelId}`);
}

BYOKヘッダーの受け取り(route.ts

リクエストヘッダーからキーとモデルIDを取得し、どちらか一方でも未設定なら即座に400を返す。サーバー側はキーを一切保持しないステートレスな設計だ。

const apiKey = request.headers.get("x-api-key");
const modelId = request.headers.get("x-model");

if (!apiKey || !modelId) {
    return NextResponse.json(
        {
            error: "API Key (x-api-key) and Model ID (x-model) are required in headers.",
        },
        { status: 400 }
    );
}

取得したキーとモデルIDはそのまま getLlmModel() に渡され、Vercel AI SDKのストリーミング呼び出しに使用される。

const model = getLlmModel(modelId, apiKey);
const result = await streamText({
    model,
    system: systemPrompt,
    prompt: userPrompt,
    ...LLM_PARAMS,
});

Vercelデプロイとサブドメイン設定

Vercelへのデプロイは、GitHubリポジトリをインポートするだけで完了する。BYOKのため、環境変数は何も設定しない。

Cloudflareでサブドメインを取得し、DNSにCNAMEレコードを追加する。この際、Proxyをオフ(DNSのみ)に設定することが重要だ。
オン(オレンジ雲)のままだとVercel側のSSL認証が通らない。

項目
Type CNAME
Name clc
Target cname.vercel-dns.com
Proxy オフ(DNSのみ)

設定後、VercelプロジェクトのDomainsに同じサブドメインを追加すれば認証が通り、clc.ena-dri.dev にてサービスが公開される。

おわりに

やっとCLCを世に出すことができた。もちろん初めてのSaaS開発となるため拙い部分もあるとおもう。
だが、自分で作ったものが、自分で考え取得したドメインで出せたというのは筆舌に尽くし難い気持ちがある。

本当に世に出せるのか?と思ったものだが案外なんとかなるものである。
元々DeepResearchでこんな隙間があるぞという思想で産まれたもの、自分で使ううちに面白いなと思ったのもあるが。

あ、基本設計がGeminiなので、Gemini以外のモデルは非推奨である。そこはご了承願いたい。
もし思ったより使われてそうだなと感じたら別モデルでのテスト、実装を行いたい。が余力があるかは不明である。

ほんとうのおわり

このプロジェクトの全サマリーはそのうち個人HPにノートとして公開してみようと思う。