個人開発でサブドメイン型ポートフォリオサービスを作った技術的チャレンジ

開発者向けポートフォリオ自動生成サービス「Brightfolio」を開発しました。このサービスの特徴の一つが、username.brightfolio.linkというサブドメイン形式でユーザー個別のポートフォリオを提供することです。

実際に私のポートフォリオも daisuke-matsuura.brightfolio.link で公開しています。

今回は、Next.jsでこのサブドメイン型サービスを実現した技術的な実装方法と、開発過程で学んだことを詳しくお話しします。

目次

なぜサブドメイン型にしたのか

ユーザー体験の向上

従来のパス形式(brightfolio.link/user/username)ではなく、サブドメイン形式(username.brightfolio.link)を選んだ理由は主に3つです:

  1. プロフェッショナルな印象: 独自ドメインのような所有感
  2. ブランド拡散効果: URLを見るだけでサービス名が分かる
  3. SEO効果: 各ポートフォリオが独立したサイトとして評価される

技術的なメリット

  • スケーラビリティ: 将来的なカスタムドメイン機能への発展性
  • CDN効率: サブドメインごとのキャッシュ戦略が可能
  • 分析精度: サブドメイン単位でのアクセス解析

技術スタックの選定

フレームワーク構成

  • Next.js 15: App Routerとサーバーコンポーネントを活用
  • Vercel: ホスティングとワイルドカードドメイン対応
  • Supabase: PostgreSQLデータベースとOAuth認証
  • TypeScript: 型安全性の確保

核心技術:Middlewareによるサブドメイン処理

基本的な仕組み

Next.jsのMiddlewareを使用して、サブドメインを動的ルートパラメータに変換します:

// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";

