🌍

Next.js + App Router + Vercel でマルチテナントアプリを作成

2023/12/25に公開

はじめに

22卒でバックエンドエンジニアしてます。
普段はReactやNext.jsを触ることはないです。
GoとかPHPをやってます。

Next.js + App Routerの実装

今回の記事に関してはApp Routerだったりその他周辺知識の話はそんなにしないのでご了承を。

App Routerに関しては↓を
https://nextjs.org/docs/app

公式が出しているスターターキットみたいなのがあるんですがここではスクラッチから作っていきます
https://vercel.com/guides/nextjs-multi-tenant-application

作成予定のディレクトリはこんな感じです。

.
├── app
│   ├── admin
│   │   └── page.tsx
│   ├── auth
│   │   └── login/page.tsx
│   ├── [sitename]
│   │   └── page.tsx
│   ├── _components
│   ├── api
├── lib
│   ├── domain.ts
├── middleware.ts
├── package.json
├── tsconfig.json
├── .env

いろいろ割愛してますがとりあえずappディレクトリとmiddleware.tsが重要です。

ってことでまずはローカル環境から始めていこうと思います。
プロジェクトの作成等はおまかせします、一応App Routerで実装してるのでそちらを選択していただければ(Page Routerでもそんなに変更点はないはず。。。。)

各ファイルを雑に作成していきます。

app/admin/page.tsx

export default function AdminPage() {
  return (
    <div>
      <h2>Admin Page</h2>
    </div>
  )
}

app/auth/login/page.tsx

export default function LoginPage() {
  return (
    <div>
      <h2>Login Page</h2>
    </div>
  )
}

ページが変わっていることを確認できればいいので簡単なもので済ませました。

続いて、middleware.tsです。

middleware.ts

import { NextResponse, NextRequest } from 'next/server';

export const config = {
  matcher: [
    /*
     * Match all paths except for:
     * 1. /api routes
     * 2. /_next (Next.js internals)
     * 3. /_static (inside /public)
     * 4. all root files inside /public (e.g. /favicon.ico)
     */
    "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)",
  ],
};


export default async functions middleware(req: NextRequest) {
  const url = req.nextUrl;
  
  // Get hostname (e.g. vercel.com, test.vercel.app, etc.)
  const hostname = req.headers.get('host');
  const subdomain = hostname.split('.')[0];
  
  // Get pathname of the request
  const path = url.pathname;
  
  // TODO: adminページにアクセスする際は認証を要する実装にする
  //if (subdomain === "admin") {
  //  isValid = something()
  //  if (isValid) {
  //    return NextResponse.rewrite(new URL(`/admin${path}`, req.url));
  //  }
  //  
  //  return new Response(null, { status: 404 });
  //}
  
  return NextResponse.rewrite(new URL(`/${subdomain}${path}`, req.url));
}

ここにて
admin.hogehoge.com -> /admin -> app/admin/page.tsxを表示
auth.hogehoge.com/login -> /auth/login => app/auth/login/page.tsxを表示
のような処理を行なっています。

では早速ローカル環境で実際に動かしてみましょう。


http://admin.localhost:3000で↑のような画面になるはずです。

続いて

http://auth.localhost:3000/loginにて。

続いて新しくlib/domain.tsapp/[sitename]/page.tsxを編集してもう少し凝ったことをしましょう。

lib/domain.ts

// mockData、本来ならdatabase等を参照
const subdomains = [
    {
      name: 'Liverpool',
      description: 'This is the website for Liverpool official',
      subdomain: 'liverpool',
      contents: 'Liverpool FC is the best club in England.'
    },
    {
      name: 'Westham',
      description: 'This is the website for Westham official',
      subdomain: 'westham',
      contents: 'The Manager of Westham FC is genius.',
    },
    {
      name: 'Chelsea',
      description: 'This is the website for Chelsea official',
      subdomain: 'chelsea',
      contents: 'Chelsea FC is currenlty 10th on the table.'
    },
];

// subdomainからデータを取得する関数
export const getSubdomainData = (subdomain: string) => {
    return subdomains.find((s) => s.subdomain === subdomain);
}
// subdomainが有効であるかどうかを返す関数
export const isValidSubdomain = (subdomain: string) => {
    const allSubdomains = subdomains.filter((s) => s.subdomain === subdomain)
    
    return allSubdomains.length > 0;
}

