見出し画像

コーポレートサイトをAstro3とmicroCMSとCloudflareをつかってリニューアルしました

概要

nocoの取締役CPOの多田と申します。
今回、gulpとejsでできていたコーポレートサイトを、Astro3を使ってリニューアルしました。
インフラはCloudflareを使い、コンテンツ管理にはmicroCMSを利用しました。CI/CDを完備し、TypeScriptによる素晴らしい開発体験の仕組みができたので共有します。

新しいコーポレートサイト

背景

弊社のコーポレートサイトは、2022年以前STUDIOを使ってノーコードで作成されていました。
STUDIOは手軽にかっこいいサイトが作れるため重宝していましたが、2022年の前半にいくつかの課題があり、gulpをつかって静的なサイトとしてリニューアルしました。

最大の課題は、「オウンドメディアをサブディレクトリで運用したい」というものでした。この手のサービスは「サブドメイン」を自由に設定することはできますが、リバースプロキシなどを駆使して「サブディレクトリ」で運用することはでき無いことが多く、今後の拡張性のためにもコンテンツやページ構成はそのままに、内製で作り直す事になったのです。

そして、2023年夏、採用活動や営業活動のために、コーポレートサイトのコンテンツリニューアルを行うことになりました。
そこで、社内で利用が拡大していたAstroを使ってみることにしました。

Astroコンポーネントの再利用

Astroといえば、アイランドアーキテクチャーが特徴的ですが、「コンポーネントごとにスタイルを含めて独立をしている構造」というのは長期間に渡るシステム開発においては非常に有効です。
Vue.jsのように、1ファイルにマークアップとスタイル、必要に応じてスクリプトがまとまるので、コードの見通しがよく、可搬性が高いです。
弊社では、2023年から他のプロジェクトでAstroの活用が始まっており、「カードスタイルのリンク」「高解像度に対応した画像タグ」「問い合わせフォームのreCAPTCHA」などいくつかのコンポーネント資産がありました。

再利用しやすいAstroコンポーネント

これらをコーポレートサイトに再利用することは容易で、開発期間の短縮につながりました。

microCMSの導入とSSGビルド

以前のコーポレートサイトは、完全に静的なサイトでした。
一部「ニュース」のような動的なコンテンツはWordPressで運用しており、フロントエンドのJavaScriptからJSONPで取得することで表示しており、運用性が低く、SEOにも不安がありました。
そこで、今回のリニューアルでは、microCMSを導入しました。

新しいコーポレートサイトでは、「ニュース」「メディア掲載」「動画ピックアップ」という3種類の可変なコンテンツがあることから、microCMSのAPIをそれぞれ 用意して、非開発者が好きなタイミングでコンテンツ更新できる仕組みを整備しました。

microCMSの記事管理画面
microCMSのスキーマ定義画面

GitHub ActionsでのAstroビルド時に、microCMSのAPIからすべてのコンテンツを取得し、静的なHTMLとしてサーバに配置することで、パフォーマンスのよいサイトを構築することができました。

詳しい内容は、microCMS本家のブログ「AstroとmicroCMSでつくるブログサイト」がとても参考になりました。

Next.jsなどと同様に、AstroではgetStaticPathsという関数をページでexportすることで、ページをビルド時に生成することができます。
getStaticPathsの中で、microCMS APIから返ってくる「記事のコンテンツID一覧」を取得することで、どのページを事前ビルドするかを決定することができます。

今回の可変なコンテンツの内、「ニュース」「メディア掲載」については、表示箇所が同じなのですが、保持すべきデータが異なります。

異なるデータ構造をもつ可変コンテンツ

「ニュース」は一般的なブログのように、記事タイトルやアイキャッチに加え、記事本文があります。

// src/lib/microcms/news.ts
// 「ニュース」のデータ構造
export type News = {
  title: string; // 一覧ページ用
  longTitle: string; // 詳細ページ用
  subtitle?: string;
  description: string;
  categories: [NewsCategoriesJa]; // microCMSではセレクトフィールドの値が配列で返ってくる
  eyecatch: MicroCMSImage;
  body: string; // HTML形式の本文
} & MicroCMSContentId &
  MicroCMSDate;

一方、「メディア掲載」は記事タイトルやアイキャッチはありますが、記事本文はなく、記事のURLや掲載サイト名があります。

// src/lib/microcms/publication.ts
// 「メディア掲載」のデータ構造
export type Publication = {
  title: string;
  eyecatch: MicroCMSImage;
  publicationURL: string;
  publicationSite: string;
} & MicroCMSContentId &
  MicroCMSDate;

