WellAlly Logo
WellAlly康心伴
Development

Build a Personalized Meal Plan Generator with Next.js, OpenAI, and PostgreSQL

A step-by-step tutorial on building a full-stack AI web app that generates personalized meal plans. Learn to use the OpenAI API with advanced prompt engineering for reliable JSON output, Next.js for the frontend/backend, and PostgreSQL with Prisma for data storage.

W
2025-12-12
10 min read

Ever stood in front of the fridge, paralyzed by the question, "What's for dinner?" Decision fatigue is real, especially when you're trying to stick to health goals, manage dietary restrictions, or just break out of a recipe rut. The demand for smart meal planning is surging, with the U.S. market valued at over $271 million. What if you could build a tool that solves this problem using the power of AI?

In this tutorial, we'll build a personalized meal plan generator. A user will input their dietary needs (e.g., "vegan," "low-carb"), restrictions ("gluten-free"), and health goals ("weight loss"), and our app will generate a structured 7-day meal plan.

We will use a modern, powerful stack:

  • Next.js: For a full-stack React application with server-side capabilities.
  • OpenAI API (GPT-4o mini): To generate the meal plans with intelligent, human-like creativity.
  • PostgreSQL: A robust, open-source relational database to store our data.
  • Prisma: A next-generation ORM that makes database access a breeze.

This project is more than just another AI chatbot. It's a practical, real-world application that teaches you how to wrangle AI into producing structured, reliable data—a crucial skill for any developer working with large language models (LLMs).

Understanding the Problem

The core challenge with using LLMs for applications like this isn't just generating text; it's generating structured data. A free-form paragraph describing a meal plan is hard for an application to parse and display. We need a predictable JSON object.

Limitations of basic prompts: Simply asking an AI to "create a meal plan in JSON format" can be unreliable. The model might:

  • Hallucinate fields or use inconsistent naming.
  • Forget to include certain days or meals.
  • Break the JSON syntax, causing parsing errors.

Our Approach: We will solve this with advanced prompt engineering. By providing a clear schema and specific instructions within our prompt, we can dramatically increase the reliability of the AI's output. We'll tell it exactly what our JSON structure should look like, ensuring we get clean data to save in our PostgreSQL database.

Prerequisites

Before we start, make sure you have the following:

  • Node.js (v18 or later) installed.
  • An OpenAI API key: You can get one from the OpenAI Platform.
  • A PostgreSQL database: You can run one locally using Docker or use a free hosted service like Supabase or Neon.
  • Basic knowledge of React, Next.js, and JavaScript/TypeScript.

Step 1: Setting Up the Next.js Project and Prisma

First, let's create our Next.js application and set up the foundation for our database connection.

What we're doing

We'll initialize a new Next.js project, install Prisma, and configure it to connect to our PostgreSQL database.

Implementation

  1. Create the Next.js app: Open your terminal and run:

    code
    npx create-next-app@latest ai-meal-planner
    cd ai-meal-planner
    
    Code collapsed

    Choose the default options, including TypeScript and the App Router.

  2. Install Prisma and the PostgreSQL driver:

    code
    npm install @prisma/client pg
    npm install --save-dev prisma
    
    Code collapsed

    The pg package is the Node.js driver for PostgreSQL.

  3. Initialize Prisma: This command sets up the necessary Prisma configuration files.

    code
    npx prisma init
    
    Code collapsed

    This creates a prisma directory with a schema.prisma file and a .env file for your database URL.

  4. Configure the Database Connection: Open the .env file and set the DATABASE_URL variable to your PostgreSQL connection string.

    code
    DATABASE_URL="postgresql://USER:PASSWORD@HOST:PORT/DATABASE"
    
    Code collapsed

    Replace the placeholders with your actual database credentials.

How it works

create-next-app scaffolds a complete Next.js project. prisma init configures our project to use Prisma by creating the schema file, which will serve as the single source of truth for our database structure.

Step 2: Defining the Database Schema

Now we'll define the data models for our application. We need a way to store the meal plans generated for users.

