🥳

Next.js(App Router)で個人ブログ集約プラットフォーム『Gatherly』を開発した話

2024/07/30に公開4

先日、Next.js の App Router のプロジェクトで、Zenn、Qiita、Note、しずかなインターネットの4つの個人ブログの記事を集約し、それぞれの記事を表示することが表示することができる 『Gatherly』 という Web アプリケーションを個人で作成しましたので、Web アプリケーションの全体像と、技術的なお話をまとめておこうと思います。
https://gatherly-gilt.vercel.app

ちなみにこちらが私のマイページになっています。

アプリケーションの全体像

ログイン画面は以下の通りですが、今回は Google 認証のみを取り入れています。

初期ログインが完了後は、以下のようなアカウント登録画面に遷移します。

ユーザー名のバリデーションはかなり厳密に行なっていて、すでに使用されていたり、使用されるであろうパスが入力された際はエラーを起こしたり、ユーザー名がマイページのパスになるため、ユーザー名の重複チェックを行なっています。

アカウント登録完了後は、マイページに遷移します。
まだ、各記事媒体のユーザー名を登録していないので、記事は表示されません。

各媒体のユーザー名の設定はアカウント設定画面で行います。

各媒体のユーザー名を登録し、マイページに戻ると、ユーザー名を登録した該当の媒体の記事が表示されます。

また、アカウント設定画面からは、表示名と自己紹介文とアバター画像の設定が可能になっています。

「プロフィール画像を変更」というボタンをクリックすると、画像編集モーダルが現れます。

以上が全体像です。

技術に関して

フロントエンドフレームワーク

Next.js の App Router を試すのが今回の目的だったため、Next.js の App Router を用いて開発しています。データの更新には Server Actions を利用しました。また、データの取得は全て Server Component 内で行っています。データの取得の詳細に関しては後述します。
https://nextjs.org/docs/app

データベース

データベースは Supabase を利用しています。無料利用枠があることと、Storage の機能も提供していることから Supabase を採用しました。 Supabase はアバター画像保存用のストレージとしても利用しています。
https://supabase.com/

ORM

使い慣れているため、 Prisma を利用しました。
https://www.prisma.io/

スタイリング

UI コンポーネント集である shadcn/ui と Tailwind CSS を利用しています。個人的に UI ライブラリはカスタマイズ性の観点から好きではないので、ヘッドレスUIライブラリである Radix を内部的に利用し、拡張した shadcn/ui は、自由にカスタマイズできるのでとても気に入っています。

例えば、 Button コンポーネントだと以下のようにカスタマイズして利用しています。

Button.tsx
const ButtonIconWrapper = ({ children }: { children: React.ReactNode }) => {
  return <span className="inline-flex items-center">{children}</span>
}

const buttonVariants = cva(
  'disabled:hover inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition duration-200 focus:outline-none focus:ring disabled:cursor-not-allowed disabled:opacity-50 focus:disabled:ring-0',
  {
    variants: {
      variant: {
        primary:
          'bg-skyBlue text-white hover:bg-royalBlue focus:ring-babyBlue hover:disabled:bg-skyBlue',
        basic:
          'border border-silverBlue bg-white hover:bg-iceBlue focus:ring-paleBlue hover:disabled:bg-white ',
      },
      fullWidth: {
        true: 'w-full',
      },
      radius: {
        sm: 'rounded-sm',
        md: 'rounded-md',
        lg: 'rounded-lg',
        full: 'rounded-full',
      },
    },
    defaultVariants: {
      variant: 'primary',
      radius: 'sm',
    },
  },
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
  startContent?: React.ReactElement
  endContent?: React.ReactElement
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      startContent,
      endContent,
      isLoading,
      className,
      variant,
      size,
      radius,
      fullWidth,
      asChild,
      ...props
    },
    ref,
  ) => {
    const Comp = asChild ? Slot : 'button'

    return (
      <Comp
        className={cn(
          buttonVariants({ variant, size, radius, fullWidth, className }),
        )}
        ref={ref}
        type="button"
        {...props}
      >
        {startContent && <ButtonIconWrapper>{startContent}</ButtonIconWrapper>}
        {children}
        {endContent && <ButtonIconWrapper>{endContent}</ButtonIconWrapper>}
      </Comp>
    )
  },
)
Button.displayName = 'Button'

