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 fromanalyseReview
, such as the success flag, enhanced content, or any errors.formAction
: A function intended for the form’saction
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’strue
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:
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.
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
.
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.
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.