TypeScriptとmicroCMSの管理上、この「ニュース」と「メディア掲載」を1つのAPIにすることもできますが、「オプショナルなプロパティが多くなり、コード上でも、microCMS管理画面上でも管理が煩雑になる」という理由から、別々のAPIを作成しました。

microCMSの「API」

microCMSの管理画面上で、「このコンテンツ種類のときは、これは必須だが、別の種類のときは任意になる」ということもできなくはないのですが、実際に非開発者が運用するとなると混乱することが予想されたためです。

microCMSとTypeScript

microCMSのAPIはJSONで返ってきますが、Astroを使っているならTypeScriptで型定義がしたいですよね。
microcms-ts-sdkというライブラリを作成している方がいらっしゃいましたが、できるだけ外部ライブラリに依存せず気軽に1ファイルで型安全にできたので、今回は自前で作成しました。

// src/lib/microcms/index.ts
import { createClient } from 'microcms-js-sdk';
import type { MicroCMSQueries } from 'microcms-js-sdk';

export const client = createClient({
  serviceDomain: import.meta.env.PUBLIC_MICROCMS_SERVICE_DOMAIN,
  apiKey: import.meta.env.PUBLIC_MICROCMS_API_KEY,
});

export type ListResponse<T> = {
  totalCount: number;
  offset: number;
  limit: number;
  contents: T[];
};

export interface Query<T> extends MicroCMSQueries {
  fields?: Extract<keyof T, string>[];
}

// 一覧取得(limitあり)
export const createGetList = <T>(endpoint: string) => {
  return async <F extends keyof T>(queries?: Query<T>) => {
    return await client.get<ListResponse<Pick<T, F>>>({
      endpoint,
      queries,
    });
  };
};

// ページングしてすべてを取得
export const createGetAll = <T>(endpoint: string) => {
  return async <F extends keyof T>(
    queries?: Query<T>
  ): Promise<Pick<T, F>[]> => {
    const LIMIT = 100;

    const getList = createGetList<T>(endpoint);

    const handler = async (
      offset = 0,
      limit = LIMIT
    ): Promise<ListResponse<Pick<T, F>>> => {
      const data = await getList(Object.assign({}, queries, { offset, limit }));

      if (data.offset + data?.limit >= data.totalCount) return data;

      const result = await handler(data.limit, data.offset + data.limit);

      return {
        offset: 0,
        limit: result.totalCount,
        totalCount: result.totalCount,
        contents: [...data.contents, ...result.contents],
      };
    };

    return (await handler()).contents;
  };
};

// 1件取得
export const createGetDetail = <T>(endpoint: string) => {
  return async <F extends keyof T>(
    contentId: string,
    queries?: Query<T>
  ): Promise<Pick<T, F>> => {
    return await client.getListDetail<Pick<T, F>>({
      endpoint,
      contentId,
      queries,
    });
  };
};

これは「記事一覧取得」「記事全件取得(自動ページング)」「記事詳細取得」の3つの関数を作成する関数になっています。

利用する側では、取得したい型情報とmicroCMSのエンドポイントを渡すだけです。

// src/lib/microcms/news.ts
// カテゴリー「メディア掲載」以外の場合
export type News = {
  title: string; // 一覧ページ用
  longTitle: string; // 詳細ページ用
  subtitle?: string;
  description: string;
  categories: [NewsCategoriesJa]; // microCMSではセレクトフィールドの値が配列で返ってくる
  eyecatch: MicroCMSImage;
  body: string;
} & MicroCMSContentId &
  MicroCMSDate;

// 一覧取得(limitあり)
export const getNewsList = createGetList<News>('news');

// ページングしてすべてを取得
export const getNewsAll = createGetAll<News>('news');

// 1件取得
export const getNewsDetail = createGetDetail<News>('news');

あとは、ページ側で getNewsList関数などを使い、データを利用するだけです。

AstroとmicroCMSとページング

AstroのSSGビルドでページングを実現するには、getStaticPaths関数で以下のような配列構造を返す必要があります。
詳細はAstro公式のNested Paginationをご覧ください。

export async function getStaticPaths() {
  return [
    {
      params: { /* ... */ }
      props: { /* ... */ }
    },
    // ...
  ]
}

実はこの返り値は、Astro2ではページネーションのために「ネストした配列」でも良かったのですが、Astro3では「フラットな配列」でないといけません。

Astro3が出た時点で公式ドキュメントに誤りがあったので、Pull Request を出してドキュメントを修正しておきました。
(2023-09-30時点で、日本語版は修正されていません)

