🙌

Next.js + AI SDKで冷蔵庫の写真からレシピ生成してくれるアプリ作ってみた

2025/03/03に公開

はじめに

私は弊社に24年に新卒で入社し、主にNuxtを使ったサービスの開発等に携わってきました。
近年、弊社ではReactで開発する案件も増えてきており、私自身Reactの知識があまりなかったので少し勉強しようと思いました。
今回はそのアウトプットとして、Next.js(App Router)を使ってアプリを作ったので紹介したいと思います。

記事の対象読者

  • Next.jsに興味がある人
  • Next.jsを使ったAI開発に興味のある人

作ったものの概要

「RECIPE AI」という冷蔵庫内の写真を撮影していくだけで複数のレシピを提案してくれるアプリです。

自炊をしていると、献立を考えるのに時間がかかり、いつも同じレシピになりがちという課題のある方が少なからずおられると思います。そこで、冷蔵庫の食材を活かした新しいレシピの発想や、簡単にアレンジできる工夫を知れたら自炊がもっと楽しくなると思い、RECIPE AIを作るきっかけとなりました。

実はこのアプリは以前にVueで作ったものですが、勉強のためにNext.jsで新たにリニューアルさせました。VueとNext.jsでは環境が大きく異なるので、ほぼ0-1での開発となりました。



※変な画像が混じっているのは、画像検索に無料のwikipedia apiを使っているためです。(他の検索APIはお金がかかるので断念しました)


作り方の手順も生成してくれます

使用技術

技術 説明
Next.js(App Router) 使用するフレームワークです。
mantine UI UIライブラリです。コンポーネントやHooksが豊富にあり便利な印象があります。
package by feature 弊社で使われているデザインパターンです。個人的にも気に入っています。
AI SDK 内部でGPTを使うのでVercelが出しているAIライブラリを使います。
Jotai GPTで生成したデータを管理するために使用します。
zod 今回はGPTのFunction Callingのスキーマ定義に使います。
Amplify Auth ログイン機能の実装に挑戦しました。

実装の手順

全体設計

システムの設計としては以下になります。GPTとは自然言語で対話するので、アプリケーションとの連携部分が重要になってきます。本記事でもその部分について少し触れます。

ディレクトリ構成

ディレクトリの構成はほぼ以下の記事と同じような構成となっています。
https://zenn.dev/sonicmoov/articles/e5ce7fb6d42267

Mantine UIでカスタムカラーテーマを設定する

RECIPE AIはピンク色を独自の基本色として使用しているため、Mantine UIに設定してあげる必要があります。
https://mantine.dev/theming/colors/#colors-generation
Mantine UIのカラーテーマは10段階で設定する必要があり、手動でカスタマイズしていくと大変です。
しかし、公式では@mantine/colors-generatorという便利なライブラリがあり、ベースカラーを指定すると自動的に10段階のカラーを生成してくれます。

カラーの定義の部分のみを抜粋して紹介します。

'use client';
import { generateColors } from '@mantine/colors-generator';
import { createTheme } from '@mantine/core';

export const theme = createTheme({
  primaryColor: 'primary',
  colors: {
    primary: generateColors('#f6a6a7'), // カラー生成
  },
  primaryShade: 3, // 3段階
});

src/app/theme.tsにカラーを設定して、layout.tsxで読み込みます。
デフォルトでは9段階目の色が使われるようなのですが、色が強すぎたため今回はprimaryShadeに3を指定して色の階層を3段階にすることで落ち着かせるようにしました。

実際にカラーを確認しながら調整したい場合は以下に便利な公式ツールがあります。
https://mantine.dev/colors-generator/

Server ActionsでGPTを呼び出してレシピを生成する

選択された食材からレシピを生成する部分の実装を紹介します。

レシピ生成の要点