export { Button, buttonVariants }

https://ui.shadcn.com/

フォームライブラリ

フォームライブラリには Conform を利用しています。今回フォームの送信は、Server Actions を利用しおり、React Hook Form が Server Actions 未対応のため、フォームの状態管理などを自前で実装するのは面倒だなと思い調べていたところ Conform というフォームライブラリの存在を知り、利用することにしました。

Conform は Server Acitons に対応しています。また、getFormProps や getInputProps のような a11y 用のビルドインヘルパーも用意されているため、a11y の考慮に必要な属性をフォームのフィールドに自動で追加できる点が非常に良かったです。

バリデーションは、より早いフィードバックが必要なため onValidate メソッドを用いて、クライアント側で実行しています。(※ ユーザー名の重複チェックのように、データベースとの問い合わせが必要なバリデーションに関してはサーバー側でチェックを行なっています)
https://conform.guide/

ちなみに、最近知ったのですが、 Tanstack Form も Server Actions に対応しているみたいです。ただ、現時点ではまだ v0 の β 版のようです。
https://tanstack.com/form/latest

データ取得とキャッシュ

今回データ取得は全て Server Component 内で行っています。

記事の取得とキャッシュ

Next.js で拡張された fetch 関数を利用して、各記事媒体の RSS からデータ取得を行なっています。余談ですが、RSS は xml 形式になっているので、それを json に変換するのにかなり苦労しました。
データのキャッシュに関しては、revalidate を用いて、Time-based Revalidation を行なっており、1時間おきにキャッシュをパージしてデータの再検証を行うように設定しています。以下は参考までに。

getZennArticles.ts
export const getZennRSSData = async (userName: string): Promise<string> => {
  const ZENN_RSS_URL = `https://zenn.dev/${userName}/feed`

  try {
    if (!userName) {
      throw new Error('userName is required')
    }

    const response = await fetch(ZENN_RSS_URL, {
      next: {
        revalidate: 3600,
      },
    })

    if (!response.ok) {
      throw new Error('Failed to fetch Zenn RSS')
    }
    const text = await response.text()

    if (!text.startsWith('<?xml')) {
      throw new Error('Invalid RSS feed format')
    }

    return text
  } catch (error) {
    if (process.env.NODE_ENV === 'development') {
      console.error(error)
    }

    throw error
  }
}

export const getZennArticles = async (userName: string): Promise<Article[]> => {
  try {
    const zennRSSData = await getZennRSSData(userName)

    const parser = new Parser({ explicitArray: false, trim: true })

    const xmlToJson = await parser.parseStringPromise(zennRSSData)
    let array = xmlToJson.rss.channel.item

    if (!Array.isArray(array)) {
      array = [array]
    }

    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const articles: Article[] = array.map((article: any) => {
      const publishedDate = new Date(article.pubDate)
      const isNewly =
        new Date().getTime() - publishedDate.getTime() <=
        30 * 24 * 60 * 60 * 1000 // 1ヶ月以内かどうかをチェック

      return {
        title: article.title,
        url: article.link,
        publishedDate: formatDate(publishedDate),
        isNewly,
      }
    })

    return articles
  } catch (error) {
    if (process.env.NODE_ENV === 'development') {
      console.error(error)
    }
    return []
  }
}

Prisma Client を利用した DB からのデータ取得とキャッシュ

Prisma Client を利用して、サーバー上で DB のデータを取得を行なっています。
キャッシュに関してですが、 fetch 関数以外のデータ取得の場合は自動ではキャッシュされません。しかし、Next.js ではこのようなサードパーティー製のライブラリを利用した場合でもデータのキャッシュが行えるように、 unstable_cache という関数が用意されているため、 unstable_cache を利用してデータのキャッシュを行いました。

https://nextjs.org/docs/app/api-reference/functions/unstable_cache
キャッシュは全て On-demand Revalidation で実装しており、Server Actions で revalidate が呼び出された時のみキャッシュをパージして、データの再検証を行うように設定しています。以下は参考までに。

 const getProfile = unstable_cache(
    async (userName: string) => getProfileFromScreenName(userName),
    [`profile/${userId}`],
    { tags: [`profile/${userId}`] },
  )