// Astro2ではネストした配列でも良かった(Astro内部で自動でフラットにしてくれていた)
export async function getStaticPaths() {
  return [
    [
      {
        params: { page: '1', /* ... */ },
        props: {
          page: {
            data: [Array],
            start: 0,
            end: 17,
            size: 18,
            total: 82,
            currentPage: 1,
            lastPage: 5,
            url: [Object]
          }
        }
      }
      // ...
    ],
    [ /* ... */ ]
  ]
}

// Astro3では配列がフラットでないといけない
export async function getStaticPaths() {
  return [
    {
      params: { page: '1', /* ... */ },
      props: {
        page: {
          data: [Array],
          start: 0,
          end: 17,
          size: 18,
          total: 82,
          currentPage: 1,
          lastPage: 5,
          url: [Object]
        }
      }
    }
    // ...
  ]
}

そのため、getStaticPaths内部でpaginate関数を使う場合、最終的な返り値はflatMapをつかってフラットにする必要があります。

export async function getStaticPaths({
  paginate,
}: {
  paginate: PaginateFunction;
}) {
  const newsCategoriesWithAll = [
    'all', // *1
    ...Object.keys(NewsCategories),
    'publication',
  ];

  // ニュース(メディア掲載を除く)
  const allNews = await getNewsAll<
    'id' | 'publishedAt' | 'title' | 'categories' | 'eyecatch'
  >({
    fields: ['id', 'publishedAt', 'title', 'categories', 'eyecatch'],
    orders: '-publishedAt',
  });

  // メディア掲載
  const allPublications = await getPublicationAll<
    'id' | 'publishedAt' | 'title' | 'eyecatch' | 'publicationURL'
  >({
    fields: ['id', 'publishedAt', 'title', 'eyecatch', 'publicationURL'],
    orders: '-publishedAt',
  });

  // ニュース+メディア掲載
  const allItems = [...allNews, ...allPublications].sort((a, b) => {
    const aDate = a.publishedAt && new Date(a.publishedAt);
    const bDate = b.publishedAt && new Date(b.publishedAt);

    if (!aDate || !bDate) return 0;

    return new Date(bDate).getTime() - new Date(aDate).getTime();
  });

  // 'all' の場合は、すべてのニュースを列挙
  // それ以外の場合は、カテゴリー(news,categories)に紐づくニュースを列挙
  return newsCategoriesWithAll.flatMap((cat) => {
    if (cat === 'all') {
      return paginate(allItems, {
        params: { category: 'all' },
        pageSize: ItemPerPage,
      });
    } else if (cat === 'publication') {
      return paginate(allPublications, {
        params: { category: 'publication' },
        pageSize: ItemPerPage,
      });
    } else {
      const filteredNews = allNews.filter((news) => {
        return mapCategory(news.categories[0]) === cat;
      });
      return paginate(filteredNews, {
        params: { category: cat },
        pageSize: ItemPerPage,
      });
    }
  });
}

この処理はsrc/pages/news/[category]/[page].astroで行っています。
[category]」にはmicroCMSのAPIが「ニュース」の場合、「announcement、press、event」の3つの値を「セレクトフィールド」で選択するように設定しています。

*1 でallという「全てのカテゴリー」を表す文字列を追加しているのは、「コンテンツ種類を問わないすべての記事一覧」を作りたかったためです。

また、AstroでSSGをつかってページネーションを実現する場合、?page=2 のようにURLパラメータでページを切り替えるのは簡単にはできません。
/articles/1/articles/2のようにパスとして完全なページを作成する必要があります。

この処理により、複数のmicroCMS APIをマージしつつ、ページネーションしたSSGをビルドすることができました。

microCMSとGitHub Actionsとプレビュー画面

今回、「ニュース」などの可変なコンテンツは、非開発者によって任意のタイミングで更新できることが要件でした。ここで活躍するのがmicroCMSの「記事が更新されたときにGitHub Actionsを起動する」というWebhook機能です。

詳細は公式ブログの「GitHub ActionsへのWebhook通知に対応しました」 をご覧ください。
特に詰まることなく簡単に設定できます。
これにより1、2分のリードタイムはありますが、記事更新から自動的にビルドが走り、開発者のフローを挟むことなくデプロイすることができます。

また、記事編集時のプレビュー機能も重要です。
こちらについても、公式ブログの「AstroとmicroCMSを使った画面プレビューを実装する」 を参考にすることで簡単に実装できます。
1点重要なのは、プレビューデータはフロントエンドから直接microCMSのAPIをfetchし、DOMを更新する必要があるので、若干のJavaScriptの開発が必要になることです。
今回は利用しませんでしたが、テンプレートレンダリングにReact等を利用することも可能です。