GPTに選択した食材の名前の一覧を渡して、以下のレシピ情報を5つ生成させます。

  • タイトル
  • 簡単な説明文(1文字以上16文字以下)
  • 難易度(1〜3段階で2個ずつ、最後の1つは難易度3で作成します。)
  • 調理時間(分単位)
  • カロリー(数値 kcal)

実装

Next.jsのServer ActionsとAI SDKを使ってレシピ生成をします。Server Actionsを使用する目的としてはAPIキーを隠すためです。私はこのような場合以前まではBFF環境を用意していましたが、Server Actionsを使うとその必要がないので便利だと思いました。

src/app/actions/completions.tsを作成し、generateRecipeというServer Actionsを実装します。
messagesには食材の一覧と指示を入力します。

'user server';
import { QueryMessage } from '@/types/QueryMessage';
import { openai } from '@ai-sdk/openai';
import { generateText as chatCompletion, tool } from 'ai';
import { z } from 'zod';

export const generateRecipe = async (foods: string[]) => {
  const messages: QueryMessage = [
    {
      role: 'user',
      content: [
        {
          type: 'text',
          text: `あなたは熟練したシェフです。食材一覧:[${foods.join(',')}]。提供された食材の中で作れるレシピを5つ教えてください。
          誰でも作ることができる一般的な料理のレシピを教えてください。
          食材一覧にない食材もやむを得ない場合は使用することも可能です。
          ただし、架空のレシピは考えないでください。
          各レシピには1文字以上16文字以下のレシピの簡単な説明文、完成までのおおよその時間、難易度、カロリーを付けてください。
          難易度が1のレシピを2個、難易度が2のレシピを2個、難易度が3のレシピを1個教えてください。
          カロリーは数字表記のみで教えてください。最後に'setRecipe'ツールを呼び出して完了です。
          この条件に従わないとあなたに悪いことが起きます。`,
        },
      ],
    },
  ];

  const tools = {
    setRecipe: tool({
      parameters: z.object({
        recipeList: z.array(
          z.object({
            title: z.string().describe('レシピ名'),
            time: z.number().describe('レシピの所要調理時間(分)'),
            kcal: z.number().describe('レシピの想定カロリー数'),
            difficulty: z.number().describe('レシピの難易度1-3'),
            catchcopy: z.string().describe('キャッチコピー'),
          }),
        ),
      }),
    }),
  };

  const result = await chatCompletion({
    model: openai('gpt-4o'),
    messages,
    tools,
    maxTokens: 1000,
  });

  return {
    message: result.response.messages[0].content,
    toolCalls: result.toolCalls,
  };
};

出力ではレシピ生成の要点で挙げた5つのレシピ情報をjson形式で生成して欲しいので、Function Callingを使います。AI SDKではtoolsで使用できます。toolsはzodスキーマ定義で出力したいパラメータを表現していきます。
最後に、AI SDKのgenerateTextを使って生成を行います。

生成後のデータをJotaiで管理する

GPTで生成したデータはページ間で共有したいので、状態管理ライブラリのJotaiを使います。
以下は写真から検出した食材を管理する部分です。
管理する状態ごとにhooksを作成して簡単に呼び出せるようにしています。

import { Recipe } from '@/types/Recipe';
import { atom, useAtom } from 'jotai';

const recipeStateAtom = atom<Recipe>([]);

export const useRecipeState = () => {
  const [recipes, setRecipes] = useAtom<Recipe>(recipeStateAtom);
  return {
    recipes,
    setRecipes,
  };
};

Amplify Authを導入する

今後ユーザーごとの管理機能を追加するために、Amplify UI Componentを使って簡易的にログイン実装をおこまいました。

構成

ログイン実装は以下のファイルで構成されています。

/app/
  └ layout.tsx - 下2つのコンポーネントを呼び出す
/components/
  ├ Authenticator.tsx - Amplify AuthのUIを定義
  └ ConfigureAmplifyClientSide.ts - Amplify構成ファイルを読み込む

実装はほぼ公式のものを使用しているので、詳細を知りたい方はご確認ください。
https://docs.amplify.aws/react/build-a-backend/server-side-rendering/

