29 Aug 2025 ~ 12 min read

Creating a Review Analyser Using the Vercel AI SDK and React 19

Review analyser

In this guide, we’ll create a form that allows users to paste an e-commerce product review to get insights on sentiment and key themes.

We’ll use the Vercel AI SDK to make an AI call to do the review. It allows us to use a wide range of AI models from different providers using a single API. It also allows us to tell AI to respond with a specific JSON format.

We’ll use the Vercel AI SDK on the server inside a React Server Function and we’ll use Zod for server-side validation.

We’ll use React’s useActionState hook to track all the form state. We’ll experience how simple this is in combination with using a React Server Function.

For styling, we’ll use Tailwind CSS to ensure our app is visually appealing and responsive.

Project Setup

To get started, create a new Next.js project with the required dependencies. Open a terminal and navigate to your desired folder, then enter the following commands:

npx create-next-app@latest review-analyser --typescript --tailwind --app --eslint --src-dir --turbopack --no-import-alias
cd review-analyser
npm install ai zod @ai-sdk/openai

Open the created review-analyser folder in your preferred code editor.

Environment Configuration

We’ll utilize OpenAI’s GPT-4o mini model for our review analysis functionality. Securely store your OpenAI API key as an environment variable by creating a .env.local file in your project’s root directory with the following content:

OPENAI_API_KEY=your_openai_api_key_here

Building the Server Function

Begin by creating a new file at src/actions/analyse-review.ts to define our Server Function. Add the following code to get started:

"use server";

import { generateObject } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

const reviewAnalysisSchema = z.object({
  sentiment: z.enum(["positive", "negative", "mixed"]),
  categories: z.object({
    product_quality: z.enum(["good", "bad", "neutral"]),
    shipping: z.enum(["good", "bad", "neutral"]),
    customer_service: z.enum(["good", "bad", "neutral"]),
    value_for_money: z.enum(["good", "bad", "neutral"]),
  }),
  summary: z.object({
    main_complaint: z.string().optional(),
    main_praise: z.string().optional(),
    recommendation: z.string(),
  }),
});

export type ReviewAnalysis = z.infer<typeof reviewAnalysisSchema>;

type AnalyseReviewState = {
  success: boolean;
  errors?: string[];
  result?: ReviewAnalysis;
  loading?: boolean;
  content?: string;
};

The "use server" directive ensures that all exported functions in this file are Server Functions, so the code runs exclusively on the server rather than the client.

We import the openai models for our AI requests, generateObject from the ai package to handle the AI call, and the zod package for schema validation. These server-side packages are never bundled into the client, keeping the client-side bundle lightweight and efficient.

The reviewAnalysisSchema is a Zod schema that specifies the expected structure of the AI response from OpenAI. It captures both the overall sentiment and category-specific sentiments, as well as a summary with the main complaint, main praise, and a recommendation.

The AnalyseReviewState type defines the complete state for the form, including whether the submission was successful, any validation errors, the analysis result, and the original content to ensure it isn’t lost after submission.

Implementing the Server Function

Now, let’s start to write the Server Function:

export async function analyseReview(
  _: AnalyseReviewState,
  formData: FormData
): Promise<AnalyseReviewState> {
  try {
  } catch {
    return {
      success: false,
      errors: [
        "An error occurred while analysing the review. Please try again.",
      ],
      content: "",
    };
  }
}

The analyseReview Server Function accepts two parameters: the previous state (unused, hence named _) and the FormData object containing the form submission. Wrapping the logic in a try-catch block ensures that any unexpected errors are gracefully handled, returning a consistent failure state to the client.

Validating Review Content

Next, we’ll get and validate the review content:

export async function analyseReview(
  _: AnalyseReviewState,
  formData: FormData
): Promise<AnalyseReviewState> {
  try {
    const content = formData.get("content");

    const validationResult = z
      .string()
      .min(3, "Review content must be at least 3 characters long")
      .max(4000, "Review content must be less than 4000 characters")
      .safeParse(content);

    if (!validationResult.success) {
      return {
        success: false,
        errors: validationResult.error.errors.map((err) => err.message),
        content: typeof content === "string" ? content : "",
      };
    }

    const parsedContent = validationResult.data;
  } catch {
    ...
  }
}

First, we extract the content field value from the form using FormData.get. Next, we validate this value with a Zod schema, which enforces a minimum length of 3 characters and a maximum of 4000 characters. If validation fails, we return a failure state.

Making the AI Call

Next, we’ll invoke the AI model to analyse the review:

export async function analyseReview(
  _: AnalyseReviewState,
  formData: FormData
): Promise<AnalyseReviewState> {
  try {
    ...
    const { object } = await generateObject({
      model: openai("gpt-4o-mini"),
      schema: reviewAnalysisSchema,
      prompt: `Analyse this customer product review and categorise it according to the specified schema.

        Customer Review: "${parsedContent}"

        Please analyse the review and provide:

        1. Overall sentiment (positive, negative, or mixed)
        2. Category ratings for:
           - product_quality: How the customer feels about the product quality (good/bad/neutral)
           - shipping: Their experience with shipping and delivery (good/bad/neutral)
           - customer_service: Their experience with customer service (good/bad/neutral)
           - value_for_money: Whether they think it's worth the price (good/bad/neutral)
        3. Summary including:
           - main_complaint: The primary issue mentioned (if any)
           - main_praise: The main positive aspect highlighted (if any)
           - recommendation: One sentence business action for improvement

        If a category is not mentioned in the review, mark it as "neutral".
      `,
    });

    return {
      success: true,
      result: object,
      content: typeof content === "string" ? content : "",
    };
  } catch {
    ...
  }
}

The generateObject function from the Vercel AI SDK enables type-safe AI responses by requiring a schema that specifies the expected output format. By supplying our reviewAnalysisSchema Zod schema, we guarantee that the AI’s response adheres to our defined structure. This function leverages the gpt-4o-mini OpenAI model and uses a comprehensive prompt to guide the review analysis.

Under the hood, generateObject calls Open AI using the key defined in our OPENAI_API_KEY environment variable. This key isn’t leaked into any client-side JavaScript because this function only exists on the server.

Creating the Review Analyser Form Component

Begin by creating a new file at src/components/review-analysis-form.tsx and add the following code:

"use client";

import { useActionState } from "react";
import { analyseReview } from "@/actions/analyse-review";
import { ReviewAnalysisDisplay } from "./review-analysis-display";

export function ReviewAnalysisForm() {}

The "use client" directive designates this component as a Client Component, which is necessary because we utilize the useActionState hook here. Alongside this, we import our server-side function analyseReview and a child component named ReviewAnalysisDisplay (which we’ll implement shortly).

By referencing the server function directly in client-side code, we benefit from full type safety—hovering over the function reveals its signature—making for an excellent developer experience.

Using the useActionState Hook

Next, we’ll use the useActionState Hook, which will manage all of the form state for us, both on the client and in the Server Function.

export function ReviewAnalysisForm() {
  const [state, formAction, isPending] = useActionState(analyseReview, {
    success: false,
  });
}

The useActionState hook takes two parameters:

  • The server function to execute on form submission—in this case, analyseReview.
  • The initial state object, which sets the default values before any action occurs. Here, we start with { success: false }.

When called, useActionState returns a tuple with three elements:

  • state: Represents the latest state returned by the server function. Initially, this is the default state, but after submitting the form, it updates to include the result from analyseReview, such as the success flag, enhanced content, or any errors.
  • formAction: A function intended for the form’s action attribute. Submitting the form triggers this function, which serializes the form data, invokes the server function, and manages state updates automatically.
  • isPending: A boolean indicating whether the server function is currently running. It’s true while awaiting a response, enabling you to show loading indicators, disable the submit button, or provide feedback during processing.