What we're doing

We will edit the schema.prisma file to define a MealPlan model. This model will store the user's input and the generated JSON meal plan.

Implementation

Open prisma/schema.prisma and replace its contents with the following:

code
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model MealPlan {
  id        String   @id @default(uuid())
  // User's preferences that generated the plan
  preferences String
  // The generated meal plan, stored as a JSON object
  plan      Json
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
Code collapsed

How it works

  • generator client: Specifies that we want to generate the Prisma Client, a type-safe query builder.
  • datasource db: Configures our database connection, telling Prisma we're using PostgreSQL and where to find the connection URL.
  • model MealPlan: Defines our table in the database.
    • id: A unique identifier for each meal plan.
    • preferences: A simple String to store the user's raw input.
    • plan: The most important field. We use the native Json type that PostgreSQL supports. Prisma recommends using JSONB for its efficiency and indexing capabilities, and it maps the Json type to jsonb in PostgreSQL.

Run the Migration

To apply this schema to our database, we'll run our first migration.

code
npx prisma migrate dev --name init
Code collapsed

This command does two things:

  1. Creates an SQL migration file that reflects the schema changes.
  2. Applies the migration to the database, creating the MealPlan table.

Step 3: Building the OpenAI API Route

This is where the magic happens. We'll create a secure backend API route in Next.js to handle requests to the OpenAI API.

What we're doing

We'll create an API route (/api/generate) that receives user preferences, constructs a detailed prompt, sends it to OpenAI, and then saves the structured JSON response to our database.

Implementation

  1. Install the OpenAI SDK:

    code
    npm install openai
    
    Code collapsed
  2. Add your OpenAI API Key: Never expose your API key on the client side. Store it securely in your .env.local file. Next.js automatically loads these variables.

    code
    # .env.local
    OPENAI_API_KEY="your-secret-key-here"
    
    Code collapsed
  3. Create the Prisma Client Instance: For efficiency, it's best practice to instantiate a single PrismaClient and reuse it. Create a file at lib/prisma.ts:

    code
    // lib/prisma.ts
    import { PrismaClient } from '@prisma/client';
    
    const prisma = new PrismaClient();
    
    export default prisma;
    
    Code collapsed
  4. Create the API Route: Create a new file at app/api/generate/route.ts:

    code
    // app/api/generate/route.ts
    import { NextResponse } from 'next/server';
    import OpenAI from 'openai';
    import prisma from '@/lib/prisma';
    
    const openai = new OpenAI({
      apiKey: process.env.OPENAI_API_KEY,
    });
    
    export async function POST(req: Request) {
      try {
        const body = await req.json();
        const { preferences } = body;
    
        if (!preferences) {
          return new NextResponse('User preferences are required', { status: 400 });
        }
    
        const prompt = `
          You are a professional nutritionist and meal planner.
          Create a personalized 7-day meal plan based on the following user preferences: ${preferences}.
    
          Your response MUST be a valid JSON object. Do not include any text, notes, or explanations outside of the JSON structure.
          The JSON object should have a single root key: "mealPlan".
          "mealPlan" should be an array of 7 objects, one for each day (e.g., "Monday", "Tuesday", ...).
          Each day object must contain the following keys: "day", "meals", and "dailyTotals".
          - "day": The name of the day (string).
          - "meals": An object with three keys: "breakfast", "lunch", and "dinner".
          - Each meal ("breakfast", "lunch", "dinner") must be an object with "name" (string), "ingredients" (array of strings), and "calories" (number).
          - "dailyTotals": An object with the total "calories" (number) for that day.
    
          Here is the required structure:
          {
            "mealPlan": [
              {
                "day": "Monday",
                "meals": {
                  "breakfast": { "name": "Scrambled Eggs", "ingredients": ["2 Eggs", "1/4 cup Milk", "Salt", "Pepper"], "calories": 250 },
                  "lunch": { "name": "Grilled Chicken Salad", "ingredients": ["100g Chicken Breast", "Mixed Greens", "Cherry Tomatoes", "Cucumber", "Vinaigrette"], "calories": 400 },
                  "dinner": { "name": "Salmon with Quinoa", "ingredients": ["150g Salmon Fillet", "1 cup Quinoa", "Steamed Broccoli"], "calories": 550 }
                },
                "dailyTotals": { "calories": 1200 }
              }
            ]
          }
        `;
    
        const response = await openai.chat.completions.create({
          model: 'gpt-4o-mini',
          messages: [{ role: 'user', content: prompt }],
          response_format: { type: 'json_object' }, // This forces the model to output valid JSON
        });
    
        const content = response.choices.message.content;
        if (!content) {
          throw new Error('No content returned from OpenAI.');
        }
    
        const mealPlanJson = JSON.parse(content);
    
        // Save to database
        const savedPlan = await prisma.mealPlan.create({
          data: {
            preferences: preferences,
            plan: mealPlanJson, // Prisma handles the JSON serialization
          },
        });
    
        return NextResponse.json(savedPlan);
      } catch (error) {
        console.error('Error generating meal plan:', error);
        return new NextResponse('Failed to generate meal plan', { status: 500 });
      }
    }
    
    Code collapsed

How it works

  • Prompt Engineering: Our prompt is highly specific. We define the AI's persona ("professional nutritionist"), state the goal, and most importantly, provide a strict schema for the JSON output. This detailed instruction is the key to getting reliable data.
  • response_format: { type: 'json_object' }: This is a powerful feature of the OpenAI API. It compels the model to output a syntactically correct JSON object, significantly reducing the chances of a parsing error.
  • Security: The route is a POST handler, and all OpenAI interactions happen on the server, keeping our API key safe.
  • Database Interaction: After receiving and parsing the JSON, we use prisma.mealPlan.create() to save the record. Prisma's type safety ensures we're inserting data that matches our schema.

Step 4: Building the Frontend Interface

Now let's create a simple UI for users to enter their preferences and view the generated meal plan.

What we're doing

We'll modify the main page (app/page.tsx) to include a form, handle loading and error states, and display the meal plan in a readable format.

Implementation

Replace the code in app/page.tsx with the following:

code
// app/page.tsx
'use client';

import { useState } from 'react';

// Define a type for our meal plan structure for type safety
type Meal = {
  name: string;
  ingredients: string[];
  calories: number;
};

type DayPlan = {
  day: string;
  meals: {
    breakfast: Meal;
    lunch: Meal;
    dinner: Meal;
  };
  dailyTotals: {
    calories: number;
  };
};

type MealPlanResponse = {
  id: string;
  preferences: string;
  plan: {
    mealPlan: DayPlan[];
  };
};

export default function HomePage() {
  const [preferences, setPreferences] = useState('');
  const [mealPlan, setMealPlan] = useState<MealPlanResponse | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);
    setMealPlan(null);

    try {
      const response = await fetch('/api/generate', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ preferences }),
      });

      if (!response.ok) {
        throw new Error(`Error: ${response.statusText}`);
      }

      const data: MealPlanResponse = await response.json();
      setMealPlan(data);
    } catch (err: any) {
      setError(err.message || 'Failed to generate meal plan. Please try again.');
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="min-h-screen bg-gray-50 flex flex-col items-center p-8">
      <h1 className="text-4xl font-bold text-gray-800 mb-4">AI Meal Planner 🥗</h1>
      <p className="text-lg text-gray-600 mb-8">
        Enter your dietary needs and goals, and let AI create a plan for you.
      </p>

      <form onSubmit={handleSubmit} className="w-full max-w-lg mb-8">
        <textarea
          value={preferences}
          onChange={(e) => setPreferences(e.target.value)}
          placeholder="e.g., 2000 calories, high-protein, gluten-free, for weight loss"
          className="w-full p-3 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-green-500 focus:border-green-500 transition"
          rows={4}
        />
        <button
          type="submit"
          disabled={loading || !preferences}
          className="w-full mt-4 bg-green-600 text-white py-3 px-4 rounded-md font-semibold hover:bg-green-700 disabled:bg-gray-400 transition"
        >
          {loading ? 'Generating...' : 'Generate Meal Plan'}
        </button>
      </form>

      {error && <p className="text-red-500">{error}</p>}
      
      {mealPlan && (
        <div className="w-full max-w-4xl bg-white p-6 rounded-lg shadow-md">
          <h2 className="text-2xl font-bold text-gray-800 mb-4">Your 7-Day Meal Plan</h2>
          <p className="mb-4 text-gray-600">Based on preferences: <span className="font-semibold">{mealPlan.preferences}</span></p>

          <div className="space-y-6">
            {mealPlan.plan.mealPlan.map((day) => (
              <div key={day.day} className="border border-gray-200 p-4 rounded-md">
                <h3 className="text-xl font-semibold text-green-700">{day.day}</h3>
                <p className="text-sm text-gray-500 mb-2">Total Calories: {day.dailyTotals.calories}</p>

                <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
                  {Object.entries(day.meals).map(([mealType, meal]) => (
                    <div key={mealType} className="bg-gray-50 p-3 rounded">
                      <h4 className="font-bold capitalize">{mealType}</h4>
                      <p>{meal.name} ({meal.calories} kcal)</p>
                      <ul className="list-disc list-inside text-sm text-gray-600">
                        {meal.ingredients.map((ing, i) => <li key={i}>{ing}</li>)}
                      </ul>
                    </div>
                  ))}
                </div>
              </div>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}
Code collapsed

How it works

  • 'use client': This directive marks the component as a Client Component, allowing us to use state and event handlers.
  • State Management: We use useState to manage the form input, loading status, errors, and the final meal plan data.
  • API Call: The handleSubmit function makes a POST request to our /api/generate endpoint, sending the user's preferences in the request body.
  • Rendering: The component conditionally renders a loading message, an error, or the formatted meal plan based on the current state. We map over the mealPlan array to display each day's details.
  • TypeScript: We define types for our data (Meal, DayPlan, MealPlanResponse) to ensure type safety throughout the component.

Putting It All Together

To test the application, run the development server:

code
npm run dev
Code collapsed

Open http://localhost:3000 in your browser. You should see the input form. Enter your preferences, such as "A 2200-calorie vegetarian plan, low in sodium, with quick 30-minute dinners," and click "Generate Meal Plan." After a few moments, your personalized, structured meal plan will appear.

Alternative Approaches

While our approach is robust, here are other methods to consider:

  • Function Calling: For more complex interactions where the AI might need to call external tools (e.g., a recipe API), OpenAI's Function Calling feature is superior. It's designed to return structured JSON that maps directly to function arguments.
  • Zod/Pydantic Integration: Libraries like Zod (for TypeScript) can be used to define schemas in code. The OpenAI SDK even has built-in support for these, which can make schema definition more programmatic and reusable.

Conclusion

We've successfully built a full-stack, AI-powered web application from scratch. You learned how to connect Next.js with a PostgreSQL database using Prisma, create a secure API route to interact with the OpenAI API, and—most critically—how to engineer prompts that deliver reliable, structured JSON.

This project is a fantastic starting point. You can now build on it by adding features like:

  • User authentication to save plans per user.
  • A "shopping list" generator based on the meal plan.
  • The ability to regenerate or swap individual meals.
  • Detailed nutritional information (protein, carbs, fats).

The ability to bridge the gap between human language and structured data is a superpower in modern web development. Now go build something amazing!

Resources

#

Article Tags

nextjs
ai
openai
postgres
webdev
nutrition

Related Medical Knowledge

Learn more about related medical concepts and tests

W

WellAlly's core development team, comprised of healthcare professionals, software engineers, and UX designers committed to revolutionizing digital health management.

Expertise

Healthcare Technology
Software Development
User Experience
AI & Machine Learning

Found this article helpful?

Try KangXinBan and start your health management journey