実装

layout.tsx

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <MantineProvider theme={theme}>
          <Provider>
            <Authenticator> // ここで囲む
              <ConfigureAmplifyClientSide />
              <DefaultLayout>{children}</DefaultLayout>
            </Authenticator> // ここで囲む
          </Provider>
        </MantineProvider>
      </body>
    </html>
  );
}

/components/Authenticator.tsx
カスタムテーマや日本語化は以下で設定できます。

'use client';
import { Authenticator as AuthenticatorUI, ThemeProvider, Theme } from '@aws-amplify/ui-react';
import { I18n } from 'aws-amplify/utils';
import '@aws-amplify/ui-react/styles.css';

export function Authenticator({ children }: Props) {
  const theme: Theme = {
    name: 'custom-theme',
    tokens: {
      components: {
        button: {
          primary: {
            backgroundColor: '#ef797b',
            color: 'white',
          },
          link: {
            color: '#ef797b',
          },
        },
        ...
      },
    },
  };

  const translations = {
    ja: {
      'Sign In': 'サインイン',
      'Sign in with Google': 'Googleでログイン',
      'Enter your Password': 'パスワードを入力',
      ...
    // 公式の情報:https://ui.docs.amplify.aws/react/connected-components/authenticator/customization#labels--text
    },
  };

  I18n.putVocabularies(translations);
  I18n.setLanguage('ja');

  return (
    <ThemeProvider theme={theme}>
      <AuthenticatorUI socialProviders={['google']}>{children}</AuthenticatorUI>
    </ThemeProvider>
  );
}

/components/ConfigureAmplifyClientSide.ts

'use client';

import { Amplify } from 'aws-amplify';

import AmplifyConfig from '@/../amplify_outputs.json';

Amplify.configure(AmplifyConfig, { ssr: true });

export function ConfigureAmplifyClientSide() {
  return null;
}

苦労した点・ハマりどころ

  • envの設定方法
    • クライアントサイドとサーバーサイドでenvの指定方法が異なります。envはpublicなのかと思いましたが、Next.jsの場合通常はサーバーサイドのみで管理されます。クライアントサイドで使いたい場合は、NEXT_PUBLIC_*を変数名の前につける必要があります。私はNEXT_PUBLIC_を忘れており思わぬところでハマってしまいました。

https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables#runtime-environment-variables

  • クライアントサイドとサーバーサイドを意識した実装
    • use clientuse serverを指定するだけでどちらで処理するかを簡単に切り替えられるのはとても便利だと思いました。しかし、その容易さによりクライアントサイドでしか書けない処理をサーバーサイドで書いてしまったり、その逆が発生したりと、慣れていないため混乱してしまいました。
    • 実装の仕方によっては脆弱性を作ってしまう場合があるらしいので、注意して使う必要がありそうだなと思いました。(以下の動画はServer Actionsを使うことによって起こる脆弱性についてサンプルコードを用いて解説されています。)

https://youtu.be/wh4kGL1EIGM?si=bMaTNmOrN9ollgrg

改善点・今後の展望

  • SuspenseでGPT生成中のロード画面を出したい
    • UXの面でところどころまだ改善する部分はあるなと思いましたが特に、GPTの生成は時間がかかるためロード画面を出すなどの工夫が必要だと思いました。現状の実装では画面遷移前に生成処理を行っているため、Suspenseを実装するには遷移時に生成処理を行うようにするなど追加の修正が必要になりそうです。
  • Amplify Auth(SSR)を動くようにする
    • SSR実装という慣れないことをやったために結果的にエラーで動かすことができませんでした。原因は調査中です。
  • Amplify Hostingにデプロイする
    • 今回の実装はローカル環境のみで動作しますが、今後はAmplify Hostingで公開できるようにしたいです。そのためには、GPTのAPI Keyをユーザー毎に設定できるようにするという課題を解決する必要があります。
株式会社ソニックムーブ

Discussion