Rendering the Form

Next, we will render the form:

export function ReviewAnalysisForm() {
  const [state, formAction, isPending] = useActionState(...);
  return (
    <div className="max-w-4xl mx-auto p-6 space-y-8">
      <div className="text-center">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          Product Review Analyser
        </h1>
        <p className="text-gray-600 max-w-2xl mx-auto">
          Paste a customer review to get insights on sentiment, key
          themes, and actionable recommendations for your business.
        </p>
      </div>

      <form action={formAction} className="space-y-6">
        <div>
          <textarea
            id="content"
            aria-label="Customer review content"
            name="content"
            rows={8}
            className={`w-full px-4 py-3 border-2 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-600 focus:border-blue-600 ${
              state.errors ? "border-red-500" : "border-gray-300"
            }`}
            placeholder="Customer Review"
            disabled={isPending}
            defaultValue={state.content ?? ""}
          />
          {state.errors && (
            <p className="mt-1 text-sm text-red-600">
              {state.errors.join(". ")}
            </p>
          )}
        </div>

        <button
          type="submit"
          disabled={isPending}
          className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-600 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
        >
          {isPending ? (
            <span className="flex items-center justify-center">
              Analysing Review...
            </span>
          ) : (
            "Analyse Review"
          )}
        </button>
      </form>
    </div>
  );
}

The form includes a textarea for users to input review content and a submit button. The action attribute is linked to formAction from the useActionState hook, which triggers the analyseReview Server Function when the form is submitted.

While the form is submitting, indicated by the isPending flag from useActionState, both the textarea and submit button are disabled to prevent further input.

The textarea’s defaultValue is set from the form state’s content property, ensuring that user input is retained if validation fails.

If there are validation errors, they are shown below the textarea using the errors array from the form state.

Displaying Review Analysis

Lastly, we will render the review analysis in a child of ReviewAnalysisForm:

export function ReviewAnalysisForm() {
  const [state, formAction, isPending] = useActionState(...);
  return (
    <div className="max-w-4xl mx-auto p-6 space-y-8">
      <h1 className="text-3xl text-center font-bold text-gray-900 mb-2">
        Email Enhancer
      </h1>
      <form action={formAction} className="space-y-6">...</form>

      {state.success && state.result && (
        <ReviewAnalysisDisplay
          analysis={state.result}
        />
      )}
    </div>
  );
}

ReviewAnalysisDisplay is only rendered if the form success state is true and we have some enhanced content. The analysis result is passed to EnhancedEmailDisplay.

Begin to implement EnhancedEmailDisplay by creating a new file at src/components/review-analysis-display.tsx and add the following code:

Here’s the definition for ReviewAnalysisDisplay:

import { type ReviewAnalysis } from "@/actions/analyse-review";

export function ReviewAnalysisDisplay({
  analysis,
}: {
  analysis: ReviewAnalysis;
}) {
  return <div className="space-y-6"></div>;
}

We’ve used the ReviewAnalysis type from the analyseReview server function as the props for the ReviewAnalysisDisplay component.

Render the overall sentiment:

export function ReviewAnalysisDisplay( ... ) {
  return (
    <div className="space-y-6">
      <div
        className={`border rounded-lg p-4 ${getSentimentColor(
          analysis.sentiment
        )}`}
      >
        <div className="flex items-center gap-3">
          <h3 className="text-lg font-semibold">Overall Sentiment:</h3>
          <div className="text-lg font-bold capitalize">
            {analysis.sentiment}
          </div>
        </div>
      </div>
    </div>
  )
}

const getSentimentColor = (sentiment: string) => {
  switch (sentiment) {
    case "positive":
      return "text-green-800 bg-green-50 border-green-200";
    case "negative":
      return "text-red-800 bg-red-50 border-red-200";
    case "mixed":
      return "text-yellow-800 bg-yellow-50 border-yellow-200";
    default:
      return "text-gray-800 bg-gray-50 border-gray-200";
  }
};

