Build an AI Content Analyzer App
Stream structured AI analysis to a React frontend using Node.js and Gemini.
Introduction
30 Second Summary
AI features are everywhere now. Every SaaS company wants engineers who can integrate AI into real products, not just play with chatbots.
In this project, you will build a full-stack content analyzer that streams AI-generated structured analysis to a React frontend in real-time, powered by a Node.js backend and the Google Gemini API.
What You'll Build
A content analyzer web app where users paste text (job descriptions, articles, meeting notes) and receive structured AI analysis streamed from a Node.js backend powered by Google Gemini.
By the end of this project, you'll have:
- A Node.js/Express REST API with a streaming endpoint that integrates the Gemini SDK to produce validated JSON.
- A React/TypeScript frontend that consumes server-sent events and displays structured results in real-time.
- Production-grade patterns including error handling, loading states, input validation, and shared TypeScript types between frontend and backend.
- Secret Mission: Add a multi-mode analysis feature where users choose between different analysis types, each with their own structured schema.
Are there any prerequisites?
This project assumes intermediate knowledge of React, TypeScript, and Node.js. You should also have an existing Gemini API key from Google AI Studio (aistudio.google.com/apikey).
Set Up the Project Structure
Before writing any AI-powered features, you need a solid project foundation. A full-stack app with separate frontend and backend code needs a clear directory structure from the start.
In this step, you will create a monorepo-style folder layout with a server and client directory, install all dependencies, and verify your Gemini API key is working.
In this step, get ready to:
- Create the project directory and install server and client dependencies.
- Define shared TypeScript types for the AI analysis result.
- Configure your API key and verify it connects to Gemini.
Create the project directory and install dependencies
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and server subfolder, then move into the project by running these commands:
mkdir ai-content-analyzer
cd ai-content-analyzer
mkdir server
- Confirm the folders exist by running:
ls
You should see server in the output.
Now you will create the server's package.json file. This file tells Node.js which packages your server needs and how to run it.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the ai-content-analyzer folder on your Desktop, then click Open.
- In the Explorer sidebar, expand the server folder.
- Right-click on server and select New File.
- Name the file package.json and press Enter.
- Paste the following code into server/package.json:
{
"name": "ai-content-analyzer-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.ts --loader tsx",
"start": "npx tsx server.ts"
},
"dependencies": {
"@google/genai": "^2.8.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"tsx": "^4.19.4",
"typescript": "^5.7.0"
}
}
What does this file do?
The "type": "module" line enables ES module syntax (import/export) instead of the older require() style.
The scripts section defines two ways to run the server. The dev script watches for file changes and restarts automatically. The start script runs the server once using tsx, which lets Node.js run TypeScript files directly.
- Save server/package.json.
Now install the dependencies listed in this file.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Navigate into the server folder and install dependencies by running these commands:
cd server
npm install
- You should see output indicating packages were added, ending with a line like added X packages.
Seeing permission errors or EACCES?
Make sure you are inside the server folder when running npm install. Run pwd to check your current directory.
If you see network errors, check your internet connection and try running the command again.
Help me troubleshoot my npm install error.
Next, scaffold the React frontend using Vite.
- Navigate back to the project root by running:
cd ..
- Scaffold the React TypeScript client by running:
npm create vite@latest client -- --template react-ts
What will I see?
npm may ask "Need to install the following packages: create-vite@latest. Ok to proceed?" Press y then Enter to continue.
This command creates a client folder pre-configured with React, TypeScript, and a development server.
- Move into the client folder and install its dependencies by running:
cd client
npm install
- You should see output ending with added X packages.
- Navigate back to the project root by running:
cd ..
- Confirm both folders exist by running:
ls
You should see both server and client listed in the output.
Don't see client in the output?
Make sure you ran the Vite scaffold command from the ai-content-analyzer root folder, not from inside server. The client argument in the command tells Vite to create a folder named client in your current directory.
Help me fix my Vite scaffold issue.
✔️ Awesome, I've got everything!
Great. Make sure you have saved server/package.json and that both npm install commands completed successfully.
ⓧ I'd like to double check the full code
Here is the complete server/package.json file:
{
"name": "ai-content-analyzer-server",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "node --watch server.ts --loader tsx",
"start": "npx tsx server.ts"
},
"dependencies": {
"@google/genai": "^2.8.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.10.0",
"tsx": "^4.19.4",
"typescript": "^5.7.0"
}
}
Create the shared types file
Both the server and client need to agree on the shape of the AI analysis response. You will define an AnalysisResult interface that both sides can import from their own types.ts file.
- In the Explorer sidebar, right-click on the server folder and select New File.
- Name the file types.ts and press Enter.
- Paste the following code into server/types.ts:
export interface AnalysisResult {
summary: string;
sentiment: "positive" | "negative" | "neutral" | "mixed";
keyInsights: string[];
actionItems: string[];
}
What does this interface define?
The AnalysisResult interface describes the exact shape of data Gemini will return. Every response will have a text summary, a sentiment label (one of four values), an array of keyInsights, and an array of actionItems.
By defining this as a TypeScript interface, both your server and client get compile-time safety. If the structure changes, TypeScript catches mismatches before runtime.
- Save server/types.ts.
Now create the same interface for the client.
- In the Explorer sidebar, expand client then src.
- Right-click on src and select New File.
- Name the file types.ts and press Enter.
- Paste the following code into client/src/types.ts:
export interface AnalysisResult {
summary: string;
sentiment: "positive" | "negative" | "neutral" | "mixed";
keyInsights: string[];
actionItems: string[];
}
- Save client/src/types.ts.
Why duplicate the types file?
In a real production monorepo you might use a shared package to avoid duplication. For this project, keeping a copy in each folder is simpler and avoids extra tooling. Both files are identical, so any change to the data shape is easy to mirror.
✔️ Awesome, I've got everything!
Great. Make sure both server/types.ts and client/src/types.ts are saved.
ⓧ I'd like to double check the full code
Both files should contain the exact same code:
export interface AnalysisResult {
summary: string;
sentiment: "positive" | "negative" | "neutral" | "mixed";
keyInsights: string[];
actionItems: string[];
}
Configure the API key and verify it works
The Gemini API requires an API key to authenticate your requests. You will store this key in a .env file so it stays out of your source code.
- In the Explorer sidebar, right-click on the server folder and select New File.
- Name the file .env and press Enter.
- Paste the following into server/.env, replacing your-api-key-here with your actual Gemini API key:
GEMINI_API_KEY=your-api-key-here
Where do I find my Gemini API key?
You can create or copy your key from Google AI Studio at aistudio.google.com/apikey. If you already have a key, paste it directly.
- Save server/.env.
Now verify the key works by making a quick test call to Gemini.
- In the Explorer sidebar, right-click on the server folder and select New File.
- Name the file test.ts and press Enter.
- Paste the following code into server/test.ts:
import { GoogleGenAI } from "@google/genai";
import dotenv from "dotenv";
// Load environment variables from .env
dotenv.config();
// Initialize the Gemini client with your API key
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
// Make a simple test request
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: "Say hello in one sentence.",
});
console.log(response.text);
What does this test do?
This script loads your API key from the .env file, creates a Gemini client, and sends a simple prompt. If it prints a response, your key is valid and the SDK is working.
- Save server/test.ts.
- In your terminal, make sure you are inside the server folder. Run the test by executing:
cd server
npx tsx test.ts
You should see a short greeting printed in the terminal, something like "Hello! How can I help you today?" The exact wording will vary.
Seeing an API key error?
Double-check that your .env file is inside the server folder (not the project root) and that there are no spaces around the = sign.
Make sure you replaced your-api-key-here with your actual key from Google AI Studio. The key should start with AI and be a long alphanumeric string.
Help me fix my Gemini API key error.
- Once you see a successful response, delete the test file since you no longer need it. Run:
rm test.ts
- Navigate back to the project root by running:
cd ..
Your project structure is ready and your API key is verified. Next up, you will build the Express API endpoint that sends text to Gemini and streams the structured response back.
Build the Gemini-Powered API Endpoint
Your project structure is ready. Now it is time to build the brain of your application.
In this step, you will create a REST API endpoint that accepts user text, sends it to Google Gemini for analysis, and streams the structured results back in real-time using server-sent events.
In this step, get ready to:
- Create the Express server with middleware and Gemini SDK initialization.
- Define a structured output schema that forces Gemini to return validated JSON.
- Build a streaming endpoint that pipes AI-generated chunks to the client.
Create the Express server with CORS and Gemini SDK
The server file is the core of your backend. It sets up Express with the middleware needed to handle cross-origin requests from the React frontend and parse JSON request bodies.
- Create a new file called server.ts inside your server folder.
- Add the following code to set up the server, middleware, and Gemini client:
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { GoogleGenAI, Type } from "@google/genai";
// Load environment variables from .env file
dotenv.config();
const app = express();
const PORT = 3001;
// Enable cross-origin requests from the React frontend
app.use(cors());
// Parse incoming JSON request bodies
app.use(express.json());
// Initialize the Gemini client with your API key
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
What does this code do?
This sets up the foundation of your API server.
- dotenv.config() loads your GEMINI_API_KEY from the .env file into process.env.
- cors() allows the React app (running on a different port) to make requests to this server.
- GoogleGenAI creates a client instance that authenticates all your Gemini API calls.
Define the structured output schema
Gemini can return freeform text, but you want guaranteed JSON with specific fields. A response schema forces the model to return validated, structured data every time.
- Add the schema definition below your ai initialization in server.ts:
const analysisSchema = {
type: Type.OBJECT,
properties: {
summary: {
type: Type.STRING,
description: "A concise 2-3 sentence summary of the content",
},
sentiment: {
type: Type.STRING,
description: "Overall sentiment: positive, negative, neutral, or mixed",
},
keyInsights: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: "3-5 key insights or main points from the content",
},
actionItems: {
type: Type.ARRAY,
items: { type: Type.STRING },
description:
"Actionable next steps or recommendations based on the content",
},
},
required: ["summary", "sentiment", "keyInsights", "actionItems"],
};
What is a structured output schema?
The schema tells Gemini exactly what shape of JSON to produce. Each property has a type and a description that guides the model's output.
The Type import from @google/genai provides constants like Type.OBJECT, Type.STRING, and Type.ARRAY that match JSON Schema types. The required array ensures Gemini never omits a field.
Why structured outputs instead of parsing freeform text?
Without a schema, AI models can return responses in unpredictable formats. You would need fragile regex or string parsing to extract data. Structured outputs guarantee the response is valid JSON matching your TypeScript types, eliminating an entire category of bugs.
Build the streaming POST endpoint
Now you will create the endpoint that ties everything together. It validates incoming text, sets up a streaming connection, calls Gemini, and pipes each chunk back to the client as it arrives.
- Add the following route handler and server listener below your analysisSchema in server.ts:
app.post("/api/analyze", async (req, res) => {
const { text } = req.body;
// Validate that text is present and is a string
if (!text || typeof text !== "string") {
res.status(400).json({ error: "Text field is required" });
return;
}
// Enforce a maximum length to avoid excessive API usage
if (text.length > 10000) {
res.status(400).json({ error: "Text must be under 10,000 characters" });
return;
}
// Set headers to establish a server-sent events connection
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
// Call Gemini with streaming and structured output
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents: `Analyze the following content and provide a structured analysis:\n\n${text}`,
config: {
responseMimeType: "application/json",
responseSchema: analysisSchema,
},
});
// Pipe each chunk to the client as a server-sent event
for await (const chunk of response) {
const chunkText = chunk.text;
if (chunkText) {
res.write(`data: ${JSON.stringify({ chunk: chunkText })}\n\n`);
}
}
// Signal the stream is complete
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred";
const status = message.includes("429") ? 429 : 500;
if (!res.headersSent) {
res.status(status).json({ error: message });
} else {
res.write(
`data: ${JSON.stringify({ error: message })}\n\n`
);
res.end();
}
}
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this endpoint do?
This is a lot of code, so here is what each section handles:
- Input validation rejects empty text or text over 10,000 characters with clear error messages.
- The three SSE headers (Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive) tell the browser to keep the connection open and expect streaming data.
- ai.models.generateContentStream calls Gemini with your schema, returning an async iterable of text chunks.
- Each chunk is sent to the client as data: {"chunk": "..."}\n\n following the SSE protocol.
- Error handling detects rate limiting (HTTP 429) and gracefully reports errors whether headers have been sent yet or not.
- Save server.ts.
- Start the server by running this command from your server folder:
npx tsx server.ts
- You should see Server running on http://localhost:3001 printed in your terminal.
Don't see the server running message?
- Make sure you are running the command from inside the server folder, not the project root.
- Check that your .env file exists in the server folder and contains GEMINI_API_KEY=your-api-key-here (with your actual key).
- If you see a module not found error, confirm that you ran npm install in the server folder in Step 1.
Why isn't my Express server starting?
✔️ Awesome, I've got everything!
Great. Double check you have saved server.ts and that the server is running before moving on.
ⓧ I'd like to double check the full code
Here is the complete server/server.ts file:
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { GoogleGenAI, Type } from "@google/genai";
dotenv.config();
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const analysisSchema = {
type: Type.OBJECT,
properties: {
summary: {
type: Type.STRING,
description: "A concise 2-3 sentence summary of the content",
},
sentiment: {
type: Type.STRING,
description: "Overall sentiment: positive, negative, neutral, or mixed",
},
keyInsights: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: "3-5 key insights or main points from the content",
},
actionItems: {
type: Type.ARRAY,
items: { type: Type.STRING },
description:
"Actionable next steps or recommendations based on the content",
},
},
required: ["summary", "sentiment", "keyInsights", "actionItems"],
};
app.post("/api/analyze", async (req, res) => {
const { text } = req.body;
if (!text || typeof text !== "string") {
res.status(400).json({ error: "Text field is required" });
return;
}
if (text.length > 10000) {
res.status(400).json({ error: "Text must be under 10,000 characters" });
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents: `Analyze the following content and provide a structured analysis:\n\n${text}`,
config: {
responseMimeType: "application/json",
responseSchema: analysisSchema,
},
});
for await (const chunk of response) {
const chunkText = chunk.text;
if (chunkText) {
res.write(`data: ${JSON.stringify({ chunk: chunkText })}\n\n`);
}
}
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred";
const status = message.includes("429") ? 429 : 500;
if (!res.headersSent) {
res.status(status).json({ error: message });
} else {
res.write(
`data: ${JSON.stringify({ error: message })}\n\n`
);
res.end();
}
}
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your API is live and ready to stream AI-generated analysis. Next up, you will build the React frontend that connects to this endpoint and displays the streamed results in real-time.
Build the React Frontend with Streaming Display
Your API is built and streaming structured AI analysis over server-sent events. Now you need a frontend that can consume that stream in real-time and display the results as they arrive.
In this step, you will build the React frontend that sends text to your API, reads the streamed response chunk by chunk, and renders a polished results display once the analysis is complete.
In this step, get ready to:
- Set up the App component and clean up Vite boilerplate.
- Implement streaming fetch logic to consume server-sent events.
- Build a results display with a thinking animation, streaming indicator, and structured cards.
Set up the App component and Analyzer shell
The Vite template created boilerplate files in your client/src/ folder. You will replace them with your own App component that renders a header and the Analyzer component.
- In your code editor, open client/src/App.tsx and replace its entire contents with the following:
import { useState } from "react";
import { Analyzer } from "./components/Analyzer";
import "./App.css";
function App() {
return (
<div className="app">
<header className="app-header">
<h1>AI Content Analyzer</h1>
<p>Paste any text and get structured AI-powered analysis in real-time</p>
</header>
<main>
<Analyzer />
</main>
</div>
);
}
export default App;
What does this code do?
The App component renders a simple page layout with a header and a main section.
The Analyzer component (which you will create next) handles all the input, streaming, and display logic. Keeping it separate from App makes the code easier to maintain.
- Now open client/src/App.css and replace its entire contents with the following:
.app {
min-height: 100vh;
background: #f8fafc;
}
.app-header {
text-align: center;
padding: 2rem 1rem;
border-bottom: 1px solid #e2e8f0;
background: white;
}
.app-header h1 {
margin: 0;
font-size: 1.75rem;
color: #1e293b;
}
.app-header p {
margin: 0.5rem 0 0;
color: #64748b;
}
What does this code do?
These styles give the app a light gray background with a clean white header. The header is centered with a bottom border to visually separate it from the main content area below.
Next, you will create the Analyzer component. This is where the real action happens.
- Create a new folder called components inside client/src/.
- Inside that folder, create a new file called Analyzer.tsx.
- Paste the following code into client/src/components/Analyzer.tsx:
import { useState } from "react";
import type { AnalysisResult } from "../types";
import "./Analyzer.css";
export function Analyzer() {
const [text, setText] = useState("");
const [result, setResult] = useState<AnalysisResult | null>(null);
const [streamedText, setStreamedText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const MAX_LENGTH = 10000;
async function handleAnalyze() {
if (!text.trim()) return;
}
return (
<div className="analyzer">
<div className="input-section">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste your text here (job description, article, meeting notes...)"
rows={8}
maxLength={MAX_LENGTH}
/>
<div className="input-footer">
<span className="char-count">
{text.length}/{MAX_LENGTH}
</span>
<button onClick={handleAnalyze} disabled={isLoading || !text.trim()}>
{isLoading ? "Analyzing..." : "Analyze"}
</button>
</div>
</div>
</div>
);
}
What does this code do?
- Five useState hooks track the input text, the parsed result, the raw streamed text, loading state, and any errors.
- MAX_LENGTH caps input at 10,000 characters, matching your server-side validation.
- The button is disabled when loading or when the textarea is empty, preventing duplicate requests.
- handleAnalyze is a stub for now. You will fill it in next.
- Create another new file in the same components folder called Analyzer.css.
- Paste the following styles into client/src/components/Analyzer.css:
.analyzer {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
}
.input-section textarea {
width: 100%;
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
.input-section textarea:focus {
outline: none;
border-color: #3b82f6;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.char-count {
font-size: 0.875rem;
color: #64748b;
}
button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
What does this code do?
These styles center the analyzer at a max width of 720px, give the textarea a clean bordered look with a blue focus ring, and style the button with a blue background that dims when disabled.
- Save all four files: App.tsx, App.css, Analyzer.tsx, and Analyzer.css.
- Start the Vite dev server by running this command from the client folder:
npm run dev
- Open the URL shown in your terminal (usually http://localhost:5173). You should see the header "AI Content Analyzer" with a textarea and an Analyze button below it.
Seeing an import error?
If your browser shows an error about ./components/Analyzer not being found, double-check that you created the components folder inside client/src/ (not client/ directly).
Also confirm the file is named Analyzer.tsx with a capital A, matching the import statement.
Help me debug the Analyzer import error
Implement the streaming fetch logic
The button exists, but clicking it does nothing yet. You need to fill in the handleAnalyze function so it sends text to your API, reads the server-sent event stream, and accumulates the response.
- In client/src/components/Analyzer.tsx, find the handleAnalyze function. It currently looks like this:
async function handleAnalyze() {
if (!text.trim()) return;
}
- Replace the entire handleAnalyze function with the following:
async function handleAnalyze() {
if (!text.trim()) return;
setIsLoading(true);
setError(null);
setResult(null);
setStreamedText("");
try {
const response = await fetch("http://localhost:3001/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response stream available");
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.error) {
throw new Error(data.error);
}
if (data.chunk) {
accumulated += data.chunk;
setStreamedText(accumulated);
}
if (data.done) {
try {
const parsed: AnalysisResult = JSON.parse(accumulated);
setResult(parsed);
setStreamedText("");
} catch {
throw new Error("Failed to parse analysis result");
}
}
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsLoading(false);
}
}
What does this code do?
- The function sends a POST request to your Express API with the textarea content as the body.
- response.body.getReader() gives you a ReadableStream reader. This is how the browser consumes server-sent events incrementally instead of waiting for the entire response.
- Each chunk from the reader is decoded and split by \n\n (the SSE delimiter your server uses).
- Lines prefixed with data: are parsed as JSON. If the payload has a chunk field, it gets appended to the accumulated string and React re-renders the streaming preview.
- When the server sends { done: true }, the accumulated JSON string is parsed into the typed AnalysisResult object.
- Save Analyzer.tsx.
Seeing a CORS error in the console?
Make sure your Express server (from the previous step) is still running on port 3001. The server already has cors() middleware enabled, so cross-origin requests from port 5173 should work.
If you stopped the server, restart it by running npm start from the server folder.
Help me debug this CORS error
Build the results display
The fetch logic works, but the UI currently shows nothing while streaming or after completion. You need conditional rendering that transitions through three states: thinking, streaming, and final results.
- In Analyzer.tsx, find the closing </div> of the input-section div. Add the following JSX immediately after it (but still inside the outermost analyzer div):
{error && <div className="error-message">{error}</div>}
{isLoading && !streamedText && (
<div className="thinking">
<div className="thinking-dots">
<span></span><span></span><span></span>
</div>
<p>AI is analyzing your content...</p>
</div>
)}
{streamedText && !result && (
<div className="streaming">
<p className="streaming-label">Receiving analysis...</p>
<pre className="streaming-text">{streamedText}</pre>
</div>
)}
{result && (
<div className="results">
<div className="result-card">
<h3>Summary</h3>
<p>{result.summary}</p>
</div>
<div className="result-card">
<h3>Sentiment</h3>
<span className={`sentiment-badge sentiment-${result.sentiment}`}>
{result.sentiment}
</span>
</div>
<div className="result-card">
<h3>Key Insights</h3>
<ul>
{result.keyInsights.map((insight, i) => (
<li key={i}>{insight}</li>
))}
</ul>
</div>
<div className="result-card">
<h3>Action Items</h3>
<ul>
{result.actionItems.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
)}
What does this code do?
- Error state: if error is set, a red error message appears.
- Thinking state: when loading starts but no chunks have arrived yet, bouncing dots animate to show the AI is processing.
- Streaming state: once chunks start arriving, the raw JSON text streams into a <pre> block so you can see the data building up in real-time.
- Results state: once parsing completes, four cards display the summary, a color-coded sentiment badge, key insights as a list, and action items as a list.
Now add the remaining CSS styles for the error, thinking, streaming, and results sections.
- Open client/src/components/Analyzer.css and add the following styles at the bottom of the file:
.error-message {
margin-top: 1rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
}
.thinking {
text-align: center;
padding: 2rem;
color: #64748b;
}
.thinking-dots span {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 4px;
background: #3b82f6;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.thinking-dots span:nth-child(1) { animation-delay: -0.32s; }
.thinking-dots span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.streaming {
margin-top: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.streaming-label {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.5rem;
}
.streaming-text {
font-size: 0.8rem;
color: #475569;
white-space: pre-wrap;
word-break: break-word;
}
.results {
margin-top: 1.5rem;
display: grid;
gap: 1rem;
}
.result-card {
padding: 1.25rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.result-card h3 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.result-card p {
margin: 0;
line-height: 1.6;
}
.result-card ul {
margin: 0;
padding-left: 1.25rem;
}
.result-card li {
margin-bottom: 0.5rem;
line-height: 1.5;
}
.sentiment-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-transform: capitalize;
}
.sentiment-positive { background: #dcfce7; color: #166534; }
.sentiment-negative { background: #fef2f2; color: #991b1b; }
.sentiment-neutral { background: #f1f5f9; color: #475569; }
.sentiment-mixed { background: #fef3c7; color: #92400e; }
What does this code do?
- The thinking-dots use a CSS keyframe animation that scales dots up and down with staggered delays, creating a bouncing effect.
- Result cards have a white background with subtle borders, giving each section its own visual container.
- The sentiment-badge classes use different background colors per sentiment value: green for positive, red for negative, gray for neutral, and amber for mixed.
- Save both Analyzer.tsx and Analyzer.css.
- Make sure your server is still running (npm start from the server folder).
- Return to your browser at http://localhost:5173.
- Paste any text into the textarea (a paragraph from a news article works well) and click Analyze.
- You should see bouncing dots appear, then raw JSON streaming in, and finally four structured result cards with a summary, sentiment badge, key insights, and action items.
Button stays disabled or nothing happens?
Check that you are typing or pasting text into the textarea. The button is disabled when the textarea is empty or contains only whitespace.
If clicking Analyze does nothing visible, open your browser's DevTools console (F12) and look for errors. A network error means the server is not running on port 3001.
Help me debug why my Analyze button isn't working
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step.
import { useState } from "react";
import { Analyzer } from "./components/Analyzer";
import "./App.css";
function App() {
return (
<div className="app">
<header className="app-header">
<h1>AI Content Analyzer</h1>
<p>Paste any text and get structured AI-powered analysis in real-time</p>
</header>
<main>
<Analyzer />
</main>
</div>
);
}
export default App;
.app {
min-height: 100vh;
background: #f8fafc;
}
.app-header {
text-align: center;
padding: 2rem 1rem;
border-bottom: 1px solid #e2e8f0;
background: white;
}
.app-header h1 {
margin: 0;
font-size: 1.75rem;
color: #1e293b;
}
.app-header p {
margin: 0.5rem 0 0;
color: #64748b;
}
import { useState } from "react";
import type { AnalysisResult } from "../types";
import "./Analyzer.css";
export function Analyzer() {
const [text, setText] = useState("");
const [result, setResult] = useState<AnalysisResult | null>(null);
const [streamedText, setStreamedText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const MAX_LENGTH = 10000;
async function handleAnalyze() {
if (!text.trim()) return;
setIsLoading(true);
setError(null);
setResult(null);
setStreamedText("");
try {
const response = await fetch("http://localhost:3001/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response stream available");
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.error) {
throw new Error(data.error);
}
if (data.chunk) {
accumulated += data.chunk;
setStreamedText(accumulated);
}
if (data.done) {
try {
const parsed: AnalysisResult = JSON.parse(accumulated);
setResult(parsed);
setStreamedText("");
} catch {
throw new Error("Failed to parse analysis result");
}
}
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsLoading(false);
}
}
return (
<div className="analyzer">
<div className="input-section">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste your text here (job description, article, meeting notes...)"
rows={8}
maxLength={MAX_LENGTH}
/>
<div className="input-footer">
<span className="char-count">
{text.length}/{MAX_LENGTH}
</span>
<button onClick={handleAnalyze} disabled={isLoading || !text.trim()}>
{isLoading ? "Analyzing..." : "Analyze"}
</button>
</div>
</div>
{error && <div className="error-message">{error}</div>}
{isLoading && !streamedText && (
<div className="thinking">
<div className="thinking-dots">
<span></span><span></span><span></span>
</div>
<p>AI is analyzing your content...</p>
</div>
)}
{streamedText && !result && (
<div className="streaming">
<p className="streaming-label">Receiving analysis...</p>
<pre className="streaming-text">{streamedText}</pre>
</div>
)}
{result && (
<div className="results">
<div className="result-card">
<h3>Summary</h3>
<p>{result.summary}</p>
</div>
<div className="result-card">
<h3>Sentiment</h3>
<span className={`sentiment-badge sentiment-${result.sentiment}`}>
{result.sentiment}
</span>
</div>
<div className="result-card">
<h3>Key Insights</h3>
<ul>
{result.keyInsights.map((insight, i) => (
<li key={i}>{insight}</li>
))}
</ul>
</div>
<div className="result-card">
<h3>Action Items</h3>
<ul>
{result.actionItems.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}
.analyzer {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
}
.input-section textarea {
width: 100%;
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
.input-section textarea:focus {
outline: none;
border-color: #3b82f6;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.char-count {
font-size: 0.875rem;
color: #64748b;
}
button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
}
.thinking {
text-align: center;
padding: 2rem;
color: #64748b;
}
.thinking-dots span {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 4px;
background: #3b82f6;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.thinking-dots span:nth-child(1) { animation-delay: -0.32s; }
.thinking-dots span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.streaming {
margin-top: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.streaming-label {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.5rem;
}
.streaming-text {
font-size: 0.8rem;
color: #475569;
white-space: pre-wrap;
word-break: break-word;
}
.results {
margin-top: 1.5rem;
display: grid;
gap: 1rem;
}
.result-card {
padding: 1.25rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.result-card h3 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.result-card p {
margin: 0;
line-height: 1.6;
}
.result-card ul {
margin: 0;
padding-left: 1.25rem;
}
.result-card li {
margin-bottom: 0.5rem;
line-height: 1.5;
}
.sentiment-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-transform: capitalize;
}
.sentiment-positive { background: #dcfce7; color: #166534; }
.sentiment-negative { background: #fef2f2; color: #991b1b; }
.sentiment-neutral { background: #f1f5f9; color: #475569; }
.sentiment-mixed { background: #fef3c7; color: #92400e; }
Your frontend is now fully connected to the streaming API. Next up, you will add production polish with error handling, input validation, and loading animations that make this app portfolio-ready.
Add Production Polish
Your streaming pipeline works end-to-end. A user can paste text, watch chunks arrive in real-time, and see structured results.
But a production app needs to handle things going wrong gracefully. In this step, you will review the error handling and UI polish patterns already in your code, then verify the full experience works as expected.
In this step, get ready to:
- Verify error handling catches rate limits, invalid input, and mid-stream failures.
- Test loading states, the character counter, and the thinking animation.
- Confirm the app looks portfolio-presentable with polished CSS.
Review the error handling on both ends
Production APIs fail in predictable ways. Users send empty input, exceed length limits, or hit rate limits when they send too many requests. Your server already handles all three scenarios.
- In your code editor, open server/server.ts.
- Find the app.post("/api/analyze") route handler and review the validation at the top:
if (!text || typeof text !== "string") {
res.status(400).json({ error: "Text field is required" });
return;
}
if (text.length > 10000) {
res.status(400).json({ error: "Text must be under 10,000 characters" });
return;
}
What do these guards do?
The first check rejects requests where text is missing or not a string. This catches malformed requests from other clients or bots.
The second check rejects input over 10,000 characters. This prevents abuse and keeps your Gemini API usage predictable. Both respond with a 400 status before the SSE stream starts, so the client can parse the error as normal JSON.
- Now scroll down to the catch block at the bottom of the route handler:
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred";
const status = message.includes("429") ? 429 : 500;
if (!res.headersSent) {
res.status(status).json({ error: message });
} else {
res.write(
`data: ${JSON.stringify({ error: message })}\n\n`
);
res.end();
}
}
Why check headersSent?
If Gemini fails before you start streaming, the SSE headers have not been sent yet. In that case, you respond with a normal JSON error. If Gemini fails mid-stream (after headers are already sent), you write the error as an SSE event so the client can still read it.
The 429 detection checks if the error message mentions a rate limit. This lets the client show a specific "too many requests" message instead of a generic failure.
- Now switch to client/src/components/Analyzer.tsx and find the handleAnalyze function's fetch logic:
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Request failed: ${response.status}`);
}
How does the client handle errors?
If the server responds with a non-OK status (400, 429, or 500), the client parses the JSON error body and throws it. The catch block at the bottom then stores the error message in state, which renders in the red error-message div.
Mid-stream errors also work because the streaming loop checks for data.error in each SSE event and throws if found. The user sees the same friendly error message regardless of when the failure happens.
- Test error handling by opening your app in the browser.
- Try clicking Analyze with no text entered. The button should be disabled (grayed out) and nothing happens.
- Paste a short sentence like Hello world and click Analyze. Confirm you see the thinking animation followed by streamed results.
Seeing a red error message?
If you see an error about the text field being required, your server might not be running. Switch to the terminal where your server runs and restart it.
If you see a rate limit error, the Gemini free tier has request-per-minute limits. Wait 60 seconds and try again.
Help me debug my error message
Test the loading states and visual polish
A polished app communicates what is happening at every stage. Your UI has three distinct visual states between clicking Analyze and seeing results.
- Return to Analyzer.tsx and find the character counter in the JSX return:
<div className="input-footer">
<span className="char-count">
{text.length}/{MAX_LENGTH}
</span>
<button onClick={handleAnalyze} disabled={isLoading || !text.trim()}>
{isLoading ? "Analyzing..." : "Analyze"}
</button>
</div>
What does this section do?
The character counter shows text.length/MAX_LENGTH so users know how much space they have left. The button disables itself during loading or when the textarea is empty, preventing duplicate submissions.
The button text also swaps to "Analyzing..." during the request. These small touches tell the user their click registered.
- Find the thinking animation JSX just below the error display:
{isLoading && !streamedText && (
<div className="thinking">
<div className="thinking-dots">
<span></span><span></span><span></span>
</div>
<p>AI is analyzing your content...</p>
</div>
)}
{streamedText && !result && (
<div className="streaming">
<p className="streaming-label">Receiving analysis...</p>
<pre className="streaming-text">{streamedText}</pre>
</div>
)}
How do the three states work together?
- State 1: When isLoading is true but no chunks have arrived yet, the bouncing dots animation appears. This covers the latency between your request and Gemini's first response.
- State 2: Once the first chunk arrives (streamedText is not empty), the raw JSON streams in a monospace block so users see progress.
- State 3: When the stream finishes and the JSON parses successfully, the formatted result cards appear.
- Switch to your browser and paste a longer block of text (a paragraph from any article or email).
- Click Analyze and watch the full cycle: thinking dots, streaming JSON, then structured results.
- Check that the character counter updates as you type.
- Verify the sentiment badge has a colored background (green for positive, red for negative, gray for neutral, amber for mixed).
Why is this portfolio-ready?
This app demonstrates exactly what "AI first" SaaS companies look for: structured outputs from an LLM, real-time streaming to the browser, defensive error handling, and a clean user experience. Each pattern maps directly to production AI features.
Not seeing the thinking animation?
The thinking dots only appear for the brief moment between clicking Analyze and receiving the first streamed chunk. If your network is fast, it flashes by quickly. Try with a longer text input to see it more clearly.
If the button stays disabled after an error, check that the finally block in handleAnalyze sets setIsLoading(false). This ensures loading resets regardless of success or failure.
Help me debug my loading states
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete server/server.ts:
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { GoogleGenAI, Type } from "@google/genai";
dotenv.config();
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const analysisSchema = {
type: Type.OBJECT,
properties: {
summary: {
type: Type.STRING,
description: "A concise 2-3 sentence summary of the content",
},
sentiment: {
type: Type.STRING,
description: "Overall sentiment: positive, negative, neutral, or mixed",
},
keyInsights: {
type: Type.ARRAY,
items: { type: Type.STRING },
description: "3-5 key insights or main points from the content",
},
actionItems: {
type: Type.ARRAY,
items: { type: Type.STRING },
description:
"Actionable next steps or recommendations based on the content",
},
},
required: ["summary", "sentiment", "keyInsights", "actionItems"],
};
app.post("/api/analyze", async (req, res) => {
const { text } = req.body;
if (!text || typeof text !== "string") {
res.status(400).json({ error: "Text field is required" });
return;
}
if (text.length > 10000) {
res.status(400).json({ error: "Text must be under 10,000 characters" });
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
try {
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents: `Analyze the following content and provide a structured analysis:\n\n${text}`,
config: {
responseMimeType: "application/json",
responseSchema: analysisSchema,
},
});
for await (const chunk of response) {
const chunkText = chunk.text;
if (chunkText) {
res.write(`data: ${JSON.stringify({ chunk: chunkText })}\n\n`);
}
}
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
} catch (error: unknown) {
const message =
error instanceof Error ? error.message : "An unexpected error occurred";
const status = message.includes("429") ? 429 : 500;
if (!res.headersSent) {
res.status(status).json({ error: message });
} else {
res.write(
`data: ${JSON.stringify({ error: message })}\n\n`
);
res.end();
}
}
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Here is the complete client/src/components/Analyzer.tsx:
import { useState } from "react";
import type { AnalysisResult } from "../types";
import "./Analyzer.css";
export function Analyzer() {
const [text, setText] = useState("");
const [result, setResult] = useState<AnalysisResult | null>(null);
const [streamedText, setStreamedText] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const MAX_LENGTH = 10000;
async function handleAnalyze() {
if (!text.trim()) return;
setIsLoading(true);
setError(null);
setResult(null);
setStreamedText("");
try {
const response = await fetch("http://localhost:3001/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Request failed: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response stream available");
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n\n");
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = JSON.parse(line.slice(6));
if (data.error) {
throw new Error(data.error);
}
if (data.chunk) {
accumulated += data.chunk;
setStreamedText(accumulated);
}
if (data.done) {
try {
const parsed: AnalysisResult = JSON.parse(accumulated);
setResult(parsed);
setStreamedText("");
} catch {
throw new Error("Failed to parse analysis result");
}
}
}
}
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setIsLoading(false);
}
}
return (
<div className="analyzer">
<div className="input-section">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste your text here (job description, article, meeting notes...)"
rows={8}
maxLength={MAX_LENGTH}
/>
<div className="input-footer">
<span className="char-count">
{text.length}/{MAX_LENGTH}
</span>
<button onClick={handleAnalyze} disabled={isLoading || !text.trim()}>
{isLoading ? "Analyzing..." : "Analyze"}
</button>
</div>
</div>
{error && <div className="error-message">{error}</div>}
{isLoading && !streamedText && (
<div className="thinking">
<div className="thinking-dots">
<span></span><span></span><span></span>
</div>
<p>AI is analyzing your content...</p>
</div>
)}
{streamedText && !result && (
<div className="streaming">
<p className="streaming-label">Receiving analysis...</p>
<pre className="streaming-text">{streamedText}</pre>
</div>
)}
{result && (
<div className="results">
<div className="result-card">
<h3>Summary</h3>
<p>{result.summary}</p>
</div>
<div className="result-card">
<h3>Sentiment</h3>
<span className={`sentiment-badge sentiment-${result.sentiment}`}>
{result.sentiment}
</span>
</div>
<div className="result-card">
<h3>Key Insights</h3>
<ul>
{result.keyInsights.map((insight, i) => (
<li key={i}>{insight}</li>
))}
</ul>
</div>
<div className="result-card">
<h3>Action Items</h3>
<ul>
{result.actionItems.map((item, i) => (
<li key={i}>{item}</li>
))}
</ul>
</div>
</div>
)}
</div>
);
}
Here is the complete client/src/components/Analyzer.css:
.analyzer {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
}
.input-section textarea {
width: 100%;
padding: 1rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
resize: vertical;
transition: border-color 0.2s;
}
.input-section textarea:focus {
outline: none;
border-color: #3b82f6;
}
.input-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.5rem;
}
.char-count {
font-size: 0.875rem;
color: #64748b;
}
button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-message {
margin-top: 1rem;
padding: 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
color: #dc2626;
}
.thinking {
text-align: center;
padding: 2rem;
color: #64748b;
}
.thinking-dots span {
display: inline-block;
width: 8px;
height: 8px;
margin: 0 4px;
background: #3b82f6;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out both;
}
.thinking-dots span:nth-child(1) { animation-delay: -0.32s; }
.thinking-dots span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.streaming {
margin-top: 1rem;
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
}
.streaming-label {
font-size: 0.875rem;
color: #64748b;
margin-bottom: 0.5rem;
}
.streaming-text {
font-size: 0.8rem;
color: #475569;
white-space: pre-wrap;
word-break: break-word;
}
.results {
margin-top: 1.5rem;
display: grid;
gap: 1rem;
}
.result-card {
padding: 1.25rem;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.result-card h3 {
margin: 0 0 0.75rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.result-card p {
margin: 0;
line-height: 1.6;
}
.result-card ul {
margin: 0;
padding-left: 1.25rem;
}
.result-card li {
margin-bottom: 0.5rem;
line-height: 1.5;
}
.sentiment-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-transform: capitalize;
}
.sentiment-positive { background: #dcfce7; color: #166534; }
.sentiment-negative { background: #fef2f2; color: #991b1b; }
.sentiment-neutral { background: #f1f5f9; color: #475569; }
.sentiment-mixed { background: #fef3c7; color: #92400e; }
Your app is complete and production-polished. It handles errors gracefully, communicates loading progress, and presents results in a clean layout. Next up is an optional Secret Mission to extend the app with multiple analysis modes.
Secret mission
Multi-Mode Analysis
Your analyzer currently treats all text the same way. What if it could detect different content types and extract mode-specific insights? Extend the app with a mode selector that switches between General Analysis, Job Description Analyzer, and Meeting Notes Extractor, each with its own structured schema.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing cloud costs. Your Gemini API usage stays within the free tier, which means no charges.
Resources you used:
- Local project folder containing server/ and client/ directories.
- Running Node.js server process (port 3001).
- Running Vite dev server for the React client.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with your content analyzer.
- Your server and client dev servers continue running in their terminal windows.
- Your project folder stays on your Desktop for future development.
- Your Gemini API key remains configured in server/.env.
Pause - I'll come back to this later
Stop the running processes to free up terminal sessions and memory. Your project files stay intact.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Switch to the terminal running your Vite client and press Ctrl+C to stop it.
- Your project folder remains on your Desktop. To restart later, open two terminals, run npx tsx server.ts in the server/ folder and npm run dev in the client/ folder.
Delete - I don't want to use this again
Remove all project files and stop all running processes.
- Switch to the terminal running your server and press Ctrl+C to stop it.
- Switch to the terminal running your Vite client and press Ctrl+C to stop it.
- Delete the project folder by running this command:
macOS
cd ~/Desktop
rm -rf ai-content-analyzer
Windows
cd ~/Desktop
rmdir /s /q ai-content-analyzer
- Confirm the folder is gone by running:
ls
You should no longer see the ai-content-analyzer folder in the output.
Nice Work!
Nice Work!
You just built a full-stack AI-powered content analyzer from scratch using React, TypeScript, Node.js, and the Google Gemini API. This is exactly the kind of project that proves you can ship production-grade AI features end-to-end.
What you learned:
- Built a Node.js/Express REST API that integrates the Google Gemini SDK with generateContentStream for real-time streaming responses.
- Implemented structured AI outputs using JSON schema validation to force the model to return typed, predictable data.
- Created a server-sent events streaming pipeline from API to browser, with a React frontend that consumes chunks in real-time using the Fetch API and getReader().
- Secret Mission: Extended the app with multi-mode analysis supporting different content types and schemas.
Ready to quiz yourself?