認証

NextAuth.js を利用して、Google 認証機能の実装を行なっています。今回は ORM に Prisma を利用しているため、adapter として PrismaAdapter を利用しているのですが、アバター画像を User モデルではなく Profile モデルに保存したかったので、PrismaAdapter を以下のようにカスタマイズして利用しています。

customPrismaAdapter
import { PrismaAdapter } from '@next-auth/prisma-adapter'
import { Adapter, AdapterUser } from 'next-auth/adapters'
import { prisma } from './prisma'

export const customPrismaAdapter: Adapter = {
  ...PrismaAdapter(prisma),
  createUser: async data => {
    const { image, ...userData } = data

    const user = await prisma.user.create({
      data: {
        ...userData,
        profile: {
          create: {
            avatarUrl: image,
          },
        },
      },
      include: { profile: true },
    })
    return user as AdapterUser
  },
}

https://next-auth.js.org/

アバター画像のクロップ

ユーザーのアバター画像のクロップには react-avatar-editor を利用しています。ただ、 アバター画像のクロップに関しては、メンテナンスの頻度の関係で react-avatar-editor ではなく、react-easy-crop を採用しておけば良かったなと少し後悔しています。


https://github.com/mosch/react-avatar-editor

セキュリティの話

今回 App Router を利用しており、Server Component と Client Component が混在しているため、うっかり機密情報を Client Component に props で渡してしまい、機密情報が流出する恐れがあります。そのため、Prisma でのデータ取得は必要なもののみ取得するように工夫しました。

Next.js のセキュリティに関しては、ムーさんがまとめてくださっています。
https://zenn.dev/moozaru/articles/d270bbc476758e

上記の記事でも書かれている通り、Prisma の v5.16.0 のリリースにより、グローバルで特定フィールドを Omit できるようになったので、また実装してみようと思います。
https://github.com/prisma/prisma/releases/tag/5.16.0

まとめ

最近開発した Gatherly という Web アプリケーションの全体像や技術的なことに関して紹介しました。
今回 Next.js の App Router のプロジェクトで Web アプリケーションを開発したことで、Server Actions やキャッシュの概念の理解につながりました。また、 Conform や shadcn/ui などの新しい技術にも触れることができたので、非常に満足しています。ただ、反省点もありましたし、修正すべき点がまだあるので、追々修正して行きたいと思います。

ここまで読んでいただき誠にありがとうございました!

何かあればコメントいただけると助かります😎

Discussion

Taru-sourceTaru-source

おお...ポートフォリオ紹介用のページ作らなくてもGatherlyのURLだけ共有すればOK、って世界観が目指せそうで素敵ですね...。ありがたく使わせていただきます!

一点だけ、実際に使ってみて困ったことがありましたのでFBさせていただきます。
Taru-sourceみたいなQiitaユーザー名はバリデーションで弾かれてしまい、UIから登録出来ませんでした。(半角英数字なら行けるかと思いましたが、半角大文字が駄目っぽい?です。)

myttymytty

コメントありがとうございます!

おお...ポートフォリオ紹介用のページ作らなくてもGatherlyのURLだけ共有すればOK、って世界観が目指せそうで素敵ですね...。ありがたく使わせていただきます!

そう言っていただき嬉しいです!
今後ブラッシュアップしていこうと思っています。

一点だけ、実際に使ってみて困ったことがありましたのでFBさせていただきます。
Taru-sourceみたいなQiitaユーザー名はバリデーションで弾かれてしまい、UIから登録出来ませんでした。(半角英数字なら行けるかと思いましたが、半角大文字が駄目っぽい?です。)

FB ありがとうございます。すごい助かります🙇‍♂️
おっしゃる通り、現状半角大文字を入力するとバリデーションで弾かれるようになっていますので、修正します。

Taru-sourceTaru-source

Qiitaのバリデーションで弾かれずに登録できました。修正ありがとうございます!

myttymytty

Taru-source さん、ご確認とご連絡ありがとうございます!