middleware.ts

import { NextResponse, NextRequest } from 'next/server';
import { isValidSubdomain } from './lib/domain';

export const config = {
  matcher: [
    /*
     * Match all paths except for:
     * 1. /api routes
     * 2. /_next (Next.js internals)
     * 3. /_static (inside /public)
     * 4. all root files inside /public (e.g. /favicon.ico)
     */
    "/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)",
  ],
};


export default async function middleware(req: NextRequest) {
  const url = req.nextUrl;
  // Get hostname (e.g. vercel.com, test.vercel.app, etc.)
  const hostname = req.headers.get('host');
  if (!hostname) {
    return new Response(null, { status: 404 });
  }

  const subdomain = hostname.split('.')[0];
  const path = url.pathname;
  
  // TODO: adminページにアクセスする際は認証を要する実装にする
  //if (subdomain === "admin") {
  //  isValid = something()
  //  if (isValid) {
  //    return NextResponse.rewrite(new URL(`/admin${path}`, req.url));
  //  }
  //  
  //  return new Response(null, { status: 404 });
  //}
  if (subdomain === "admin") {
    return NextResponse.rewrite(new URL(`/admin${path}`, req.url));
  }
  if (subdomain === "auth") {
    return NextResponse.rewrite(new URL(`/auth${path}`, req.url));
  }
  // subdomainが有効かチェック
  if (!isValidSubdomain(subdomain)) {
    return new Response(null, { status: 404 });
  }
  return NextResponse.rewrite(new URL(`/${subdomain}${path}`, req.url));
}

app/[sitename]/page.tsx

import { getSubdomainData } from "@/lib/domain";
import { Metadata } from "next";

export default function SitePage({
    params,
}: {
    params: { sitename: string };
}) {
    const data = getSubdomainData(params.sitename);
    return (
        <div>
        <p>{data && data.contents}</p>
        </div>
    )
}
    
// metadataもページに合わせて更新しましょう。
export function generateMetadata({
    params,
}: {
    params: { sitename: string };
}): Metadata {
    const data = getSubdomainData(params.sitename);

    if (!data) {
        return {
            title: "404",
            description: "Not Found",
        };
    }

    return {
        title: data.name,
        description: data.description
    }
}

http://liverpool.localhost:3000にアクセスしましょう


埋め込んだtitleとdescriptionも変わっていることも確認。

本来であればjsonで作成したデータはdatabase等で持っておきたいですね。
そしてapp/api/[sitename]/route.ts等で取得しにいくものかなと。

TODOとしてNextAuthあたりを使った認証を実装し、middleware.tsにてsubdomainがadminであれば検証を行うようにできればアプリとしてなお完成度が高まる感ありますね。

その辺りは↑の公式があげているスターターキットでできるんですかね??(まだやってない💦)

Vercelにデプロイ

とりあえずGithubにrepositoryを作成してpushしましょう。

その後になければvercelのアカウントを作成し、Github連携をしてAdd New Projectで先ほど作ったrepositoryからimportしましょう。この辺りの手順は雑にいきます(笑)。

おそらくこんな感じでdeployが完了しているはずです。
project-name.vercel.appの用に自動でドメインを取得してくれていますが今回はこちらは使いません。

vercelからでも他のお名前.com等からでもいいので今回使うドメインを取得しておきましょう。
vercelだと最安値でも20ドルぐらいするので少し高いですね。

お名前.comだと以下ようなのサイトが調べたら出てきます。
https://asaitoshiya.com/setup-onamae-com-domain-to-vercel/

vercelでドメインを購入する場合だとトップページからAdd New Domainを選択し、購入を選べます。

ドメインを用意したのちに作成したプロジェクトのSettings->Domainsを開きます。
そこで新しくドメインが追加できるので以下のように
*.[取得したドメイン名]で登録します。

こんな感じでValid Configurationが表示されていればOKです。

表示したいsubdomainを入力しましょう。

先ほどローカルで表示されたような画面が表示されていればOKです。
だいぶ最後の方雑になりましたが以上になります。

Discussion