// src/pages/preview.astro
<article class="news-inner">
  <time id="js-news-published"></time>
  <h1 id="js-news-title"></h1>
  <!-- ... -->
</article>

<script>
  // フロントエンドで実行されるコード
  import { getNewsDetail } from '@/lib/microcms/news';
  const params = new URLSearchParams(window.location.search);
  const contentId = params.get('contentId')!;
  const draftKey = params.get('draftKey')!;

  const news = await getNewsDetail(contentId, { draftKey });

  document.getElementById('js-news-published')!.textContent = news.publishedAt;
  document.getElementById('js-news-title')!.textContent = news.title;
  // ...
</script>

Cloudflareによるリバースプロキシ

弊社のコーポレートサイトは以下のようなディレクトリ構成になっています。

  • https://nocoinc.co.jp

    • / -> コーポレートサイト

    • /media -> Kinsta WordPress

これを簡単に実現するために、Cloudflare Workersを使いました。AWS での Cloudfront + S3 Website hostingと同様の処理になるように、インデックスアクセスの工夫を行っています。
Cloudflare Workersは、Web APIのRequest/Responseに抽象化されているので、理解しやすいコードが書けて便利です。

// worker.ts
import { requestR2 } from './request_r2';

interface Env {
  R2_BUCKET: R2Bucket;
}

export default {
  /**
   * リクエストを処理し、WordPressとR2 Bucketのリバースプロキシを提供します。
   * @param request - リクエストオブジェクト
   * @param env - 環境オブジェクト
   * @returns レスポンスオブジェクト
   */
  async fetch(
    request: Request,
    env: Env,
    context: ExecutionContext
  ): Promise<Response> {
    const cache = caches.default;

    const url = new URL(request.url);

    // Reverse proxy for WordPress(Kinsta)
    if (
      url.pathname.startsWith('/media')
    ) {
      const newUrl = new URL(request.url);
      newUrl.hostname = 'xxx.kinsta.cloud';
      const newRequest = new Request(newUrl, request);
      newRequest.headers.set('host', 'nocoinc.co.jp');
      return fetch(newRequest);
    }

    // Reverse proxy for R2 Bucket
    return requestR2(request, context, env.R2_BUCKET, cache);
  },
};
--------------------------------------------------------------
// request_r2.ts
export const requestR2 = async (
  request: Request,
  context: ExecutionContext,
  bucket: R2Bucket,
  cache: Cache,
): Promise<Response> => {
  const url = new URL(request.url);
  const cacheKey = new Request(url.toString(), request);

  let key: string;

  // top('/')へのアクセス
  // NOTE: top('')へのアクセスでも url.pathname === '/' になる
  if (url.pathname === '/') {
    key = 'index.html';
  } else {
    // パスの先頭のスラッシュを削除する
    // /path/to/file -> path/to/file
    key = url.pathname.slice(1);

    // スラッシュで終わる場合はindex.htmlをアクセスするようにする
    // path/to/ -> path/to/index.html
    if (key.endsWith('/')) {
      key += 'index.html';
    }
  }

  switch (request.method) {
    case 'HEAD':
    case 'GET':
      return fetchCache(context, cacheKey, cache, async () => {
        let object = await bucket.get(key);

        // オブジェクトが存在しない場合は/index.htmlにファイルがあるか確認する
        if (object === null) {
          key += '/index.html';
          object = await bucket.get(key);

          // /index.htmlにファイルがあれば、
          if (object !== null) {
            // スラッシュありのURLにリダイレクトする
            const location = new URL(request.url);
            location.pathname = `${url.pathname}/`;
            return Response.redirect(location.toString(), 301);
          }

          // それでもオブジェクトが存在しない場合は404を返す
          return new Response('Object Not Found', { status: 404 });
        }

        const headers = new Headers();
        object.writeHttpMetadata(headers);

        headers.set('etag', object.httpEtag);

        return new Response(request.method === 'HEAD' ? null : object.body, {
          headers,
        });
      });
    default:
      return new Response('Method Not Allowed', {
        status: 405,
        headers: {
          Allow: 'GET, HEAD',
        },
      });
  }
};

type NoCachedFn = () => Promise<Response>;