export async function middleware(request: NextRequest) {
  const hostname = request.headers.get("host") || "";
  const url = request.nextUrl.clone();

  // メインドメインの場合は通常処理
  if (hostname === "brightfolio.link" || hostname === "www.brightfolio.link") {
    return NextResponse.next({ request });
  }

  // サブドメイン抽出
  const mainDomain = "brightfolio.link";
  const subdomainMatch = hostname.match(
    new RegExp(`^(.+)\\.${mainDomain.replace(".", "\\.")})
  );
  const portfolioId = subdomainMatch ? subdomainMatch[1] : null;

  if (portfolioId && portfolioId !== "www") {
    // 無限ループ防止
    if (url.pathname.startsWith("/portfolio/")) {
      return NextResponse.next({ request });
    }

    // ポートフォリオページにリライト
    url.pathname = `/portfolio/${portfolioId}${
      url.pathname === "/" ? "" : url.pathname
    }`;
    return NextResponse.rewrite(url);
  }

  return NextResponse.next({ request });
}

正規表現による柔軟な抽出

ドメインのエスケープ処理により、ドット文字を適切に処理:

const subdomainMatch = hostname.match(
  new RegExp(`^(.+)\\.${mainDomain.replace(".", "\\.")})
);

これにより、user-name.brightfolio.linkのようなハイフン付きサブドメインも正しく処理できます。

動的ルーティングの実装

ファイル構成

src/app/
├── portfolio/
│   └── [portfolio_id]/
│       ├── layout.tsx     # ポートフォリオ専用レイアウト
│       ├── page.tsx       # メインページ
│       └── not-found.tsx  # カスタム404ページ

存在確認ロジック

データベースでユーザーの存在を確認してから表示:

// layout.tsx
async function checkUserExists(portfolioId: string): Promise<boolean> {
  try {
    const supabase = await createSupabaseServerClient();
    const { data: profile, error } = await supabase
      .from("profiles")
      .select("id")
      .eq("portfolio_id", portfolioId)
      .single();

    return !!profile && !error;
  } catch (error) {
    return false;
  }
}

export default async function PortfolioLayout({ children, params }) {
  const { portfolio_id } = await params;
  const userExists = await checkUserExists(portfolio_id);

  if (!userExists) {
    notFound(); // カスタム404ページに遷移
  }

  return <>{children}</>;
}

SEO最適化の実装

動的メタデータ生成

各ポートフォリオページで個別のメタデータを生成:

export async function generateMetadata({ params }): Promise<Metadata> {
  const { portfolio_id } = await params;
  
  const supabase = await createSupabaseServerClient();
  const { data: profile } = await supabase
    .from("profiles")
    .select("username, avatar_url, bio")
    .eq("portfolio_id", portfolio_id)
    .single();

  if (!profile) {
    return { title: "ポートフォリオが見つかりません" };
  }

  return {
    title: `${profile.username} - ポートフォリオ | Brightfolio`,
    description: `${profile.username}さんの開発者ポートフォリオ`,
    openGraph: {
      title: `${profile.username} - 開発者ポートフォリオ`,
      images: [{ url: profile.avatar_url, width: 400, height: 400 }],
    },
    twitter: {
      card: "summary_large_image",
      images: [profile.avatar_url],
    }
  };
}

構造化データの実装

検索エンジン向けの構造化データも自動生成:

const structuredData = {
  "@context": "https://schema.org",
  "@type": "Person",
  name: profile.username,
  description: profile.bio,
  url: `https://brightfolio.link/portfolio/${portfolio_id}`,
  image: profile.avatar_url,
  jobTitle: "ソフトウェア開発者",
  knowsAbout: stats.top_technologies.map(tech => tech.name)
};

パフォーマンス最適化

サブドメイン型サービスでは、各ユーザーのポートフォリオが独立したページとして動作するため、パフォーマンス最適化が特に重要になります。Brightfolioでは以下の戦略で高速化を実現しています。

静的生成とISRの使い分け

課題: 全ユーザーのポートフォリオを事前生成するとビルド時間が膨大になる

解決策: 人気ユーザーのみ事前生成し、その他はISR(Incremental Static Regeneration)で対応

// 開発環境では動的、本番環境ではISR
export const dynamic = "auto";
export const revalidate = 3600; // 1時間ごとに再検証

export async function generateStaticParams() {
  // 開発環境ではSSGを無効化
  if (process.env.NODE_ENV === "development") {
    return [];
  }

  // 実際に存在するポートフォリオIDを取得
  const { data: profiles } = await supabase
    .from("profiles")
    .select("portfolio_id")
    .not("portfolio_id", "is", null)
    .limit(10); // 最大10個まで事前生成

  return profiles.map(profile => ({
    portfolio_id: profile.portfolio_id
  }));
}

この戦略により:

  • 初回アクセス: サーバーサイドレンダリングで即座に表示
  • 2回目以降: 静的ファイルから高速配信
  • コンテンツ更新: 1時間後に自動で最新情報に更新

GitHub API のキャッシュ戦略

課題: 各ポートフォリオでGitHub APIを呼び出すとレート制限に引っかかる

解決策: Next.jsのfetchキャッシュ機能を活用した段階的キャッシュ

const githubResponse = await fetch(
  `https://api.github.com/repos/${owner}/${repoName}`,
  {
    headers: {
      "User-Agent": "Brightfolio-Portfolio",
      Accept: "application/vnd.github.v3+json",
    },
    next: { revalidate: 3600 }, // 1時間キャッシュ
  }
);

キャッシュ階層:

  1. Next.jsキャッシュ: 1時間の短期キャッシュ
  2. Supabaseストレージ: 24時間の中期キャッシュ(予定)
  3. CDNキャッシュ: Vercelの edge cache で配信最適化

これにより GitHub API のレート制限(1時間あたり5,000リクエスト)を効率的に管理しています。

データベースアクセスの最適化

並列データ取得: 複数のデータソースを並行処理で高速化

// 逐次処理ではなく並列処理
const [profileData, repositoriesData, analysisData] = await Promise.all([
  supabase.from("profiles").select("*").eq("portfolio_id", portfolio_id),
  supabase.from("repositories").select("*").eq("profile_id", profile.id),
  supabase.from("analysis").select("*").eq("profile_id", profile.id)
]);

必要最小限のデータ取得: SQLクエリの最適化

// 不要なデータは取得しない
const { data } = await supabase
  .from("repositories")
  .select(`
    repository_full_name,
    custom_title,
    custom_description,
    is_featured
  `) // 必要なカラムのみ指定
  .eq("profile_id", profile.id)
  .order("is_featured", { ascending: false })
  .limit(20); // 最大表示件数で制限

Vercelでのワイルドカードドメイン設定

DNS設定

  1. Aレコード: brightfolio.link → Vercelの IP
  2. CNAMEレコード: *.brightfolio.linkcname.vercel-dns.com

Vercelプロジェクト設定

  1. プロジェクトの「Domains」設定で brightfolio.link を追加
  2. ワイルドカードドメイン *.brightfolio.link を追加
  3. SSL証明書の自動発行を確認

注意点

  • ワイルドカードSSL証明書は自動で発行される
  • CNAMEレコードの設定ミスに注意(TTLは短めに設定)
  • Vercelの管理画面でドメイン認証を確認

実装で苦労したポイント

サブドメイン型サービスの実装は想像以上に落とし穴が多く、何度も夜中までデバッグに苦しみました。特に以下の3つの問題は解決に数日を要した難敵でした。

1. 無限リダイレクトループ地獄

症状: ブラウザが「このページは動作していません」エラーを表示し、開発者ツールで見ると延々とリダイレクトが続いている

開発初期、Middlewareでサブドメインを検出してリライトした瞬間、ブラウザがフリーズするという謎の現象に遭遇しました。最初は「Next.jsのバグか?」と疑いましたが、原因は単純でした。

原因: Middlewareでリライトした後、再度同じMiddlewareが実行されてしまう

// 問題のあったコード(初期版)
if (portfolioId) {
  url.pathname = `/portfolio/${portfolioId}`;
  return NextResponse.rewrite(url); // これが無限ループの原因
}

解決に至るまでの試行錯誤:

  1. Vercelのログを見ても「too many redirects」しか出ない
  2. console.logを大量に仕込んでリクエストフローを追跡
  3. Middlewareが想定以上に多く実行されていることを発見
  4. 既にポートフォリオパスの場合は処理をスキップする条件を追加

最終的な解決策:

// 無限ループ防止の条件分岐を追加
if (url.pathname.startsWith("/portfolio/")) {
  return NextResponse.next({ request });
}

url.pathname = `/portfolio/${portfolioId}${
  url.pathname === "/" ? "" : url.pathname
}`;
return NextResponse.rewrite(url);

この一行を追加するまでに丸2日かかりました…。

2. 開発環境で動くのに本番で動かない問題

症状: localhost:3000では完璧に動作するのに、Vercelにデプロイすると404エラー連発

「よくある話」として聞いていましたが、実際に体験すると本当に絶望的な気持ちになります。特にサブドメインのテストは開発環境では限界があるため、本番環境でしか確認できないのが辛いところでした。

問題の根本原因:

  • 開発環境では localhost:3000 なので、サブドメイン処理がそもそも動作しない
  • 本番環境特有のワイルドカードドメイン設定が必要
  • DNSの浸透待ちで、設定変更の確認に30分〜1時間かかる

デバッグの苦労:

// デバッグ用に大量のログを仕込んだ
console.log(`[MIDDLEWARE] Processing: ${hostname}${url.pathname}`);
console.log(`[MIDDLEWARE] Portfolio ID: ${portfolioId}`);
console.log(`[MIDDLEWARE] Rewriting to: ${url.pathname}`);

しかし、Vercelの Function Logs は時々表示されないため、問題の特定に時間がかかりました。

解決策: 環境別の条件分岐で開発体験を改善

if (process.env.NODE_ENV === "development" || 
    hostname === "brightfolio.link" || 
    hostname === "www.brightfolio.link") {
  // 開発環境とメインドメインは通常処理
  return NextResponse.next({ request });
}

3. 静的生成でのCookies認証エラー

症状: generateStaticParams() 内で Supabase にアクセスすると「cookies is not defined」エラー

Next.js の静的生成時には request context が存在しないため、Supabase の cookies 認証が使用できません。これに気づくまでに半日を無駄にしました。

エラーメッセージの嵐:

Error: cookies() can only be used in a Server Component, Route Handler, or Server Action.
ReferenceError: cookies is not defined

問題のコード:

// これはビルド時に失敗する
export async function generateStaticParams() {
  const supabase = await createSupabaseServerClient(); // ❌ cookies使用
  // ...
}

最終的な解決策: cookies を使わない専用クライアントを作成

export async function generateStaticParams() {
  // cookies不使用のクライアントを直接作成
  const { createClient } = await import("@supabase/supabase-js");
  const supabase = createClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
  
  const { data: profiles } = await supabase
    .from("profiles")
    .select("portfolio_id");
  // ...
}

学んだこと: Next.js の実行コンテキスト(ビルド時、サーバーサイド、クライアントサイド)の違いを意識することの重要性。公式ドキュメントをもっと読み込むべきでした。

その他の小さな罠たち

  • Vercel の DNS 浸透: 設定変更後の確認に毎回30分待機
  • TypeScript の型エラー: params が Promise になった Next.js 15 の変更に対応
  • CSS の優先順位: サブドメインごとに異なるスタイル適用の複雑さ

これらの問題を一つずつ解決していく過程で、Next.js とサブドメイン処理への理解が深まりました。ただし、心理的ストレスも相当なものでした…😅

運用開始後の課題と改善

パフォーマンス改善

  1. 画像最適化: Next.jsのImageコンポーネント活用
  2. データ取得の並列化: Promise.allによる並行処理
  3. 不要なクエリの削減: 必要なフィールドのみ取得

ユーザビリティ向上

  1. エラーページの充実: カスタム404ページでサービス紹介
  2. ローディング状態: スケルトンUIの実装
  3. レスポンシブ対応: モバイルファーストデザイン

今後の展開

カスタムドメイン機能

有料プランでユーザー独自ドメイン(portfolio.example.com)に対応予定:

  1. DNSレコードの検証機能
  2. SSL証明書の自動発行
  3. ドメイン管理ダッシュボード

パフォーマンス分析

各ポートフォリオのアクセス解析機能:

  • Vercel Analyticsとの連携
  • ユーザー向けダッシュボード
  • A/Bテスト機能

まとめ

Next.jsでサブドメイン型SaaSを実装する際の重要ポイント:

  1. Middleware活用: 柔軟なルーティング制御
  2. 動的メタデータ: SEO最適化の自動化
  3. エラーハンドリング: ユーザー体験の向上
  4. パフォーマンス: 静的生成とキャッシュ戦略

技術的には複雑ですが、ユーザー体験の向上とブランド価値の創出という観点で大きなメリットがあります。

特にB2B SaaSでのcustomer.service.com形式や、個人向けサービスでのusername.service.com形式を検討している方の参考になれば幸いです。

関連リンク


この記事が役に立ったら、Brightfolioで実際にポートフォリオを作ってみてください!GitHubアカウントを連携するだけで3分で完成します。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

Web Developer / Educator

コメント

コメントする