Render the category analysis beneath the overall sentiment:

export function ReviewAnalysisDisplay( ... ) {
  return (
    <div className="space-y-6">
      ...
      <div className="bg-white border border-gray-200 rounded-lg p-6">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">
          Category Analysis
        </h3>
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          {Object.entries(analysis.categories).map(([category, rating]) => (
            <div
              key={category}
              className={`p-3 rounded-md border ${getCategoryColor(rating)}`}
            >
              <div className="flex items-center justify-between">
                <span className="font-medium capitalize">
                  {category.replace("_", " ")}
                </span>
                <div className="flex items-center gap-1">
                  <span>{getCategoryIcon(rating)}</span>
                  <span className="text-sm font-medium capitalize">
                    {rating}
                  </span>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

const getCategoryColor = (rating: string) => {
  switch (rating) {
    case "good":
      return "text-green-700 bg-green-100";
    case "bad":
      return "text-red-700 bg-red-100";
    case "neutral":
      return "text-gray-700 bg-gray-100";
    default:
      return "text-gray-700 bg-gray-100";
  }
};

const getCategoryIcon = (rating: string) => {
  switch (rating) {
    case "good":
      return "👍";
    case "bad":
      return "👎";
    case "neutral":
      return "";
    default:
      return "";
  }
};

Lastly, render the insights under the category analysis:

export function ReviewAnalysisDisplay( ... ) {
  return (
    <div className="space-y-6">
      ...
      div className="bg-white border border-gray-200 rounded-lg p-6">
        <h3 className="text-lg font-semibold text-gray-900 mb-4">
          Key Insights
        </h3>
        <div className="space-y-4">
          {analysis.summary.main_praise && (
            <div className="p-4 bg-green-50 border border-green-200 rounded-md">
              <h4 className="font-medium text-green-900 mb-2">Main Praise</h4>
              <p className="text-green-800">{analysis.summary.main_praise}</p>
            </div>
          )}

          {analysis.summary.main_complaint && (
            <div className="p-4 bg-red-50 border border-red-200 rounded-md">
              <h4 className="font-medium text-red-900 mb-2">Main Complaint</h4>
              <p className="text-red-800">{analysis.summary.main_complaint}</p>
            </div>
          )}

          <div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
            <h4 className="font-medium text-blue-900 mb-2">
              Business Recommendation
            </h4>
            <p className="text-blue-800">{analysis.summary.recommendation}</p>
          </div>
        </div>
      </div>
    </div>
  )
}

Main Page Component

Our final coding task is to update app/page.tsx to use our form component. Replace the existing content with the following:

import { ReviewAnalysisForm } from "@/components/review-analysis-form";

export default function Home() {
  return (
    <main className="min-h-screen bg-gray-50 py-8">
      <ReviewAnalysisForm />
    </main>
  );
}

Running the Review Analyser

In a terminal, run the following command to start the app:

npm run dev

Navigate to http://localhost:3000 and enter review:

Review

Prior to submitting the form, open your browser’s DevTools and navigate to the Network tab. After submitting the form, you’ll see the analysis results displayed.

Analysis

When you submit the form, notice that a network request is made—a POST request sent to the root URL. The payload contains both the form field values and metadata specifying which Server Function should be executed, all organized as FormData.

Request payload

The server responds using a streaming protocol, where each line corresponds to an individual data chunk. The first line (line 0) contains metadata, such as a pointer to the Server Function, while the second line (line 1) holds the actual response data.

Response

Conclusion

This guide walked through building an AI-powered review analyser leveraging the latest features in React 19. By combining Server Functions, server-side validation, and the Vercel AI SDK, you gain a streamlined development workflow with simplified form management, automatic state handling, and type-safe AI integration—all without the complexity of traditional client-server coordination.