const cachePattern = [
  {
    type: 'video',
    regex:
      /\.(m4s|mp4|ts|avi|mpeg|mpg|mkv|bin|webm|vob|flv|m2ts|mts|3gp|m4v|wmv|qt)$/,
    ttl: 60 * 60 * 24,
  },
  {
    type: 'image',
    regex:
      /\.(jpg|jpeg|png|bmp|pict|tif|tiff|webp|gif|heif|exif|bat|bpg|ppm|pgn|pbm|pnm|svg)$/,
    ttl: 60 * 60 * 24,
  },
  {
    type: 'text',
    regex: /\.(css|js)$/,
    ttl: 60 * 60,
  },
  {
    type: 'audio',
    regex: /\.(flac|aac|mp3|alac|aiff|wav|ogg|aiff|opus|ape|wma|3gp)$/,
    ttl: 60 * 60 * 24,
  },
  {
    type: 'manifest',
    regex: /\.(m3u8|mpd)$/,
    ttl: 60 * 60,
  },
];

const defaultTtl = 30;

const fetchCache = async (
  context: ExecutionContext,
  cacheKey: Request,
  cache: Cache,
  noCachedFn: NoCachedFn
): Promise<Response> => {
  // キャッシュ取得
  const cachedResponse = await cache.match(cacheKey);

  // キャッシュが存在すれば、
  if (cachedResponse) {
    // キャッシュを返す
    return new Response(cachedResponse.body, cachedResponse);
  }
  // キャッシュが存在しなければ、
  // キャッシュ実際のファイルを取得
  const noCachedResponse = await noCachedFn();

  const newResponse = new Response(noCachedResponse.body, noCachedResponse);

  // キャッシュの有効期限を設定
  const url = new URL(cacheKey.url);
  const { type, ttl } = cachePattern.find(({ regex }) =>
    url.pathname.match(regex)
  ) ?? {
    type: 'default',
    ttl: defaultTtl,
  };

  newResponse.headers.append('cache-control', `s-maxage=${ttl}`);

  // キャッシュ保存
  context.waitUntil(cache.put(cacheKey, newResponse.clone()));

  return newResponse;
};

このキャッシュの仕組みにより、サブディレクトリのWordPressを含め、約90%のリクエストがキャッシュにより配信されています。

Cloudflare Wokersによるキャッシュ

Astro3のView Transitions

Astro3の目玉機能の一つは、何と言ってもView Transitionsです。
まだ触ったことがない方は https://nocoinc.co.jp/ にアクセスして、いくつかページ遷移してみてください。

Astro3でView Transitionsを設定するのは非常に簡単で、レイアウトに以下のコードを追加するだけです。

// src/layouts/Layout.astro
import { ViewTransitions } from 'astro:transitions';

<html>
  <head>
    <ViewTransitions />
    <!-- ... -->
  </head>
  <body>
    <!-- ... -->
  </body>
</html>

しかしView Transitionsを使うと以下のような問題があります。

  • フォームページなどでbeforeunloadによる処理をしていると、ページ遷移イベントが不安定になる

  • DOMContentLoadedでの初期化コードがページ遷移で実行されない

これらの問題は、ページ単位でView Transitionsを無効にするか、Astro3の astro:page-loadイベントを使うことで解決できます。

例えば、今回のコーポレートサイトは、Lottieを使ったアニメーションが随所に使われています。
これはページ遷移によってデータを取得して初期化する必要があり、通常は DOMContentLoadedで実行します。

import lottie from 'lottie-web';
import lottieData from '@/lib/lottie/Discovery.json';

document.addEventListener("DOMContentLoaded", () => {
  const animationContainer = document.getElementById('lottie-container');
  if (!animationContainer) {
    return;
  }

  lottie.loadAnimation({
    container: animationContainer,
    renderer: 'svg',
    loop: true,
    autoplay: true,
    animationData: lottieData,
  });
});

しかし、View Transitionsでは、ページ遷移時にDOMContentLoadedが発火せず、代わりにAstroが用意しているastro:page-loadを利用する必要があります。

document.addEventListener('astro:page-load', () => { /* ... */ }

これ以外でも、分析系ツールでのPV計測に不具合がでたり、ページ遷移とリロード時の挙動が変わってしまうなどの問題が発生する可能性があるので、View Transitionsを利用する際は注意が必要です。

まとめ

Astro3、Cloudflare、microCMSを使って、コーポレートサイトをリニューアルしました。コーポレートサイトのように、長期の運用容易性を持ちつつ、パフォーマンスやデザインにこだわったサイトを作るときには、Astroは非常に有効な選択肢になります。言うなれば「ACMスタック」とでも呼びましょうか。
この構成の利用シーンは、今後も増えていくと思いますので、参考になれば幸いです。