next.jsを使ったembed cardの作り方(cloudflare環境対応)

cloudflare環境でも機能するembed cardの作り方を紹介します。

公開日:2024年9月4日 更新日:2024年9月4日
programming

emebed cardを作るにあたりurlからmetadataを取得しなければならない。

metadataはそのサイトのtitle, image, descriptionなどの情報である。

packageを使って取得すると簡単なので、packageを使います。

今回はurl-metadataを使いました。

https://www.npmjs.com/package/url-metadata/v/3.0.0

有名どころでいうと他にmetascraperというのがあるったのですが、cloudflareにデプロイするためにはRuntime: Edgeに対応させる必要があるのですがmetascraperはそれに対応していなかったため使用することができませんでした。

他にもlink-preview-jsなどもありましたが試してないです。

コードは以下のとおりです。

ちなみにコードはapp routerで書かれています。

/src/app/api/metadata/route.ts

export const runtime = 'edge';

import { NextRequest, NextResponse } from 'next/server';
import urlMetadata from 'url-metadata';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const url = searchParams.get('url');

  if (!url) {
    return NextResponse.json({ error: 'URL parameter is required' }, { status: 400 });
  }

  try {
    // const metadata = await urlMetadata(url, options);
    // fetch the url in your own code
    const response = await fetch(url);
    // ... do other stuff with it...
    // pass the `response` object to be parsed for its metadata
    const metadata = await urlMetadata(null, { parseResponseObject: response });
    return NextResponse.json(metadata, { status: 200 });
  } catch (e) {
    console.error('Error fetching metadata:', e);
    return NextResponse.json({ error: 'Failed to fetch metadata: ' + e }, { status: 500 });
  }
}

重要な点として

const response = await fetch(url);
const metadata = await urlMetadata(null, { parseResponseObject: response });

の部分です。

本来であれば

 const metadata = await urlMetadata(url);

だけでいいのですが、一度fetchを挟むことにより、cloudflareにデプロイすると発生するmetadataを取得するときに発生した、以下のErrorを解消することができます。

Error: The cache' field on 'RequestInitializerDict' is not implemented

参考: https://github.com/laurengarcia/url-metadata#readme

あとは取得したmetadataをカード形式に表示させるようなcomponentを作るだけです。

参考として以下のようになります。

/src/components/notionBlocks/Bookmark.tsx

"use client";

import React, { useEffect, useState } from "react";
import Image from "next/image";

interface Metadata {
  title: string | null;
  description: string | null;
  image: string | null;
}

const getImageSize = (
  url: string
): Promise<{ width: number; height: number }> => {
  return new Promise((resolve, reject) => {
    const img = new window.Image();
    img.onload = () => {
      resolve({ width: img.width, height: img.height });
    };
    img.onerror = reject;
    img.src = url;
  });
};

const Bookmark = ({ url }: { url: string }) => {
  const [metadata, setMetadata] = useState<Metadata | null>(null);
  const [imageSize, setImageSize] = useState<{
    width: number;
    height: number;
  } | null>(null);

  useEffect(() => {
    try {
      const urlOjg = new URL(url);
      fetch(`/api/metadata?url=${urlOjg.toString()}`)
        .then((res) => res.json())
        .then((data) => {
          setMetadata(data as Metadata);
          if (data.image) {
            const size = getImageSize(data.image);
            getImageSize(data.image).then((size) => {
              setImageSize(size);
            });
          }
        });
    } catch (e) {
      console.log(e);
    }
  }, [url]);

  let urlOjg: URL;
  try {
    urlOjg = new URL(url);
  } catch (e) {
    console.log(e);
    return <>{url}</>;
  }

  if (!metadata) {
    return <>{url}</>;
  }

  const { title, description, image } = metadata;

  return (
    <a
      href={url.toString()}
      className="not-prose card card-side card-bordered card-compact max-w-3xl"
      target="_blank"
      rel="noopener noreferrer"
    >
      <figure>
        {image && imageSize ? (
          <Image
            src={image}
            alt="title"
            width={imageSize.width}
            height={imageSize.height}
            loading="lazy"
            decoding="async"
            className="max-h-60"
          />
        ) : null}
      </figure>
      <div className="card-body">
        <h2 className="card-title">{title ? title : ""}</h2>
        <p>{description ? description : ""}</p>
        <div className="card-actions justify-end">
          <div className="flex">
            <Image
              src={`https://www.google.com/s2/favicons?domain=${urlOjg.hostname}`}
              alt="title"
              width={16}
              height={16}
              loading="lazy"
              decoding="async"
              className="mr-1"
            />
            <div>{urlOjg.origin}</div>
          </div>
        </div>
      </div>
    </a>
  );
};

export default Bookmark;

実際の表示は以下の通りです。

https://www.cloudflare.com/

まとめ

localとサーバーに上げたときとで挙動が違うので外部からfetchしてくる処理をするときは気をつけた方がよさそうでした。

どうやらruntime edgeを使う際はreactにもともとあるfetch以外を使うとerrorが発生する可能性が高そうです。