Build a Full-Stack AI Content Analyzer

Build a React and Express app that uses Gemini AI to analyze text.

Introduction

30 Second Summary

We read more content online than ever before. But carefully reading and summarizing everything yourself takes time.

In this project, you will build BriefAI, a full-stack web application that sends any text to Google's Gemini AI and returns a structured analysis. You will create the React frontend, Express backend, and PostgreSQL database all from scratch.

What You'll Build

BriefAI is a content analysis dashboard where you paste in any article or essay, send it to Gemini AI for structured analysis, and manage your saved results in a responsive dashboard.

By the end of this project, you'll have:

  • An Express.js REST API with TypeScript, featuring full CRUD endpoints for creating, reading, updating, and deleting analyses.
  • A PostgreSQL database managed by Prisma ORM, with migrations and type-safe queries.
  • A React frontend styled with Tailwind CSS, featuring a responsive dashboard with search, sort, and detail views.
  • Secret Mission: Add a tagging system so you can categorize and filter your analyses by custom tags.

Are there any prerequisites?

You should have Node.js v20 or later, VS Code, and Git already installed. Step 1 walks you through installing PostgreSQL and verifying your full setup.

Install PostgreSQL and Verify Your Tools

Every analysis that BriefAI creates needs a permanent home. PostgreSQL is the database that will store your AI-generated summaries, sentiment scores, and reading insights.

In this step, you'll get PostgreSQL running on your machine, create a fresh database for the project, and confirm that the rest of your development tools are in place.

In this step, get ready to:
  • Install PostgreSQL and confirm the server is running.
  • Create the briefai database.
  • Verify Node.js, npm, and Git are installed.
Download and install PostgreSQL

PostgreSQL is a powerful open-source relational database. You need to install the server, make sure it's running, and confirm the command line tools are accessible.

macOS

  • Install PostgreSQL using Homebrew by running this command:
brew install postgresql@17

Don't have Homebrew?

Homebrew is the standard package manager for macOS. Install it first by following the instructions at brew.sh, then come back and run the command above.

  • Add the PostgreSQL binaries to your PATH by running:
echo 'export PATH="/opt/homebrew/opt/postgresql@17/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
  • Start the PostgreSQL service by running:
brew services start postgresql@17

You should see a message confirming the service has started successfully.

Windows

  • Open your browser and go to postgresql.org/download/windows.
  • Download the latest PostgreSQL installer (version 16.x or 17.x).
  • Run the installer.
  • Keep all default components selected (PostgreSQL Server, pgAdmin, Command Line Tools).
  • When prompted, set a password for the postgres superuser.
  • Keep the default port as 5432.
  • Click Next through the remaining screens and finish the installation.

Remember your password

Write down the password you chose for the postgres superuser. You will need it every time you connect to the database.

  • After installation, press the Windows key to open your search bar.
  • Type pgAdmin and press Enter to open it.

If pgAdmin opens and shows your PostgreSQL server in the left sidebar, the installation is working.

  • Verify the PostgreSQL command line tools are available by running:
psql --version

✔️ I see a version number

You should see output like psql (PostgreSQL) 17.x. PostgreSQL is ready to go.

ⓧ Command not found

The PostgreSQL binaries are not on your system PATH. Follow the fix for your operating system below.

macOS

  • Link the PostgreSQL binaries by running:
brew link postgresql@17
  • Run psql --version again to confirm.

Windows

You need to add the PostgreSQL bin folder to your system PATH.

  • Press the Windows key and search for Environment Variables.
  • Click Edit the system environment variables.
  • Click Environment Variables.
  • Under System variables, find and select Path.
  • Click Edit.
  • Click New and add C:\Program Files\PostgreSQL\17\bin (adjust the version number if you installed a different version).
  • Click OK on all open dialogs to save.
  • Close and reopen your terminal, then run psql --version again.

What is PostgreSQL?

PostgreSQL is an open-source relational database. It stores data in structured tables with rows and columns, similar to a spreadsheet.

BriefAI will use it to permanently store every AI-generated analysis so you can browse, search, and manage your results later.

Create the BriefAI database

PostgreSQL is installed, but it does not yet have a database for your project. You need to create one called briefai.

macOS

  • Create the briefai database by running this command:
createdb briefai
  • Verify the database was created by listing all databases:
psql -l

You should see briefai in the list of databases.

Why no password on macOS?

Homebrew's PostgreSQL install uses your macOS username as the default database user with no password. This is fine for local development.

Windows

  • Connect to PostgreSQL by running this command:
psql -U postgres
  • Enter your postgres password when prompted.
  • Create the briefai database by running this SQL command:
CREATE DATABASE briefai;
  • Verify the database was created by listing all databases:
\l

You should see briefai in the list of databases.

  • Exit psql by running:
\q

What did these commands do?

  • CREATE DATABASE briefai; (or createdb briefai on macOS) creates a new, empty database named briefai on your PostgreSQL server.
  • \l (or psql -l) lists every database on the server so you can confirm yours exists.

Don't see briefai in the list?

  • Make sure you included the semicolon at the end of CREATE DATABASE briefai;. Without it, PostgreSQL waits for more input instead of running the command.
  • If you see connection refused, the PostgreSQL server may not be running. On macOS, run brew services start postgresql@17. On Windows, open Services (search in Start Menu) and make sure the PostgreSQL service is set to Running.

Help me troubleshoot my PostgreSQL database creation

Verify your other tools and create the project folder

BriefAI's backend and frontend both rely on Node.js and npm. You also need Git for version control. All three should already be on your machine.

  • Confirm each tool is installed by running these commands one at a time:
node --version
npm --version
git --version

✔️ I see version numbers for all three

You should see v20 or higher for Node.js. Any recent version of npm and Git is fine.

ⓧ One of the commands failed

  • If node --version fails, download and install Node.js v20 or later from nodejs.org. The installer includes npm.
  • If git --version fails, download and install Git from git-scm.com.

After installing, close and reopen your terminal, then run the version commands again.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project root folder and move into it by running these commands:
mkdir briefai
cd briefai
  • Confirm the folder was created by running:
ls

The folder will be empty right now. That is expected.

Seeing a 'no such file or directory' error?

  • Make sure you ran cd ~/Desktop before running mkdir briefai. The tilde (~) refers to your home directory.

Help me create the briefai project folder on my Desktop

Your database is running and your development tools are ready. Next up, you'll scaffold the Express backend and start building the API that powers BriefAI.

Scaffold the Express + TypeScript Backend

Your database is live and your tools are verified. Now it is time to build the server that will power BriefAI.

Every full-stack app needs a backend to handle requests, talk to the database, and run business logic. You will scaffold an Express.js server written in TypeScript, install all the packages it needs, and confirm everything runs before writing any real features.

In this step, get ready to:
  • Initialize an npm project and install Express, TypeScript, and supporting packages.
  • Create the folder structure and core server files.
  • Start the dev server and confirm the health check endpoint responds.
Initialize the backend project

The backend lives in its own folder inside the briefai project root. This keeps the server code separate from the frontend you will build later.

  • Make sure you are inside your briefai folder on the Desktop, then create the server directory and move into it by running these commands:
cd ~/Desktop/briefai
mkdir server
cd server
  • Initialize a new npm project by running:
npm init -y

What does this command do?

npm init -y creates a package.json file with default values. This file tracks your project's dependencies and scripts.

  • Install the production dependencies your server needs by running:
npm install express cors helmet dotenv zod

What are these packages?

  • express is the web framework that handles HTTP requests and responses.
  • cors allows your frontend (on a different port) to talk to the backend.
  • helmet sets secure HTTP headers automatically.
  • dotenv loads environment variables from a .env file.
  • zod validates incoming data so your API rejects bad requests before they reach the database.
  • Install the development dependencies by running:
npm install -D typescript @types/node @types/express @types/cors tsx

What are dev dependencies?

Dev dependencies are packages you only need while developing. The @types/ packages give TypeScript the type definitions for Express, Node, and CORS. tsx runs your TypeScript code directly without a separate compile step.

  • Confirm the installs worked by running:
ls node_modules/.package-lock.json

You should see the file listed in the output. If you get an error, re-run the install commands above.

Seeing permission errors during install?

  • On Windows, try running your terminal as Administrator.
  • Make sure you are inside the server folder, not the parent briefai folder.
  • Help me fix npm install permission errors

Next, you need a TypeScript configuration file. This tells the TypeScript compiler how strict to be, where your source files live, and where to output compiled JavaScript.

  • Generate a default TypeScript config by running:
npx tsc --init

This creates a tsconfig.json with many commented-out options. You will replace all of it with a clean configuration tuned for this project.

  • Open tsconfig.json in VS Code.
  • Select all the contents with Cmd+A (macOS) or Ctrl+A (Windows), then replace with this configuration:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

What do these options mean?

  • target and lib set to ES2022 let you use modern JavaScript features like top-level await.
  • strict: true turns on all of TypeScript's strictest checks, catching bugs before they reach production.
  • rootDir tells TypeScript your source code lives in src/, and outDir puts compiled JavaScript into dist/.
  • Save tsconfig.json.
Create the project structure and entry point

A well-organized backend separates concerns into distinct folders. Each folder has a clear job: routes handle URLs, controllers handle request logic, and services handle business logic.

  • Create the src directory and all its subfolders by running these commands:
mkdir src
mkdir src/config src/routes src/controllers src/services src/middleware src/types src/lib
  • Confirm the folders exist by running:
ls src

You should see config, controllers, lib, middleware, routes, services, and types listed in the output.

Now you will create the core files that make the server start and respond to requests. The first file is a small environment config utility that loads settings from a .env file.

  • Create a new file at src/config/env.ts and add the following code:
import dotenv from "dotenv";

// Load variables from the .env file into process.env
dotenv.config();

// Export a typed config object so the rest of the app can use env.PORT, env.DATABASE_URL, etc.
export const env = {
  PORT: parseInt(process.env.PORT || "3001", 10),
  DATABASE_URL: process.env.DATABASE_URL || "",
  GEMINI_API_KEY: process.env.GEMINI_API_KEY || "",
};

What does this file do?

This reads environment variables from a .env file and exports them as a typed object. Instead of calling process.env.PORT everywhere, you import env.PORT and get type safety plus a default fallback value.

  • Save env.ts.

Next is the error handling middleware. When something goes wrong in a route, Express passes the error to this function, which sends a clean JSON response to the client.

  • Create a new file at src/middleware/error.middleware.ts and add the following code:
import { Request, Response, NextFunction } from "express";

// Custom error class that carries an HTTP status code
export class AppError extends Error {
  statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

// Central error handler that Express calls when next(error) is used
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      error: err.message,
    });
    return;
  }

  console.error("Unexpected error:", err);
  res.status(500).json({
    success: false,
    error: "An unexpected error occurred",
  });
}

What does this code do?

  • AppError is a custom error class. When your code throws new AppError("Not found", 404), the handler knows exactly what status code to send back.
  • errorHandler is an Express error-handling middleware. It checks whether the error is an AppError (sends a specific status) or an unexpected error (sends a generic 500).
  • Save error.middleware.ts.

Now for the main event: the Express app itself. This file wires together all the middleware and defines the health check route.

  • Create a new file at src/app.ts and add the following code:
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { errorHandler } from "./middleware/error.middleware";

const app = express();

// Security and parsing middleware
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10mb" }));

// Health check endpoint to verify the server is running
app.get("/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

// Error handler must be the last middleware registered
app.use(errorHandler);

export default app;

What does this code do?

  • helmet() adds security headers to every response. cors() allows cross-origin requests from your frontend.
  • express.json({ limit: "10mb" }) parses incoming JSON request bodies. The 10 MB limit lets users paste large articles.
  • The /health route returns a simple JSON object. You will use it in a moment to confirm the server is alive.
  • errorHandler is registered last so it catches errors from all routes above it.
  • Save app.ts.

The final server file imports the app and tells it to start listening for requests.

  • Create a new file at src/server.ts and add the following code:
import app from "./app";
import { env } from "./config/env";

// Start the Express server on the configured port
app.listen(env.PORT, () => {
  console.log(`Server running on http://localhost:${env.PORT}`);
});

Why separate app.ts and server.ts?

app.ts defines the Express app (middleware, routes, error handler). server.ts starts it. This separation makes the app easy to test, because tests can import app.ts without spinning up a real server.

  • Save server.ts.

The last piece is telling npm how to run your server. You need to add scripts to package.json so you can start the dev server with a single command.

  • Open package.json in the server folder.
  • Find the "scripts" section. It currently looks like this:
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1"
}
  • Replace the entire "scripts" block with these three scripts:
"scripts": {
  "dev": "tsx watch src/server.ts",
  "build": "tsc",
  "start": "node dist/server.js"
}

What do these scripts do?

  • dev runs your TypeScript code directly with tsx watch. It auto-restarts the server every time you save a file.
  • build compiles TypeScript into JavaScript for production deployment.
  • start runs the compiled JavaScript output. This is what a production server would use.
  • Save package.json.

✔️ Awesome, I've got everything!

Great. Double check you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Here is every file you should have so far. Compare each one carefully.

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}
import dotenv from "dotenv";

dotenv.config();

export const env = {
  PORT: parseInt(process.env.PORT || "3001", 10),
  DATABASE_URL: process.env.DATABASE_URL || "",
  GEMINI_API_KEY: process.env.GEMINI_API_KEY || "",
};
import { Request, Response, NextFunction } from "express";

export class AppError extends Error {
  statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      error: err.message,
    });
    return;
  }

  console.error("Unexpected error:", err);
  res.status(500).json({
    success: false,
    error: "An unexpected error occurred",
  });
}
import express from "express";
import cors from "cors";
import helmet from "helmet";
import { errorHandler } from "./middleware/error.middleware";

const app = express();

app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10mb" }));

app.get("/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

app.use(errorHandler);

export default app;
import app from "./app";
import { env } from "./config/env";

app.listen(env.PORT, () => {
  console.log(`Server running on http://localhost:${env.PORT}`);
});
Test the backend server

Everything is in place. Time to boot it up and hit the health check endpoint.

  • Make sure your terminal is inside the server folder.
  • Start the dev server by running:
npm run dev

You should see this output in your terminal:

Server running on http://localhost:3001
  • Open your browser and navigate to http://localhost:3001/health.

You should see a JSON response that looks like this:

{ "status": "ok", "timestamp": "2026-05-01T12:00:00.000Z" }

What about auto-restart?

The tsx watch command monitors your source files. Every time you save a change, the server restarts automatically. Leave this terminal running while you work through the project.

Server not starting?

  • If you see Cannot find module errors, double check that all files are saved and that the import paths match exactly (e.g. ./middleware/error.middleware not ./middleware/error).
  • If port 3001 is already in use, check for other running servers. Stop them or change the port in your command.
  • If you see tsx: command not found, re-run npm install -D tsx inside the server folder.
  • Help me debug why my Express server is not starting

Your Express server is running and responding to requests. Next up, you will connect it to your PostgreSQL database using Prisma ORM and design the data model for storing AI analyses.

Set Up Prisma ORM and Design the Database Schema

Your Express server is up and running. Now it needs somewhere to store data.

In this step, you will set up Prisma, a TypeScript-first ORM (Object-Relational Mapper) that lets you define your database tables in code and query them with full auto-completion. You will design the schema for your Analysis table and connect everything to your PostgreSQL database.

In this step, get ready to:
  • Install Prisma and initialize it with PostgreSQL.
  • Define the Analysis data model and run a database migration.
  • Create a Prisma client utility and verify the database connection.
Install and initialize Prisma

Prisma has two packages. The prisma CLI is a dev tool for generating migrations and managing your schema. The @prisma/client package is the runtime library your server code imports to query the database.

  • Make sure your terminal is inside the server folder. Install both Prisma packages by running these commands:
npm install -D prisma
npm install @prisma/client

Why two separate installs?

The -D flag installs prisma as a dev dependency because it is only used during development for tasks like running migrations. The @prisma/client is a regular dependency because your server needs it at runtime to talk to the database.

  • Initialize Prisma with PostgreSQL as the database provider by running:
npx prisma init --datasource-provider postgresql

What does this command do?

This creates two things inside your server folder:

  • A prisma/schema.prisma file where you define your database models.
  • A .env file with a template DATABASE_URL connection string.
  • Confirm the files were created by running:
ls prisma

You should see schema.prisma in the output.

Don't see the prisma folder?

Make sure you ran the command from inside the server folder, not the briefai root. Run pwd to check your current directory.

Help me troubleshoot my Prisma init command

Now you need to configure the .env file so Prisma knows how to connect to your briefai database.

  • Open .env in the server folder (Prisma just created it).
  • Replace the entire contents of the file with this:
DATABASE_URL="postgresql://postgres:your-password-here@localhost:5432/briefai?schema=public"
GEMINI_API_KEY=your-api-key-here
PORT=3001

What's in this file?

  • DATABASE_URL tells Prisma how to find your PostgreSQL database. The format is: protocol, username, password, host, port, and database name.
  • GEMINI_API_KEY is a placeholder for the Google AI key you will set up in a later step.
  • PORT sets the server's listening port to 3001.
  • Replace your-password-here with the password you set for the postgres user when you installed PostgreSQL in Step 1.
  • Save .env.

Keep .env out of Git

The .env file contains your database password. The npx prisma init command automatically added .env to your .gitignore file. Never commit this file to version control.

Define the Analysis data model

Prisma uses a schema file to describe your database tables. Each model in the schema maps to a table in PostgreSQL. You will define an Analysis model that holds everything BriefAI needs to store about a piece of analyzed content.

  • Open prisma/schema.prisma in VS Code.
  • Replace the entire contents of the file with this schema:
generator client {
  provider = "prisma-client-js"
}

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

// The Analysis model stores every AI-generated content analysis
model Analysis {
  id                String   @id @default(uuid())  // Unique identifier for each analysis
  title             String                          // Display title for the analysis
  sourceText        String                          // The original text the user submitted
  summary           String                          // AI-generated summary of the text
  keyInsights       String[]                        // Array of key takeaways from the text
  sentiment         String                          // Sentiment label: positive, negative, or neutral
  sentimentScore    Float                           // Numeric sentiment score from -1.0 to 1.0
  readingDifficulty String                          // Reading level: easy, medium, or hard
  estimatedReadTime Int                             // Estimated reading time in minutes
  wordCount         Int                             // Total word count of the source text
  createdAt         DateTime @default(now())        // Timestamp when the analysis was created
  updatedAt         DateTime @updatedAt             // Auto-updated timestamp on every edit
}

What does this schema do?

The generator block tells Prisma to generate a TypeScript client you can import in your server code. The datasource block reads your database URL from the .env file.

The Analysis model defines one database table. Each field becomes a column. Prisma decorators like @id, @default(uuid()), and @updatedAt handle primary keys, auto-generated IDs, and automatic timestamps so your application code stays clean.

  • Save prisma/schema.prisma.

With the schema defined, you need to run a database migration. A migration translates your Prisma schema into actual SQL commands that create the tables in PostgreSQL.

  • Run your first migration by entering this command:
npx prisma migrate dev --name init

What does this command do?

  • prisma migrate dev compares your schema to the current database state, generates a SQL migration file, and runs it.
  • --name init gives this migration a descriptive name. Prisma creates a timestamped folder inside prisma/migrations/ to track every change.
  • The command also runs prisma generate automatically, which builds the TypeScript client you will import in your server code.

You should see output that includes Your database is now in sync with your schema and Generated Prisma Client.

Seeing a connection error?

  • Check that PostgreSQL is running. Open pgAdmin and verify the server shows a green icon.
  • Double check the password in your .env file matches the one you set during PostgreSQL installation.
  • Make sure the briefai database exists. You created it in Step 1 with CREATE DATABASE briefai; in psql.

Help me fix my Prisma migration error

Create the Prisma client utility and test the connection

You need a reusable Prisma client that any file in your server can import to run database queries. Creating a single shared instance prevents your app from opening too many database connections.

  • Create src/lib/prisma.ts and add this code:
import { PrismaClient } from "@prisma/client";

// Create a single shared Prisma client instance for the entire app
const prisma = new PrismaClient();

export default prisma;

What does this code do?

This creates one PrismaClient instance and exports it. Every file that needs to query the database imports this same instance instead of creating its own. This is called the singleton pattern.

  • Save src/lib/prisma.ts.

Next, you need an environment configuration module that loads variables from .env and exports them as typed values your server code can use.

  • Open src/config/env.ts and replace its contents with this code:
import dotenv from "dotenv";

// Load environment variables from the .env file into process.env
dotenv.config();

// Export typed environment variables for use throughout the server
export const env = {
  PORT: parseInt(process.env.PORT || "3001", 10),
  DATABASE_URL: process.env.DATABASE_URL || "",
  GEMINI_API_KEY: process.env.GEMINI_API_KEY || "",
};

What does this code do?

  • dotenv.config() reads your .env file and makes its values available through process.env.
  • The env object centralizes all environment variables in one place with sensible defaults. Other files import env instead of accessing process.env directly.
  • Save src/config/env.ts.

The final piece is updating your server startup code to connect to the database before accepting any requests.

  • Open src/server.ts and replace the entire contents with this code:
import app from "./app";
import { env } from "./config/env";
import prisma from "./lib/prisma";

// Wrap startup in an async function so we can await the database connection
async function main() {
  try {
    // Connect to PostgreSQL before starting the server
    await prisma.$connect();
    console.log("Database connected successfully");

    app.listen(env.PORT, () => {
      console.log(`Server running on http://localhost:${env.PORT}`);
    });
  } catch (error) {
    console.error("Failed to start server:", error);
    process.exit(1);
  }
}

main();

What's happening here?

  • prisma.$connect() opens a connection to your PostgreSQL database. Wrapping it in a try/catch ensures the server stops with a clear error message if the database is unreachable.
  • The server only starts listening for requests after the database connection succeeds. This prevents requests from hitting the API before the database is ready.
  • process.exit(1) exits the process with an error code if anything goes wrong during startup.
  • Save src/server.ts.
  • Check your terminal where the dev server is running. It should auto-restart thanks to tsx watch.

You should see both Database connected successfully and Server running on http://localhost:3001 in the terminal output.

Server crashing on restart?

  • If you see a connection error, double check that your password in .env is correct and that PostgreSQL is still running.
  • If you see a module not found error for @prisma/client, make sure you ran both install commands earlier. Try npm install @prisma/client again.
  • If you see an error about the Prisma client not being generated, run npx prisma generate manually.

Help me fix my database connection error

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file modified in this step. Compare them against yours.

DATABASE_URL="postgresql://postgres:your-password-here@localhost:5432/briefai?schema=public"
GEMINI_API_KEY=your-api-key-here
PORT=3001
generator client {
  provider = "prisma-client-js"
}

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

model Analysis {
  id                String   @id @default(uuid())
  title             String
  sourceText        String
  summary           String
  keyInsights       String[]
  sentiment         String
  sentimentScore    Float
  readingDifficulty String
  estimatedReadTime Int
  wordCount         Int
  createdAt         DateTime @default(now())
  updatedAt         DateTime @updatedAt
}
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

export default prisma;
import dotenv from "dotenv";

dotenv.config();

export const env = {
  PORT: parseInt(process.env.PORT || "3001", 10),
  DATABASE_URL: process.env.DATABASE_URL || "",
  GEMINI_API_KEY: process.env.GEMINI_API_KEY || "",
};
import app from "./app";
import { env } from "./config/env";
import prisma from "./lib/prisma";

async function main() {
  try {
    await prisma.$connect();
    console.log("Database connected successfully");

    app.listen(env.PORT, () => {
      console.log(`Server running on http://localhost:${env.PORT}`);
    });
  } catch (error) {
    console.error("Failed to start server:", error);
    process.exit(1);
  }
}

main();

Your database is set up and your server is connected to it. Next up, you will build the full CRUD API that creates, reads, updates, and deletes analyses in this database.

Build the CRUD API Routes

Your database is set up and your Prisma client is ready to talk to PostgreSQL. Now you need a way for the outside world to interact with that database.

In this step, you will build a full REST API with five CRUD endpoints: create, read (list and detail), update, and delete. You will also add input validation with Zod and centralized error handling so your API behaves predictably.

In this step, get ready to:
  • Define TypeScript types and Zod validation schemas for your API inputs.
  • Build a service layer that performs all database operations through Prisma.
  • Wire up Express routes, controllers, and middleware to expose five API endpoints.
Create TypeScript types and Zod validation schemas

Before writing any route logic, you need to define the shape of data that your API accepts. Zod schemas validate incoming requests at runtime, so invalid data never reaches your database.

  • Create a new file at src/types/index.ts inside your server folder and paste the following code:
import { z } from "zod";

// Zod schema for validating new analysis requests
export const CreateAnalysisSchema = z.object({
  sourceText: z
    .string()
    .min(50, "Text must be at least 50 characters long"),
  title: z.string().optional(),
});

// Zod schema for validating update requests
export const UpdateAnalysisSchema = z.object({
  title: z.string().min(1, "Title cannot be empty").optional(),
});

// Infer TypeScript types directly from Zod schemas
export type CreateAnalysisInput = z.infer<typeof CreateAnalysisSchema>;
export type UpdateAnalysisInput = z.infer<typeof UpdateAnalysisSchema>;

// Shape of the structured data the AI will return later
export interface AIAnalysisResult {
  summary: string;
  keyInsights: string[];
  sentiment: string;
  sentimentScore: number;
  readingDifficulty: string;
  estimatedReadTime: number;
}

What does this code do?

CreateAnalysisSchema enforces that every new analysis request includes sourceText with at least 50 characters. The title field is optional.

z.infer<typeof CreateAnalysisSchema> automatically generates a TypeScript type from the Zod schema. This means your runtime validation and your compile-time types always stay in sync.

AIAnalysisResult defines the shape of data the AI will return. You will use this interface in a later step when you integrate the Gemini API.

  • Save src/types/index.ts.
  • Check your terminal where tsx watch is running. You should see the server restart without any TypeScript errors.

Next, you need middleware to handle errors consistently across your entire API. Your app.ts already imports an error handler, but the file it points to does not exist yet.

  • Create src/middleware/error.middleware.ts and paste the following code:
import { Request, Response, NextFunction } from "express";

// Custom error class that carries an HTTP status code
export class AppError extends Error {
  statusCode: number;

  constructor(message: string, statusCode: number) {
    super(message);
    this.statusCode = statusCode;
  }
}

// Centralized error handler that Express calls for any next(error)
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
): void {
  if (err instanceof AppError) {
    res.status(err.statusCode).json({
      success: false,
      error: err.message,
    });
    return;
  }

  console.error("Unexpected error:", err);
  res.status(500).json({
    success: false,
    error: "An unexpected error occurred",
  });
}

What does this code do?

AppError is a custom error class. When you throw new AppError("Analysis not found", 404), Express passes it to the errorHandler function, which sends back a clean JSON response with the right HTTP status.

For unexpected errors (anything that is not an AppError), the handler logs the full error to the console and returns a generic 500 response. This prevents your server from leaking internal details to the client.

  • Save src/middleware/error.middleware.ts.
  • Check your terminal. The dev server should restart cleanly now that the error handler file exists.

Seeing a TypeScript error in the terminal?

Make sure the file path is exactly src/middleware/error.middleware.ts. The import in app.ts expects this exact path. Also confirm you exported both AppError and errorHandler with the export keyword.

Help me debug this TypeScript error in my error middleware

The last piece of middleware converts Zod validation into Express middleware. This lets you validate request bodies before they reach your controller.

  • Create src/middleware/validate.middleware.ts and paste the following code:
import { Request, Response, NextFunction } from "express";
import { ZodSchema } from "zod";

// Reusable middleware that validates req.body against any Zod schema
export function validate(schema: ZodSchema) {
  return (req: Request, res: Response, next: NextFunction): void => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      res.status(400).json({
        success: false,
        error: "Validation failed",
        details: result.error.errors,
      });
      return;
    }
    req.body = result.data;
    next();
  };
}

What does this code do?

validate(schema) is a higher-order function. You pass it a Zod schema and it returns an Express middleware function.

safeParse() checks the request body without throwing an error. If validation fails, the middleware sends back a 400 response with detailed error messages. If it passes, the cleaned data replaces req.body and the request continues to the next handler.

  • Save src/middleware/validate.middleware.ts.
  • Check your terminal. The server should restart without errors.
Build the analysis service layer

The service layer sits between your routes and the database. Each function in this layer performs one database operation through the Prisma client.

You will build the service file in three chunks. The first chunk handles creating a new analysis record.

  • Create src/services/analysis.service.ts and paste the following code:
import prisma from "../lib/prisma";
import { CreateAnalysisInput, UpdateAnalysisInput } from "../types";

// Create a new analysis record in the database
export async function createAnalysis(input: CreateAnalysisInput) {
  const { sourceText, title } = input;
  const wordCount = sourceText.trim().split(/\s+/).length;
  const analysisTitle = title || sourceText.substring(0, 50).trim() + "...";

  const analysis = await prisma.analysis.create({
    data: {
      title: analysisTitle,
      sourceText,
      summary: "",
      keyInsights: [],
      sentiment: "",
      sentimentScore: 0,
      readingDifficulty: "",
      estimatedReadTime: 0,
      wordCount,
    },
  });

  return analysis;
}

What does this code do?

createAnalysis takes the validated input, calculates a word count, and auto-generates a title from the first 50 characters if the user did not provide one.

The AI-related fields (summary, sentiment, etc.) are set to empty placeholders for now. You will fill them in with real AI results when you integrate the Gemini API in a later step.

  • Save the file. You will not see a change yet. The next chunk adds the read operations.

The getAllAnalyses function is the most complex service function. It supports filtering by title and sorting by different fields.

  • In src/services/analysis.service.ts, add the following function below the createAnalysis function:
// Retrieve all analyses with optional search and sort
export async function getAllAnalyses(search?: string, sortBy?: string) {
  const where = search
    ? { title: { contains: search, mode: "insensitive" as const } }
    : {};

  let orderBy: Record<string, string> = { createdAt: "desc" };
  if (sortBy === "oldest") orderBy = { createdAt: "asc" };
  if (sortBy === "sentiment") orderBy = { sentimentScore: "desc" };

  const analyses = await prisma.analysis.findMany({
    where,
    orderBy,
    select: {
      id: true,
      title: true,
      summary: true,
      sentiment: true,
      sentimentScore: true,
      readingDifficulty: true,
      estimatedReadTime: true,
      wordCount: true,
      createdAt: true,
      updatedAt: true,
    },
  });

  return analyses;
}

What does this code do?

The where clause filters results by title using Prisma's contains operator with case-insensitive matching. If no search term is provided, the filter is empty and all records are returned.

The select object excludes sourceText from the list response. Full source text can be large, so the list endpoint only returns the metadata needed to render cards.

  • Save the file. You will not see a change yet. The next chunk adds the remaining three functions.
  • Add the following three functions below getAllAnalyses in src/services/analysis.service.ts:
// Retrieve a single analysis by its UUID
export async function getAnalysisById(id: string) {
  const analysis = await prisma.analysis.findUnique({
    where: { id },
  });
  return analysis;
}

// Update an existing analysis (currently supports title changes)
export async function updateAnalysis(id: string, input: UpdateAnalysisInput) {
  const analysis = await prisma.analysis.update({
    where: { id },
    data: input,
  });
  return analysis;
}

// Permanently remove an analysis from the database
export async function deleteAnalysis(id: string) {
  await prisma.analysis.delete({
    where: { id },
  });
}

What does this code do?

findUnique retrieves a single record by its primary key. If no match is found, it returns null instead of throwing.

update applies only the fields included in input. Since UpdateAnalysisInput only allows title, that is the only field a user can change right now.

  • Save src/services/analysis.service.ts.
  • Check your terminal. The server should restart without errors.

Seeing a Prisma type error?

Make sure your import at the top of the file reads import prisma from "../lib/prisma". If your Prisma client was not generated, run npx prisma generate in your server folder to regenerate it.

Help me debug this Prisma type error in my analysis service

✔️ Awesome, I've got everything!

Great. Double check you have saved src/services/analysis.service.ts.

ⓧ I'd like to double check the full code

import prisma from "../lib/prisma";
import { CreateAnalysisInput, UpdateAnalysisInput } from "../types";

// Create a new analysis record in the database
export async function createAnalysis(input: CreateAnalysisInput) {
  const { sourceText, title } = input;
  const wordCount = sourceText.trim().split(/\s+/).length;
  const analysisTitle = title || sourceText.substring(0, 50).trim() + "...";

  const analysis = await prisma.analysis.create({
    data: {
      title: analysisTitle,
      sourceText,
      summary: "",
      keyInsights: [],
      sentiment: "",
      sentimentScore: 0,
      readingDifficulty: "",
      estimatedReadTime: 0,
      wordCount,
    },
  });

  return analysis;
}

// Retrieve all analyses with optional search and sort
export async function getAllAnalyses(search?: string, sortBy?: string) {
  const where = search
    ? { title: { contains: search, mode: "insensitive" as const } }
    : {};

  let orderBy: Record<string, string> = { createdAt: "desc" };
  if (sortBy === "oldest") orderBy = { createdAt: "asc" };
  if (sortBy === "sentiment") orderBy = { sentimentScore: "desc" };

  const analyses = await prisma.analysis.findMany({
    where,
    orderBy,
    select: {
      id: true,
      title: true,
      summary: true,
      sentiment: true,
      sentimentScore: true,
      readingDifficulty: true,
      estimatedReadTime: true,
      wordCount: true,
      createdAt: true,
      updatedAt: true,
    },
  });

  return analyses;
}

// Retrieve a single analysis by its UUID
export async function getAnalysisById(id: string) {
  const analysis = await prisma.analysis.findUnique({
    where: { id },
  });
  return analysis;
}

// Update an existing analysis (currently supports title changes)
export async function updateAnalysis(id: string, input: UpdateAnalysisInput) {
  const analysis = await prisma.analysis.update({
    where: { id },
    data: input,
  });
  return analysis;
}

// Permanently remove an analysis from the database
export async function deleteAnalysis(id: string) {
  await prisma.analysis.delete({
    where: { id },
  });
}
Wire up routes and controllers

Controllers are thin functions that receive an HTTP request, call the appropriate service function, and send back the response. They keep your route definitions clean by separating "what comes in" from "what happens next."

  • Create src/controllers/analysis.controller.ts and paste the following code for the create and list handlers:
import { Request, Response, NextFunction } from "express";
import {
  createAnalysis,
  getAllAnalyses,
  getAnalysisById,
  updateAnalysis,
  deleteAnalysis,
} from "../services/analysis.service";
import { AppError } from "../middleware/error.middleware";

// POST /api/analyses - create a new analysis
export async function handleCreate(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const analysis = await createAnalysis(req.body);
    res.status(201).json({ success: true, data: analysis });
  } catch (error) {
    next(error);
  }
}

// GET /api/analyses - list all analyses
export async function handleGetAll(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const search = req.query.search as string | undefined;
    const sortBy = req.query.sortBy as string | undefined;
    const analyses = await getAllAnalyses(search, sortBy);
    res.json({ success: true, data: analyses });
  } catch (error) {
    next(error);
  }
}

What does this code do?

Each handler follows the same pattern: try the service call, send the result, or pass the error to next(error). Calling next(error) hands the error to the centralized errorHandler you created earlier.

handleCreate returns a 201 Created status on success. handleGetAll reads search and sortBy from the URL query string and passes them to the service.

  • Save the file. You will not see a change yet. The next chunk adds the remaining three handlers.
  • Add the following three handler functions below handleGetAll in src/controllers/analysis.controller.ts:
// GET /api/analyses/:id - get a single analysis
export async function handleGetById(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const analysis = await getAnalysisById(req.params.id);
    if (!analysis) {
      throw new AppError("Analysis not found", 404);
    }
    res.json({ success: true, data: analysis });
  } catch (error) {
    next(error);
  }
}

// PATCH /api/analyses/:id - update an analysis
export async function handleUpdate(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const analysis = await updateAnalysis(req.params.id, req.body);
    res.json({ success: true, data: analysis });
  } catch (error) {
    next(error);
  }
}

// DELETE /api/analyses/:id - delete an analysis
export async function handleDelete(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    await deleteAnalysis(req.params.id);
    res.status(204).send();
  } catch (error) {
    next(error);
  }
}

What does this code do?

handleGetById checks if the service returned null and throws an AppError with a 404 status if the analysis does not exist. This is the first place you use your custom error class.

handleDelete returns a 204 No Content status, which is the standard HTTP response for a successful deletion with no response body.

  • Save src/controllers/analysis.controller.ts.
  • Check your terminal for a clean restart.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/controllers/analysis.controller.ts.

ⓧ I'd like to double check the full code

import { Request, Response, NextFunction } from "express";
import {
  createAnalysis,
  getAllAnalyses,
  getAnalysisById,
  updateAnalysis,
  deleteAnalysis,
} from "../services/analysis.service";
import { AppError } from "../middleware/error.middleware";

// POST /api/analyses - create a new analysis
export async function handleCreate(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const analysis = await createAnalysis(req.body);
    res.status(201).json({ success: true, data: analysis });
  } catch (error) {
    next(error);
  }
}

// GET /api/analyses - list all analyses
export async function handleGetAll(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const search = req.query.search as string | undefined;
    const sortBy = req.query.sortBy as string | undefined;
    const analyses = await getAllAnalyses(search, sortBy);
    res.json({ success: true, data: analyses });
  } catch (error) {
    next(error);
  }
}

// GET /api/analyses/:id - get a single analysis
export async function handleGetById(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const analysis = await getAnalysisById(req.params.id);
    if (!analysis) {
      throw new AppError("Analysis not found", 404);
    }
    res.json({ success: true, data: analysis });
  } catch (error) {
    next(error);
  }
}

// PATCH /api/analyses/:id - update an analysis
export async function handleUpdate(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    const analysis = await updateAnalysis(req.params.id, req.body);
    res.json({ success: true, data: analysis });
  } catch (error) {
    next(error);
  }
}

// DELETE /api/analyses/:id - delete an analysis
export async function handleDelete(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  try {
    await deleteAnalysis(req.params.id);
    res.status(204).send();
  } catch (error) {
    next(error);
  }
}

Now you need a router that maps HTTP methods and URL paths to the controller functions.

  • Create src/routes/analysis.routes.ts and paste the following code:
import { Router } from "express";
import {
  handleCreate,
  handleGetAll,
  handleGetById,
  handleUpdate,
  handleDelete,
} from "../controllers/analysis.controller";
import { validate } from "../middleware/validate.middleware";
import { CreateAnalysisSchema, UpdateAnalysisSchema } from "../types";

const router = Router();

// POST and PATCH routes use the validate middleware to check request bodies
router.post("/", validate(CreateAnalysisSchema), handleCreate);
router.get("/", handleGetAll);
router.get("/:id", handleGetById);
router.patch("/:id", validate(UpdateAnalysisSchema), handleUpdate);
router.delete("/:id", handleDelete);

export default router;

What does this code do?

The router defines five endpoints. The POST and PATCH routes include the validate middleware because they accept user input in the request body. The GET and DELETE routes do not need validation because they only use URL parameters.

The routes use relative paths (/, /:id) because the base path will be set when you mount the router in app.ts next.

  • Save src/routes/analysis.routes.ts. You will not see a change yet. The routes are not mounted to the Express app.

The final connection is mounting the router in your main Express app file. This tells Express to direct any request starting with /api/analyses to your new router.

  • Open src/app.ts.
  • Find this line near the top of the file:
import { errorHandler } from "./middleware/error.middleware";
  • Add this import directly above that line:
import analysisRoutes from "./routes/analysis.routes";
  • Now find this line near the bottom of the file:
app.use(errorHandler);
  • Add this line directly above app.use(errorHandler):
app.use("/api/analyses", analysisRoutes);

Why does the route mount go before the error handler?

Express processes middleware in the order you register it. The error handler must be last so it catches errors from all routes above it. If you placed the route mount after the error handler, errors from your analysis endpoints would never reach it.

  • Save src/app.ts.
  • Check your terminal. You should see the server restart with Server running on http://localhost:3001 and Database connected successfully.

Server not starting?

If you see Cannot find module, double check that the import path in app.ts is exactly ./routes/analysis.routes and that the file is saved at src/routes/analysis.routes.ts.

Help me debug why my Express server is not starting after adding analysis routes

✔️ Awesome, I've got everything!

Great. Double check you have saved src/app.ts.

ⓧ I'd like to double check the full code

import express from "express";
import cors from "cors";
import helmet from "helmet";
import analysisRoutes from "./routes/analysis.routes";
import { errorHandler } from "./middleware/error.middleware";

const app = express();

app.use(helmet());
app.use(cors());
app.use(express.json({ limit: "10mb" }));

app.get("/health", (req, res) => {
  res.json({ status: "ok", timestamp: new Date().toISOString() });
});

app.use("/api/analyses", analysisRoutes);

app.use(errorHandler);

export default app;

Time to test your endpoints. Open a second terminal window (keep your dev server running in the first one).

  • Test the create endpoint by running this command:
curl -X POST http://localhost:3001/api/analyses -H "Content-Type: application/json" -d "{\"sourceText\": \"This is a test analysis with enough characters to pass the fifty character minimum validation requirement for the source text field.\"}"

You should see a JSON response with "success": true and a data object containing your new analysis record with a generated UUID, the source text, and placeholder values for the AI fields.

  • Test the list endpoint by running:
curl http://localhost:3001/api/analyses

You should see a JSON array containing the analysis you just created.

  • Test validation by sending text that is too short:
curl -X POST http://localhost:3001/api/analyses -H "Content-Type: application/json" -d "{\"sourceText\": \"Too short\"}"

You should see a 400 response with "error": "Validation failed" and a details array explaining that the text must be at least 50 characters.

Browse your data visually

You can also run npx prisma studio in a separate terminal to open a visual database browser at http://localhost:5555. This is a helpful way to inspect your records as you build.

Getting Connection refused?

Make sure your dev server is still running in the other terminal window. If it crashed, run npm run dev from the server folder to restart it.

Help me debug why my curl request to localhost:3001 is being refused

Your API can now create, read, update, and delete analysis records in PostgreSQL. Next up, you will connect the Gemini AI so those placeholder fields get filled with real content analysis.

Integrate Google Gemini AI

Your CRUD API is fully wired up and can create, read, update, and delete analysis records. Right now though, every new analysis is saved with empty fields for summary, sentiment, and insights.

In this step, you'll connect your backend to Google's Gemini AI. You'll grab a free API key, install the official SDK, and build a service that sends text to Gemini and receives structured analysis results back as typed JSON.

In this step, get ready to:
  • Get a free Gemini API key from Google AI Studio.
  • Install the @google/genai SDK and build the AI analysis service.
  • Test that the AI returns properly structured JSON.
Get your free Gemini API key

Google's Gemini API offers a generous free tier with 1,500 requests per day for Flash models. No credit card is required.

  • Open Google AI Studio in your browser and sign in with your Google account.
  • Click Get API Key in the left sidebar.
  • Click Create API key.
  • Copy the generated key. You will need it in the next step.

What is an API key?

An API key is a unique string that identifies your application when it makes requests to an external service. Gemini uses it to track your usage against the free tier limits.

Your .env file already has a placeholder for the Gemini key from earlier setup. Now you need to replace the placeholder with your real key.

  • Open server/.env in VS Code.
  • Find this line:
GEMINI_API_KEY=your-api-key-here
  • Replace your-api-key-here with the API key you just copied, so the line looks like this:
GEMINI_API_KEY=AIzaSy...your-actual-key
  • Save .env.

Keep your API key private

Never commit your .env file to Git. The .gitignore generated by Prisma already excludes it, so you are safe.

Install the Gemini SDK and build the AI service

Google provides an official Node.js SDK called @google/genai for calling Gemini models. This is the current GA package that replaced the older @google/generative-ai library.

  • Make sure your terminal is in the server directory, then install the SDK by running:
npm install @google/genai
  • Verify the install succeeded by checking your package.json. You should see @google/genai listed under dependencies.

Install failed?

Make sure you are inside the server folder, not the root briefai folder. Run pwd (macOS/Linux) or cd (Windows) to check your current directory.

Help me debug my npm install error for @google/genai.

Now you will create the AI service. This is a dedicated module that handles all communication with Gemini. It sends raw text to the model and gets back structured JSON matching your AIAnalysisResult type.

  • Create a new file at src/services/ai.service.ts and paste the following code:
import { GoogleGenAI } from "@google/genai";
import { env } from "../config/env";
import { AIAnalysisResult } from "../types";

// Create a Gemini client using your API key from .env
const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });

export async function analyzeContent(
  text: string
): Promise<AIAnalysisResult> {
  // Send the text to Gemini with a structured output schema
  const response = await ai.models.generateContent({
    model: "gemini-2.5-flash",
    contents: `Analyze the following text and provide a structured analysis:\n\n${text}`,
    config: {
      // Force JSON output so we always get parseable data
      responseMimeType: "application/json",
      // Define the exact shape of the response we expect
      responseSchema: {
        type: "OBJECT",
        properties: {
          summary: {
            type: "STRING",
            description:
              "A concise 2-3 sentence summary of the text",
          },
          keyInsights: {
            type: "ARRAY",
            items: { type: "STRING" },
            description:
              "3-5 key insights or takeaways from the text",
          },
          sentiment: {
            type: "STRING",
            description:
              "Overall sentiment: positive, negative, or neutral",
          },
          sentimentScore: {
            type: "NUMBER",
            description:
              "Sentiment score from -1.0 (very negative) to 1.0 (very positive)",
          },
          readingDifficulty: {
            type: "STRING",
            description:
              "Reading difficulty level: easy, medium, or hard",
          },
          estimatedReadTime: {
            type: "INTEGER",
            description:
              "Estimated reading time in minutes",
          },
        },
        required: [
          "summary",
          "keyInsights",
          "sentiment",
          "sentimentScore",
          "readingDifficulty",
          "estimatedReadTime",
        ],
      },
    },
  });

  // Extract the text content from Gemini's response
  const text_response = response.text;
  if (!text_response) {
    throw new Error("AI returned an empty response");
  }

  // Parse the JSON string into our typed interface
  const parsed: AIAnalysisResult = JSON.parse(text_response);
  return parsed;
}

What does this code do?

The GoogleGenAI client is initialized once with your API key from env.ts. Every call to analyzeContent reuses this client.

The key part is the responseSchema config. This tells Gemini exactly what fields to return and their types. Combined with responseMimeType: "application/json", the model is forced to return valid JSON matching your schema. No regex parsing or hoping the AI formats things correctly.

The function then calls response.text to get the raw JSON string, checks that it is not empty, and parses it into the AIAnalysisResult type you defined in Step 4.

Why structured output instead of plain text?

Without a schema, the AI might return a nicely formatted paragraph one time and a bulleted list the next. Your frontend would have no reliable way to display the results.

Structured output guarantees every response has the same fields with the same types. Your TypeScript code can safely access result.sentimentScore without worrying about what shape the AI decided to use.

  • Save src/services/ai.service.ts.

Seeing a TypeScript error on the import?

If VS Code underlines @google/genai in red, the package may not be installed yet. Double check that you ran npm install @google/genai from the server directory.

Help me fix a TypeScript import error for @google/genai.

✔️ Awesome, I've got everything!

Great. Double check you have saved ai.service.ts before moving on.

ⓧ I'd like to double check the full code

Here is the complete ai.service.ts file for comparison.

import { GoogleGenAI } from "@google/genai";
import { env } from "../config/env";
import { AIAnalysisResult } from "../types";

const ai = new GoogleGenAI({ apiKey: env.GEMINI_API_KEY });

export async function analyzeContent(
  text: string
): Promise<AIAnalysisResult> {
  const response = await ai.models.generateContent({
    model: "gemini-2.5-flash",
    contents: `Analyze the following text and provide a structured analysis:\n\n${text}`,
    config: {
      responseMimeType: "application/json",
      responseSchema: {
        type: "OBJECT",
        properties: {
          summary: {
            type: "STRING",
            description:
              "A concise 2-3 sentence summary of the text",
          },
          keyInsights: {
            type: "ARRAY",
            items: { type: "STRING" },
            description:
              "3-5 key insights or takeaways from the text",
          },
          sentiment: {
            type: "STRING",
            description:
              "Overall sentiment: positive, negative, or neutral",
          },
          sentimentScore: {
            type: "NUMBER",
            description:
              "Sentiment score from -1.0 (very negative) to 1.0 (very positive)",
          },
          readingDifficulty: {
            type: "STRING",
            description:
              "Reading difficulty level: easy, medium, or hard",
          },
          estimatedReadTime: {
            type: "INTEGER",
            description:
              "Estimated reading time in minutes",
          },
        },
        required: [
          "summary",
          "keyInsights",
          "sentiment",
          "sentimentScore",
          "readingDifficulty",
          "estimatedReadTime",
        ],
      },
    },
  });

  const text_response = response.text;
  if (!text_response) {
    throw new Error("AI returned an empty response");
  }

  const parsed: AIAnalysisResult = JSON.parse(text_response);
  return parsed;
}
Test the AI service

Before wiring the AI service into your API, you should confirm it actually works. You will create a small temporary script, run it, and then delete it.

  • Create a new file at src/test-ai.ts and paste the following code:
import { analyzeContent } from "./services/ai.service";

async function test() {
  console.log("Sending text to Gemini...");

  const result = await analyzeContent(
    "Artificial intelligence is transforming how we build software. "
    + "Modern AI models can understand natural language, generate code, "
    + "and analyze text with remarkable accuracy. This technology is "
    + "becoming more accessible every day, with free APIs and open-source "
    + "tools making it possible for any developer to integrate AI into "
    + "their applications."
  );

  console.log("AI Analysis Result:");
  console.log(JSON.stringify(result, null, 2));
}

test();

What does this script do?

This is a throwaway test. It imports your analyzeContent function, sends a sample paragraph about AI to Gemini, and prints the structured JSON result to the terminal.

  • Run the test script from the server directory by running:
npx tsx src/test-ai.ts

After a few seconds, you should see a JSON object printed in your terminal with all six fields: summary, keyInsights, sentiment, sentimentScore, readingDifficulty, and estimatedReadTime.

Seeing an API key error?

If you see API key not valid or a 403 error, open server/.env and make sure GEMINI_API_KEY has your real key with no extra spaces or quotes around it.

If you see an empty response error, the model may have returned nothing. Try running the script again. Transient failures are normal with AI APIs.

Help me debug my Gemini API call that is failing.

  • Once you see valid JSON output, delete the test file by running:
rm src/test-ai.ts
  • Confirm the file is gone by running:
ls src/
  • You should see your existing folders and files (config, services, app.ts, etc.) but no test-ai.ts.

Your AI service is proven and ready. Next up, you will wire it into the create endpoint so every new analysis automatically gets AI-generated results saved to the database.

Connect AI Analysis to the Create Endpoint

Your AI service can analyze text. Your CRUD API can save records to the database. But right now, these two pieces live in separate worlds.

When you create an analysis right now, the createAnalysis function saves empty placeholder values because it never calls the AI. In this step, you'll wire the AI service into the create endpoint so every new analysis gets real Gemini-generated results saved straight to PostgreSQL.

In this step, get ready to:
  • Wire the AI service into the analysis creation flow.
  • Verify error handling covers AI failures.
  • Test the complete end-to-end pipeline from HTTP request to database record.
Update the create flow to include AI analysis

The createAnalysis function in analysis.service.ts currently hardcodes empty strings and zeros for every AI field. You need it to call analyzeContent first, then pass the real results into the database record.

  • In src/services/analysis.service.ts, find the imports at the top of the file:
import prisma from "../lib/prisma";
import { CreateAnalysisInput, UpdateAnalysisInput } from "../types";
  • Add a new import for analyzeContent between the two existing import lines, so the imports look like this:
import prisma from "../lib/prisma";
import { analyzeContent } from "./ai.service";
import { CreateAnalysisInput, UpdateAnalysisInput } from "../types";

Now you need to replace the createAnalysis function body. The current version saves empty values. The new version calls the AI first.

  • Find the existing createAnalysis function and replace it entirely with the following code:
export async function createAnalysis(input: CreateAnalysisInput) {
  const { sourceText, title } = input;

  // Send the text to Gemini AI for analysis
  const aiResult = await analyzeContent(sourceText);

  // Calculate word count and generate a title if none provided
  const wordCount = sourceText.trim().split(/\s+/).length;
  const analysisTitle =
    title || sourceText.substring(0, 50).trim() + "...";

  // Save the AI results alongside the source text
  const analysis = await prisma.analysis.create({
    data: {
      title: analysisTitle,
      sourceText,
      summary: aiResult.summary,
      keyInsights: aiResult.keyInsights,
      sentiment: aiResult.sentiment,
      sentimentScore: aiResult.sentimentScore,
      readingDifficulty: aiResult.readingDifficulty,
      estimatedReadTime: aiResult.estimatedReadTime,
      wordCount,
    },
  });

  return analysis;
}

What does this code do?

  • analyzeContent(sourceText) sends the raw text to Gemini and waits for the structured JSON response. This is the AI service you built in the previous step.
  • aiResult holds all the AI-generated fields: summary, key insights, sentiment, reading difficulty, and estimated read time.
  • The word count is calculated locally from the source text. The title falls back to the first 50 characters if the user did not provide one.
  • Every field from aiResult is mapped directly into the Prisma create call, replacing the old empty placeholders.
  • Save analysis.service.ts.
  • Check your terminal. The server should auto-restart without errors thanks to tsx watch.

Seeing a TypeScript error on save?

  • Make sure the new import line reads import { analyzeContent } from "./ai.service" with a relative path starting with ./. Missing the dot means Node.js looks for an npm package instead of your local file.
  • Check that you did not accidentally delete the rest of the file. The getAllAnalyses, getAnalysisById, updateAnalysis, and deleteAnalysis functions should still be below createAnalysis.
  • Help me debug a TypeScript import error in my analysis service
Understand how errors are handled

AI calls can fail for many reasons. The Gemini API might rate-limit you, the response might come back malformed, or the network connection might drop. You need confidence that your app handles these gracefully instead of crashing.

The good news is your existing architecture already covers this. You built a layered error handling system across your earlier steps, and it catches AI failures automatically.

How do AI errors propagate through the app?

Your error handling works in three layers:

  • Layer 1: ai.service.ts throws an error if Gemini returns an empty response. It also throws if JSON.parse fails on malformed output.
  • Layer 2: analysis.controller.ts wraps every handler in a try-catch block. When createAnalysis throws, the controller catches it and passes it to next(error).
  • Layer 3: error.middleware.ts receives the error and returns a formatted JSON response with success: false and the error message. The client gets a clean error instead of a server crash.

Why not add another try-catch in the service layer?

Adding a try-catch inside createAnalysis would be redundant. The controller already catches all thrown errors. Letting errors bubble up keeps each layer focused on its own job.

The service layer handles business logic. The controller handles request/response flow. The middleware handles error formatting. Each layer trusts the next one to do its part.

Test the end-to-end analysis pipeline

Everything is wired up. Time to send real text through the full pipeline and see AI-generated analysis land in your database.

  • Make sure your dev server is running. If it is not, start it by running:
npm run dev
  • Open a second terminal window in the server folder.
  • Send a POST request with sample text by running this command:
curl -X POST http://localhost:3001/api/analyses -H "Content-Type: application/json" -d "{\"sourceText\": \"Artificial intelligence is transforming how we build software. Modern AI tools can analyze code, generate documentation, and even detect security vulnerabilities. Developers who understand how to integrate AI into their workflows are becoming increasingly valuable in the job market. This shift is creating entirely new categories of tools and career paths that did not exist five years ago.\"}"

What should I see?

The response takes a few seconds because the Gemini API needs time to process your text. You should see a JSON response with success: true and a data object containing all the AI-generated fields: summary, keyInsights, sentiment, sentimentScore, readingDifficulty, and estimatedReadTime.

The fields should have real values this time, not empty strings or zeros.

Getting a 500 error or empty response?

  • Check that your GEMINI_API_KEY in .env is correct. A missing or invalid key causes the AI call to fail.
  • If you see a rate limit error, wait a minute and try again. The Gemini free tier allows 15 requests per minute.
  • Make sure your request body has at least 50 characters in sourceText. The Zod validation rejects shorter text with a 400 error.
  • Help me debug why my POST request to /api/analyses is failing

Now confirm the record actually landed in your database.

  • Retrieve all saved analyses by running:
curl http://localhost:3001/api/analyses
  • You should see a JSON array containing the analysis you just created, with all the AI-generated fields populated.

You can also browse the database visually using Prisma Studio.

  • Open Prisma Studio by running this command in a terminal inside the server folder:
npx prisma studio
  • Prisma Studio opens in your browser at http://localhost:5555.
  • Click the Analysis model to see your saved records.
  • You should see a row with your analysis, including the AI-generated summary, key insights, sentiment, and all other fields filled in.

Test with different types of text

Try sending a few more POST requests with different content. Paste a news article, a technical blog post, or a casual email. Watch how the sentiment, reading difficulty, and insights change based on the input. This helps you verify the AI is producing meaningful, varied results.

Want to double check your updated file matches what it should look like?

✔️ Awesome, I've got everything!

Make sure you have saved analysis.service.ts and your server restarted without errors.

ⓧ I'd like to double check the full code

Here is the complete src/services/analysis.service.ts file. Make sure your file matches this exactly.

import prisma from "../lib/prisma";
import { analyzeContent } from "./ai.service";
import { CreateAnalysisInput, UpdateAnalysisInput } from "../types";

export async function createAnalysis(input: CreateAnalysisInput) {
  const { sourceText, title } = input;

  const aiResult = await analyzeContent(sourceText);

  const wordCount = sourceText.trim().split(/\s+/).length;
  const analysisTitle =
    title || sourceText.substring(0, 50).trim() + "...";

  const analysis = await prisma.analysis.create({
    data: {
      title: analysisTitle,
      sourceText,
      summary: aiResult.summary,
      keyInsights: aiResult.keyInsights,
      sentiment: aiResult.sentiment,
      sentimentScore: aiResult.sentimentScore,
      readingDifficulty: aiResult.readingDifficulty,
      estimatedReadTime: aiResult.estimatedReadTime,
      wordCount,
    },
  });

  return analysis;
}

export async function getAllAnalyses(search?: string, sortBy?: string) {
  const where = search
    ? { title: { contains: search, mode: "insensitive" as const } }
    : {};

  let orderBy: Record<string, string> = { createdAt: "desc" };
  if (sortBy === "oldest") orderBy = { createdAt: "asc" };
  if (sortBy === "sentiment") orderBy = { sentimentScore: "desc" };

  const analyses = await prisma.analysis.findMany({
    where,
    orderBy,
    select: {
      id: true,
      title: true,
      summary: true,
      sentiment: true,
      sentimentScore: true,
      readingDifficulty: true,
      estimatedReadTime: true,
      wordCount: true,
      createdAt: true,
      updatedAt: true,
    },
  });

  return analyses;
}

export async function getAnalysisById(id: string) {
  const analysis = await prisma.analysis.findUnique({
    where: { id },
  });
  return analysis;
}

export async function updateAnalysis(id: string, input: UpdateAnalysisInput) {
  const analysis = await prisma.analysis.update({
    where: { id },
    data: input,
  });
  return analysis;
}

export async function deleteAnalysis(id: string) {
  await prisma.analysis.delete({
    where: { id },
  });
}

Your backend is now a complete AI analysis pipeline. Text goes in, Gemini analyzes it, and the structured results get saved to PostgreSQL. Next up, you'll build the React frontend so you can interact with this API through a real dashboard instead of curl commands.

Scaffold the React + TypeScript Frontend

Your backend is fully operational. It accepts text, analyzes it with Gemini AI, stores results in PostgreSQL, and serves them through a REST API.

Now it's time to build the frontend that users actually interact with. You'll use Vite to scaffold a React + TypeScript project, add Tailwind CSS for styling, and wire up an API client that talks to your Express server.

In this step, get ready to:
  • Scaffold a Vite React project and configure Tailwind CSS.
  • Create shared TypeScript types and an API client layer.
  • Set up React Router and a layout component.
Create the Vite React project

Vite is a modern build tool that scaffolds a ready-to-go React project with TypeScript support baked in. It also comes with a lightning-fast dev server that reloads your browser instantly when you save a file.

  • You're currently inside the server directory. Navigate back to your briefai root folder by running this command:
cd ..
  • Create a new React + TypeScript project using Vite by running this command:
npm create vite@latest client -- --template react-ts

What does this command do?

The --template react-ts flag tells Vite to scaffold a React project pre-configured with TypeScript. The -- separator ensures the template flag gets passed to the create-vite script instead of to npm itself.

You should see a "Scaffolding project in" message followed by "Done" in the terminal.

Seeing a permission or network error?

Make sure you're connected to the internet. Vite downloads the project template from npm.

If you see a permission error on macOS, prefix the command with sudo. On Windows, try running your terminal as Administrator.

Help me troubleshoot the Vite scaffold command.

  • Move into the new client folder and install the base dependencies by running these commands:
cd client
npm install
  • Install the routing and HTTP client libraries by running:
npm install react-router-dom axios
  • Install Tailwind CSS and its Vite plugin as dev dependencies by running:
npm install -D tailwindcss @tailwindcss/vite

What are these packages for?

  • react-router-dom adds client-side page routing so you can navigate between the dashboard and detail views without a full page reload.
  • axios is an HTTP client that makes calling your Express API clean and type-safe.
  • tailwindcss and @tailwindcss/vite let you style components with utility classes directly in your JSX.

All three installs should complete with no errors. You should see "added X packages" messages.

Install failing?

Run node --version to confirm you're on v20 or higher. The @tailwindcss/vite plugin requires a modern Node.js version.

Help me fix my npm install errors in the client folder.

Now you need to configure Vite to use the Tailwind plugin and proxy API requests to your backend server. This proxy lets your frontend call /api endpoints without running into cross-origin issues during development.

  • Open vite.config.ts in VS Code (it's in the client root) and replace all of its contents with this code:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    // Keep the default Vite port
    port: 5173,
    // Forward /api requests to the Express backend
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true,
      },
    },
  },
});
  • Next, open src/index.css and replace all of its contents with this single line:
@import "tailwindcss";

What's happening in these config files?

In vite.config.ts, the proxy setting intercepts any request starting with /api and forwards it to your Express server on port 3001. This means your frontend can use relative URLs like /api/analyses instead of hardcoding http://localhost:3001.

The @import "tailwindcss" line is all Tailwind CSS v4 needs. There's no tailwind.config.js file required anymore.

  • Save both vite.config.ts and src/index.css.

You won't see a visible change yet. The next substep adds the files that bring the app to life.

Seeing red squiggly lines in vite.config.ts?

Make sure you ran npm install -D tailwindcss @tailwindcss/vite in the previous step. The @tailwindcss/vite import won't resolve until the package is installed.

Help me fix TypeScript errors in my vite.config.ts file.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the full vite.config.ts:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    port: 5173,
    proxy: {
      "/api": {
        target: "http://localhost:3001",
        changeOrigin: true,
      },
    },
  },
});

And the full src/index.css:

@import "tailwindcss";
Set up the project structure and shared types

Your frontend needs to know the exact shape of the data your backend sends. By defining TypeScript interfaces that mirror your backend types, you get autocompletion and compile-time error checking across the entire stack.

  • Create the folder structure for your frontend code by running this command:
mkdir src/api src/components src/types src/pages
  • Confirm the folders exist by running:
ls src

You should see api, components, pages, and types listed alongside the existing assets folder.

  • Create a new file at src/types/index.ts and paste in this code:
// Full analysis record (detail view)
export interface Analysis {
  id: string;
  title: string;
  sourceText: string;
  summary: string;
  keyInsights: string[];
  sentiment: string;
  sentimentScore: number;
  readingDifficulty: string;
  estimatedReadTime: number;
  wordCount: number;
  createdAt: string;
  updatedAt: string;
}

// Lighter record for list/card views (no sourceText)
export interface AnalysisListItem {
  id: string;
  title: string;
  summary: string;
  sentiment: string;
  sentimentScore: number;
  readingDifficulty: string;
  estimatedReadTime: number;
  wordCount: number;
  createdAt: string;
  updatedAt: string;
}

// Shape of the data we send when creating an analysis
export interface CreateAnalysisInput {
  sourceText: string;
  title?: string;
}

// Shape of the data we send when updating an analysis
export interface UpdateAnalysisInput {
  title?: string;
}

// Every API response wraps data in this envelope
export interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;
}

Why two different analysis interfaces?

Analysis includes sourceText and is used on the detail page where you need the full record. AnalysisListItem omits sourceText because the dashboard cards don't display the original text. This matches the select query in your backend's getAllAnalyses function.

The ApiResponse<T> generic interface matches the { success: true, data: ... } envelope your Express controllers return.

  • Save src/types/index.ts.

You won't see a change yet. The API client in the next chunk uses these types.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before continuing.

ⓧ I'd like to double check the full code

export interface Analysis {
  id: string;
  title: string;
  sourceText: string;
  summary: string;
  keyInsights: string[];
  sentiment: string;
  sentimentScore: number;
  readingDifficulty: string;
  estimatedReadTime: number;
  wordCount: number;
  createdAt: string;
  updatedAt: string;
}

export interface AnalysisListItem {
  id: string;
  title: string;
  summary: string;
  sentiment: string;
  sentimentScore: number;
  readingDifficulty: string;
  estimatedReadTime: number;
  wordCount: number;
  createdAt: string;
  updatedAt: string;
}

export interface CreateAnalysisInput {
  sourceText: string;
  title?: string;
}

export interface UpdateAnalysisInput {
  title?: string;
}

export interface ApiResponse<T> {
  success: boolean;
  data: T;
  error?: string;
}

Now you'll build an API client that wraps every backend endpoint in a clean, typed function. This keeps HTTP logic out of your React components.

  • Create a new file at src/api/client.ts and paste in this code:
import axios from "axios";
import {
  Analysis,
  AnalysisListItem,
  CreateAnalysisInput,
  UpdateAnalysisInput,
  ApiResponse,
} from "../types";

// Create a reusable Axios instance pointed at our API proxy
const api = axios.create({
  baseURL: "/api",
  headers: { "Content-Type": "application/json" },
});

// Fetch all analyses with optional search and sort
export async function fetchAnalyses(
  search?: string,
  sortBy?: string
): Promise<AnalysisListItem[]> {
  const params = new URLSearchParams();
  if (search) params.set("search", search);
  if (sortBy) params.set("sortBy", sortBy);
  const { data } = await api.get<ApiResponse<AnalysisListItem[]>>(
    `/analyses?${params.toString()}`
  );
  return data.data;
}

// Fetch a single analysis by its ID
export async function fetchAnalysisById(
  id: string
): Promise<Analysis> {
  const { data } = await api.get<ApiResponse<Analysis>>(
    `/analyses/${id}`
  );
  return data.data;
}

// Send text to the backend for AI analysis
export async function createAnalysis(
  input: CreateAnalysisInput
): Promise<Analysis> {
  const { data } = await api.post<ApiResponse<Analysis>>(
    "/analyses",
    input
  );
  return data.data;
}

// Update an existing analysis (e.g. rename the title)
export async function updateAnalysis(
  id: string,
  input: UpdateAnalysisInput
): Promise<Analysis> {
  const { data } = await api.patch<ApiResponse<Analysis>>(
    `/analyses/${id}`,
    input
  );
  return data.data;
}

// Delete an analysis permanently
export async function deleteAnalysis(id: string): Promise<void> {
  await api.delete(`/analyses/${id}`);
}

What does this code do?

  • axios.create() builds a reusable HTTP client with baseURL: "/api". Every request goes to /api/... which the Vite proxy forwards to your Express server.
  • Each function maps to one of your five CRUD endpoints. The generic type on api.get<ApiResponse<T>> tells TypeScript the exact shape of the response.
  • The functions return data.data because Axios wraps the HTTP response in its own data property, and your API wraps the payload in { success, data }. So it's axiosResponse.data.data to get the actual record.
  • Save src/api/client.ts.

You won't see a change yet. These functions get called by your React components in the next steps.

Seeing import errors in client.ts?

Make sure src/types/index.ts exists and is saved. The import path ../types resolves to src/types/index.ts automatically.

Help me fix import errors in my API client file.

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

import axios from "axios";
import {
  Analysis,
  AnalysisListItem,
  CreateAnalysisInput,
  UpdateAnalysisInput,
  ApiResponse,
} from "../types";

const api = axios.create({
  baseURL: "/api",
  headers: { "Content-Type": "application/json" },
});

export async function fetchAnalyses(
  search?: string,
  sortBy?: string
): Promise<AnalysisListItem[]> {
  const params = new URLSearchParams();
  if (search) params.set("search", search);
  if (sortBy) params.set("sortBy", sortBy);
  const { data } = await api.get<ApiResponse<AnalysisListItem[]>>(
    `/analyses?${params.toString()}`
  );
  return data.data;
}

export async function fetchAnalysisById(
  id: string
): Promise<Analysis> {
  const { data } = await api.get<ApiResponse<Analysis>>(
    `/analyses/${id}`
  );
  return data.data;
}

export async function createAnalysis(
  input: CreateAnalysisInput
): Promise<Analysis> {
  const { data } = await api.post<ApiResponse<Analysis>>(
    "/analyses",
    input
  );
  return data.data;
}

export async function updateAnalysis(
  id: string,
  input: UpdateAnalysisInput
): Promise<Analysis> {
  const { data } = await api.patch<ApiResponse<Analysis>>(
    `/analyses/${id}`,
    input
  );
  return data.data;
}

export async function deleteAnalysis(id: string): Promise<void> {
  await api.delete(`/analyses/${id}`);
}
Configure routing and a layout component

Your app needs a consistent header on every page and a way to navigate between the dashboard and analysis detail views. React Router handles the navigation, and a Layout component wraps every page with shared UI.

  • Create a new file at src/components/Layout.tsx and paste in this code:
import { ReactNode } from "react";
import { Link } from "react-router-dom";

interface LayoutProps {
  children: ReactNode;
}

export default function Layout({ children }: LayoutProps) {
  return (
    <div className="min-h-screen bg-gray-50">
      {/* Sticky header with app name and tagline */}
      <header className="bg-white border-b border-gray-200">
        <div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
          <Link to="/" className="text-2xl font-bold text-indigo-600">
            BriefAI
          </Link>
          <p className="text-sm text-gray-500">
            AI-Powered Content Analysis
          </p>
        </div>
      </header>
      {/* Page content renders here */}
      <main className="max-w-6xl mx-auto px-4 py-8">
        {children}
      </main>
    </div>
  );
}

What does this code do?

  • LayoutProps uses the ReactNode type to accept any valid React content as children.
  • The Link component from React Router makes the "BriefAI" logo a clickable link back to the dashboard. It navigates without a full page reload.
  • All the className strings are Tailwind utility classes. For example, min-h-screen bg-gray-50 makes the page fill the viewport with a light gray background.
  • Save src/components/Layout.tsx.

You won't see a change yet. The Layout renders once you wire it into App.tsx next.

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before continuing.

ⓧ I'd like to double check the full code

import { ReactNode } from "react";
import { Link } from "react-router-dom";

interface LayoutProps {
  children: ReactNode;
}

export default function Layout({ children }: LayoutProps) {
  return (
    <div className="min-h-screen bg-gray-50">
      <header className="bg-white border-b border-gray-200">
        <div className="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
          <Link to="/" className="text-2xl font-bold text-indigo-600">
            BriefAI
          </Link>
          <p className="text-sm text-gray-500">
            AI-Powered Content Analysis
          </p>
        </div>
      </header>
      <main className="max-w-6xl mx-auto px-4 py-8">
        {children}
      </main>
    </div>
  );
}

Before you can update App.tsx with route definitions, the page components those routes point to need to exist. You'll build the full pages in later steps, but for now you need simple placeholders so the app compiles.

  • Create a new file at src/pages/Dashboard.tsx and paste in this placeholder:
// Placeholder - you'll build the full dashboard in a later step
export default function Dashboard() {
  return <h1 className="text-2xl font-bold">Dashboard</h1>;
}
  • Create another file at src/pages/AnalysisDetailPage.tsx and paste in this placeholder:
// Placeholder - you'll build the full detail view in a later step
export default function AnalysisDetailPage() {
  return <h1 className="text-2xl font-bold">Analysis Detail</h1>;
}
  • Save both placeholder files.

Now you can wire everything together in the main app file.

  • Open src/App.tsx and replace all of its contents with this code:
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import AnalysisDetailPage from "./pages/AnalysisDetailPage";

function App() {
  return (
    <BrowserRouter>
      {/* Layout wraps all routes with a shared header */}
      <Layout>
        <Routes>
          {/* Dashboard at the root URL */}
          <Route path="/" element={<Dashboard />} />
          {/* Detail view with a dynamic :id parameter */}
          <Route
            path="/analysis/:id"
            element={<AnalysisDetailPage />}
          />
        </Routes>
      </Layout>
    </BrowserRouter>
  );
}

export default App;

What does this code do?

  • BrowserRouter enables client-side routing. It watches the browser URL and renders the matching Route component.
  • The Layout component wraps all routes, so the header appears on every page.
  • The :id in /analysis/:id is a dynamic URL parameter. When someone visits /analysis/abc-123, React Router passes abc-123 as the id param.
  • Save src/App.tsx.
  • Delete the file src/App.css. It contains Vite's default styles that you no longer need.

VS Code showing errors in App.tsx?

Make sure src/pages/Dashboard.tsx and src/pages/AnalysisDetailPage.tsx both exist and are saved. The imports in App.tsx point to these files.

If you see an error about App.css not found, that means the old App.tsx is still loaded. Make sure you replaced all of its contents with the new code.

Help me fix errors in my App.tsx file.

✔️ Awesome, I've got everything!

Great. Double check you've saved all files before running the dev server.

ⓧ I'd like to double check the full code

Here is the full App.tsx:

import { BrowserRouter, Routes, Route } from "react-router-dom";
import Layout from "./components/Layout";
import Dashboard from "./pages/Dashboard";
import AnalysisDetailPage from "./pages/AnalysisDetailPage";

function App() {
  return (
    <BrowserRouter>
      <Layout>
        <Routes>
          <Route path="/" element={<Dashboard />} />
          <Route
            path="/analysis/:id"
            element={<AnalysisDetailPage />}
          />
        </Routes>
      </Layout>
    </BrowserRouter>
  );
}

export default App;
  • Start the Vite dev server by running:
npm run dev
  • Open your browser to http://localhost:5173.

You should see a white header bar with "BriefAI" in indigo text on the left and "AI-Powered Content Analysis" on the right. Below the header, the placeholder "Dashboard" heading appears on a light gray background.

Seeing a blank page or error?

Open your browser's DevTools console (F12) and check for red error messages. The most common cause is a missing import. Make sure all four files (Layout.tsx, Dashboard.tsx, AnalysisDetailPage.tsx, App.tsx) are saved.

If Tailwind styles aren't showing (no gray background, no indigo text), confirm that src/index.css contains only @import "tailwindcss"; and that vite.config.ts includes tailwindcss() in the plugins array.

Help me fix my Vite React app that isn't loading correctly.

Your frontend skeleton is up and running with routing, a layout, shared types, and an API client ready to go. Next up, you'll build the analysis form where users paste text and send it to the AI.

Build the Analysis Form and Dashboard

Your backend API can analyze text and store results in the database. Your frontend has routing, a layout, and an API client ready to talk to that backend. The missing piece is the actual user interface that ties it all together.

In this step, you'll build the core React components that make up the BriefAI dashboard. You'll create a form for submitting text to the AI, card components that display each analysis, and a dashboard page that brings everything together with search and sorting.

In this step, get ready to:
  • Create the analysis form with text input, validation, and AI submission.
  • Build reusable card and list components for displaying analyses.
  • Assemble the dashboard page with search and sort controls.
Create the analysis form component

The form is the primary way users interact with BriefAI. It needs a large textarea for pasting content, an optional title field, live word and character counts, input validation, and a loading state while the AI processes the text.

  • Create a new file at src/components/AnalysisForm.tsx inside the client folder, then paste in the following code:
import { useState, FormEvent } from "react";
import { createAnalysis } from "../api/client";

// Define the props this component accepts
interface AnalysisFormProps {
  onAnalysisCreated: () => void;
}

export default function AnalysisForm({ onAnalysisCreated }: AnalysisFormProps) {
  // Form state for user inputs
  const [sourceText, setSourceText] = useState("");
  const [title, setTitle] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  // Derived values that recalculate when sourceText changes
  const wordCount = sourceText.trim() ? sourceText.trim().split(/\s+/).length : 0;
  const isValid = sourceText.length >= 50;

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!isValid) return;
    setLoading(true);
    setError("");
    try {
      // Send text to the backend for AI analysis
      await createAnalysis({ sourceText, title: title || undefined });
      setSourceText("");
      setTitle("");
      // Tell the parent component to refresh the analysis list
      onAnalysisCreated();
    } catch (err) {
      setError("Failed to analyze content. Please try again.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 mb-8">
      <h2 className="text-lg font-semibold text-gray-900 mb-4">Analyze New Content</h2>
      <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title (optional)" className="w-full px-3 py-2 border border-gray-300 rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
      <textarea value={sourceText} onChange={(e) => setSourceText(e.target.value)} placeholder="Paste your article, essay, or text here (minimum 50 characters)..." rows={8} className="w-full px-3 py-2 border border-gray-300 rounded-md resize-y focus:outline-none focus:ring-2 focus:ring-indigo-500" />
      <div className="flex items-center justify-between mt-2 mb-4">
        <span className="text-sm text-gray-500">{wordCount} words | {sourceText.length} characters</span>
        {sourceText.length > 0 && !isValid && <span className="text-sm text-red-500">Minimum 50 characters required</span>}
      </div>
      {error && <p className="text-red-500 text-sm mb-4">{error}</p>}
      <button type="submit" disabled={!isValid || loading} className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
        {loading ? "Analyzing with AI..." : "Analyze Content"}
      </button>
    </form>
  );
}

What does this code do?

This component manages a form with four pieces of state: the source text, an optional title, a loading flag, and an error message.

  • wordCount and isValid are derived values that recalculate every time sourceText changes. The form requires at least 50 characters before allowing submission.
  • handleSubmit calls the createAnalysis API function you built in the previous step. On success, it clears the form and calls onAnalysisCreated() so the parent component can refresh the list.
  • The submit button shows "Analyzing with AI..." and becomes disabled while the request is in progress. This prevents duplicate submissions.
  • Save AnalysisForm.tsx.

You won't see the form yet. The Dashboard page in the next substep renders this component.

✔️ Awesome, I've got everything!

Great. Make sure you've saved AnalysisForm.tsx before moving on.

ⓧ I'd like to double check the full code

import { useState, FormEvent } from "react";
import { createAnalysis } from "../api/client";

interface AnalysisFormProps {
  onAnalysisCreated: () => void;
}

export default function AnalysisForm({ onAnalysisCreated }: AnalysisFormProps) {
  const [sourceText, setSourceText] = useState("");
  const [title, setTitle] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const wordCount = sourceText.trim() ? sourceText.trim().split(/\s+/).length : 0;
  const isValid = sourceText.length >= 50;

  async function handleSubmit(e: FormEvent) {
    e.preventDefault();
    if (!isValid) return;
    setLoading(true);
    setError("");
    try {
      await createAnalysis({ sourceText, title: title || undefined });
      setSourceText("");
      setTitle("");
      onAnalysisCreated();
    } catch (err) {
      setError("Failed to analyze content. Please try again.");
    } finally {
      setLoading(false);
    }
  }

  return (
    <form onSubmit={handleSubmit} className="bg-white rounded-lg shadow p-6 mb-8">
      <h2 className="text-lg font-semibold text-gray-900 mb-4">Analyze New Content</h2>
      <input type="text" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title (optional)" className="w-full px-3 py-2 border border-gray-300 rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
      <textarea value={sourceText} onChange={(e) => setSourceText(e.target.value)} placeholder="Paste your article, essay, or text here (minimum 50 characters)..." rows={8} className="w-full px-3 py-2 border border-gray-300 rounded-md resize-y focus:outline-none focus:ring-2 focus:ring-indigo-500" />
      <div className="flex items-center justify-between mt-2 mb-4">
        <span className="text-sm text-gray-500">{wordCount} words | {sourceText.length} characters</span>
        {sourceText.length > 0 && !isValid && <span className="text-sm text-red-500">Minimum 50 characters required</span>}
      </div>
      {error && <p className="text-red-500 text-sm mb-4">{error}</p>}
      <button type="submit" disabled={!isValid || loading} className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors">
        {loading ? "Analyzing with AI..." : "Analyze Content"}
      </button>
    </form>
  );
}
Create the analysis card and list components

Each saved analysis needs a visual representation. You'll build two components: a card that displays a single analysis, and a list that arranges those cards in a responsive grid with loading and empty states.

  • Create a new file at src/components/AnalysisCard.tsx inside the client folder, then paste in the following code:
import { Link } from "react-router-dom";
import { AnalysisListItem } from "../types";

interface AnalysisCardProps {
  analysis: AnalysisListItem;
}

// Map sentiment values to Tailwind color classes
function getSentimentColor(sentiment: string): string {
  switch (sentiment.toLowerCase()) {
    case "positive": return "bg-green-100 text-green-800";
    case "negative": return "bg-red-100 text-red-800";
    default: return "bg-yellow-100 text-yellow-800";
  }
}

// Map difficulty levels to Tailwind color classes
function getDifficultyColor(difficulty: string): string {
  switch (difficulty.toLowerCase()) {
    case "easy": return "text-green-600";
    case "hard": return "text-red-600";
    default: return "text-yellow-600";
  }
}

export default function AnalysisCard({ analysis }: AnalysisCardProps) {
  return (
    <Link to={`/analysis/${analysis.id}`} className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow p-5">
      <h3 className="font-semibold text-gray-900 mb-2 truncate">{analysis.title}</h3>
      <p className="text-sm text-gray-600 mb-3 line-clamp-2">{analysis.summary}</p>
      <div className="flex items-center gap-2 flex-wrap">
        {/* Color-coded sentiment badge */}
        <span className={`text-xs font-medium px-2 py-1 rounded-full ${getSentimentColor(analysis.sentiment)}`}>{analysis.sentiment}</span>
        {/* Color-coded difficulty label */}
        <span className={`text-xs font-medium ${getDifficultyColor(analysis.readingDifficulty)}`}>{analysis.readingDifficulty}</span>
        <span className="text-xs text-gray-400">{analysis.wordCount} words</span>
        <span className="text-xs text-gray-400">{analysis.estimatedReadTime} min read</span>
      </div>
      <p className="text-xs text-gray-400 mt-3">{new Date(analysis.createdAt).toLocaleDateString()}</p>
    </Link>
  );
}

What does this code do?

  • getSentimentColor and getDifficultyColor are helper functions that map AI-generated string values to Tailwind CSS color classes. Positive sentiment gets green, negative gets red, and neutral defaults to yellow.
  • The entire card is wrapped in a Link from React Router. Clicking any card navigates to its detail page at /analysis/:id.
  • truncate on the title and line-clamp-2 on the summary prevent long text from breaking the card layout.
  • Save AnalysisCard.tsx.

Now the card component needs a list to live in. The list component handles three states: loading (skeleton placeholders), empty (a friendly message), and populated (a responsive grid of cards).

  • Create a new file at src/components/AnalysisList.tsx inside the client folder, then paste in the following code:
import { AnalysisListItem } from "../types";
import AnalysisCard from "./AnalysisCard";

interface AnalysisListProps {
  analyses: AnalysisListItem[];
  loading: boolean;
}

export default function AnalysisList({ analyses, loading }: AnalysisListProps) {
  // Show animated placeholder cards while data is loading
  if (loading) {
    return (
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="bg-white rounded-lg shadow p-5 animate-pulse">
            <div className="h-5 bg-gray-200 rounded w-3/4 mb-3"></div>
            <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
            <div className="h-4 bg-gray-200 rounded w-2/3"></div>
          </div>
        ))}
      </div>
    );
  }

  // Show a friendly message when no analyses exist yet
  if (analyses.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No analyses yet</p>
        <p className="text-gray-400 text-sm mt-1">Paste some text above to get started</p>
      </div>
    );
  }

  // Render analysis cards in a responsive grid
  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {analyses.map((analysis) => (
        <AnalysisCard key={analysis.id} analysis={analysis} />
      ))}
    </div>
  );
}

What does this code do?

  • The component checks for the loading state first and renders three pulsing gray rectangles as skeleton placeholders. This gives users visual feedback while data loads.
  • When analyses.length === 0, the component shows an empty state message instead of a blank page.
  • The responsive grid uses Tailwind breakpoints: 1 column on mobile, 2 on medium screens (md:), and 3 on large screens (lg:).
  • Save AnalysisList.tsx.

You won't see these components yet. The Dashboard page in the next substep imports and renders them.

✔️ Awesome, I've got everything!

Make sure you've saved both AnalysisCard.tsx and AnalysisList.tsx before moving on.

ⓧ I'd like to double check AnalysisCard.tsx

import { Link } from "react-router-dom";
import { AnalysisListItem } from "../types";

interface AnalysisCardProps {
  analysis: AnalysisListItem;
}

function getSentimentColor(sentiment: string): string {
  switch (sentiment.toLowerCase()) {
    case "positive": return "bg-green-100 text-green-800";
    case "negative": return "bg-red-100 text-red-800";
    default: return "bg-yellow-100 text-yellow-800";
  }
}

function getDifficultyColor(difficulty: string): string {
  switch (difficulty.toLowerCase()) {
    case "easy": return "text-green-600";
    case "hard": return "text-red-600";
    default: return "text-yellow-600";
  }
}

export default function AnalysisCard({ analysis }: AnalysisCardProps) {
  return (
    <Link to={`/analysis/${analysis.id}`} className="block bg-white rounded-lg shadow hover:shadow-md transition-shadow p-5">
      <h3 className="font-semibold text-gray-900 mb-2 truncate">{analysis.title}</h3>
      <p className="text-sm text-gray-600 mb-3 line-clamp-2">{analysis.summary}</p>
      <div className="flex items-center gap-2 flex-wrap">
        <span className={`text-xs font-medium px-2 py-1 rounded-full ${getSentimentColor(analysis.sentiment)}`}>{analysis.sentiment}</span>
        <span className={`text-xs font-medium ${getDifficultyColor(analysis.readingDifficulty)}`}>{analysis.readingDifficulty}</span>
        <span className="text-xs text-gray-400">{analysis.wordCount} words</span>
        <span className="text-xs text-gray-400">{analysis.estimatedReadTime} min read</span>
      </div>
      <p className="text-xs text-gray-400 mt-3">{new Date(analysis.createdAt).toLocaleDateString()}</p>
    </Link>
  );
}

ⓧ I'd like to double check AnalysisList.tsx

import { AnalysisListItem } from "../types";
import AnalysisCard from "./AnalysisCard";

interface AnalysisListProps {
  analyses: AnalysisListItem[];
  loading: boolean;
}

export default function AnalysisList({ analyses, loading }: AnalysisListProps) {
  if (loading) {
    return (
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
        {[1, 2, 3].map((i) => (
          <div key={i} className="bg-white rounded-lg shadow p-5 animate-pulse">
            <div className="h-5 bg-gray-200 rounded w-3/4 mb-3"></div>
            <div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
            <div className="h-4 bg-gray-200 rounded w-2/3"></div>
          </div>
        ))}
      </div>
    );
  }

  if (analyses.length === 0) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500 text-lg">No analyses yet</p>
        <p className="text-gray-400 text-sm mt-1">Paste some text above to get started</p>
      </div>
    );
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
      {analyses.map((analysis) => (
        <AnalysisCard key={analysis.id} analysis={analysis} />
      ))}
    </div>
  );
}
Build the dashboard page

The Dashboard page is the conductor that orchestrates all your components. It manages the list of analyses in state, fetches data from the API, and passes callbacks so the form can trigger a refresh when a new analysis is created.

  • Create a new file at src/pages/Dashboard.tsx inside the client folder, then paste in the following code:
import { useState, useEffect, useCallback } from "react";
import { fetchAnalyses } from "../api/client";
import { AnalysisListItem } from "../types";
import AnalysisForm from "../components/AnalysisForm";
import AnalysisList from "../components/AnalysisList";

export default function Dashboard() {
  // State for the analysis list, loading indicator, and filter controls
  const [analyses, setAnalyses] = useState<AnalysisListItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState("");
  const [sortBy, setSortBy] = useState("newest");

  // Memoized fetch function that re-runs when search or sortBy changes
  const loadAnalyses = useCallback(async () => {
    setLoading(true);
    try {
      const data = await fetchAnalyses(search || undefined, sortBy);
      setAnalyses(data);
    } catch (error) {
      console.error("Failed to fetch analyses:", error);
    } finally {
      setLoading(false);
    }
  }, [search, sortBy]);

  // Fetch analyses on mount and whenever loadAnalyses changes
  useEffect(() => {
    loadAnalyses();
  }, [loadAnalyses]);

  return (
    <div>
      {/* Analysis submission form */}
      <AnalysisForm onAnalysisCreated={loadAnalyses} />

      {/* Search and sort controls */}
      <div className="flex items-center gap-4 mb-6">
        <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search analyses..." className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" />
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500">
          <option value="newest">Newest First</option>
          <option value="oldest">Oldest First</option>
          <option value="sentiment">By Sentiment</option>
        </select>
      </div>

      {/* Analysis count */}
      <p className="text-sm text-gray-500 mb-4">{analyses.length} analysis{analyses.length !== 1 ? "es" : ""}</p>

      {/* Responsive card grid */}
      <AnalysisList analyses={analyses} loading={loading} />
    </div>
  );
}

What does this code do?

  • useCallback wraps the loadAnalyses function so it only gets recreated when search or sortBy changes. This prevents unnecessary re-fetches on every render.
  • useEffect calls loadAnalyses when the component first mounts and whenever the search or sort state changes. Typing in the search bar or selecting a new sort order triggers a fresh API call automatically.
  • Passing loadAnalyses as the onAnalysisCreated callback to AnalysisForm means the list refreshes immediately after a successful submission.
  • Save Dashboard.tsx.
  • Make sure both your backend (npm run dev in the server folder) and frontend (npm run dev in the client folder) are running.
  • Open your browser to http://localhost:5173.

You should see the BriefAI header, the "Analyze New Content" form with a title input and textarea, a search bar and sort dropdown below the form, and an empty state message that reads "No analyses yet."

  • Try typing a few characters into the textarea. You should see the word and character count update live below the input.
  • The Analyze Content button stays grayed out until you have at least 50 characters.

Seeing a blank page or errors in the console?

  • Check that the file paths match exactly. Dashboard.tsx must be inside src/pages/, and AnalysisForm.tsx, AnalysisCard.tsx, and AnalysisList.tsx must all be inside src/components/.
  • If you see a "Cannot find module" error, check that your import paths use ../api/client (with ../) and not ./api/client. Components inside src/components/ need to go up one level to reach the api/ folder.
  • If the page loads but styling looks off, confirm that @import "tailwindcss"; is at the top of your src/index.css file.

Why is my BriefAI dashboard not rendering correctly?

✔️ Awesome, I've got everything!

Make sure you've saved all four files before moving on.

ⓧ I'd like to double check Dashboard.tsx

import { useState, useEffect, useCallback } from "react";
import { fetchAnalyses } from "../api/client";
import { AnalysisListItem } from "../types";
import AnalysisForm from "../components/AnalysisForm";
import AnalysisList from "../components/AnalysisList";

export default function Dashboard() {
  const [analyses, setAnalyses] = useState<AnalysisListItem[]>([]);
  const [loading, setLoading] = useState(true);
  const [search, setSearch] = useState("");
  const [sortBy, setSortBy] = useState("newest");

  const loadAnalyses = useCallback(async () => {
    setLoading(true);
    try {
      const data = await fetchAnalyses(search || undefined, sortBy);
      setAnalyses(data);
    } catch (error) {
      console.error("Failed to fetch analyses:", error);
    } finally {
      setLoading(false);
    }
  }, [search, sortBy]);

  useEffect(() => {
    loadAnalyses();
  }, [loadAnalyses]);

  return (
    <div>
      <AnalysisForm onAnalysisCreated={loadAnalyses} />
      <div className="flex items-center gap-4 mb-6">
        <input type="text" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search analyses..." className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" />
        <select value={sortBy} onChange={(e) => setSortBy(e.target.value)} className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500">
          <option value="newest">Newest First</option>
          <option value="oldest">Oldest First</option>
          <option value="sentiment">By Sentiment</option>
        </select>
      </div>
      <p className="text-sm text-gray-500 mb-4">{analyses.length} analysis{analyses.length !== 1 ? "es" : ""}</p>
      <AnalysisList analyses={analyses} loading={loading} />
    </div>
  );
}

Your dashboard is looking sharp. You have a form that validates input and talks to your AI backend, cards that display results with color-coded badges, and a list that handles loading and empty states gracefully. Next up, you'll build the detail view so users can dive into a single analysis and edit or delete it.

Build the Analysis Detail View and Final Polish

Your dashboard is live. You can create analyses, browse them in a searchable grid, and sort by date or sentiment.

One critical piece is still missing: when a learner clicks an analysis card, there is nowhere to go. In this step, you will build the detail page that shows the full analysis. You will also wire up inline title editing and a delete button with a confirmation dialog. Then you will run a final end-to-end test across the entire app.

In this step, get ready to:
  • Create the analysis detail page with summary, key insights, and collapsible source text.
  • Add inline title editing and a delete flow with a confirmation dialog.
  • Run a final end-to-end test of the full CRUD workflow.
Create the analysis detail page

The detail page is the deepest view in the app. When a user clicks an analysis card on the dashboard, React Router navigates to /analysis/:id and this page fetches the full record from the API.

The route is already wired up in App.tsx from the previous step. You just need to create the component itself.

  • Create a new file called AnalysisDetailPage.tsx inside the client/src/pages/ folder, then paste the following code:
import { useState, useEffect } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { fetchAnalysisById, updateAnalysis, deleteAnalysis } from "../api/client";
import { Analysis } from "../types";

export default function AnalysisDetailPage() {
  // Pull the analysis ID from the URL
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();

  // State for the analysis data and UI modes
  const [analysis, setAnalysis] = useState<Analysis | null>(null);
  const [loading, setLoading] = useState(true);
  const [editing, setEditing] = useState(false);
  const [editTitle, setEditTitle] = useState("");
  const [showSource, setShowSource] = useState(false);

  // Fetch the full analysis when the page loads
  useEffect(() => {
    if (!id) return;
    fetchAnalysisById(id)
      .then((data) => { setAnalysis(data); setEditTitle(data.title); })
      .catch(() => navigate("/"))
      .finally(() => setLoading(false));
  }, [id, navigate]);

  // Save an updated title via the PATCH endpoint
  async function handleSaveTitle() {
    if (!id || !editTitle.trim()) return;
    const updated = await updateAnalysis(id, { title: editTitle });
    setAnalysis(updated);
    setEditing(false);
  }

  // Delete the analysis after user confirmation
  async function handleDelete() {
    if (!id) return;
    if (!window.confirm("Are you sure you want to delete this analysis?")) return;
    await deleteAnalysis(id);
    navigate("/");
  }

  if (loading) return <p className="text-gray-500">Loading...</p>;
  if (!analysis) return <p className="text-gray-500">Analysis not found</p>;

  // Color-code the sentiment badge
  function getSentimentColor(sentiment: string): string {
    switch (sentiment.toLowerCase()) {
      case "positive": return "bg-green-100 text-green-800";
      case "negative": return "bg-red-100 text-red-800";
      default: return "bg-yellow-100 text-yellow-800";
    }
  }

  return (
    <div>
      <Link to="/" className="text-indigo-600 hover:text-indigo-800 text-sm mb-4 inline-block">&larr; Back to Dashboard</Link>
      <div className="bg-white rounded-lg shadow p-6">
        <div className="flex items-start justify-between mb-6">
          {editing ? (
            <div className="flex items-center gap-2 flex-1">
              <input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" />
              <button onClick={handleSaveTitle} className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Save</button>
              <button onClick={() => setEditing(false)} className="text-gray-500 px-4 py-2">Cancel</button>
            </div>
          ) : (
            <h1 className="text-2xl font-bold text-gray-900">{analysis.title}</h1>
          )}
          <div className="flex gap-2 ml-4">
            {!editing && <button onClick={() => setEditing(true)} className="text-indigo-600 hover:text-indigo-800 text-sm">Edit</button>}
            <button onClick={handleDelete} className="text-red-600 hover:text-red-800 text-sm">Delete</button>
          </div>
        </div>
        <div className="flex items-center gap-3 mb-6">
          <span className={`text-sm font-medium px-3 py-1 rounded-full ${getSentimentColor(analysis.sentiment)}`}>{analysis.sentiment} ({analysis.sentimentScore.toFixed(1)})</span>
          <span className="text-sm text-gray-500">{analysis.readingDifficulty} difficulty</span>
          <span className="text-sm text-gray-500">{analysis.wordCount} words</span>
          <span className="text-sm text-gray-500">{analysis.estimatedReadTime} min read</span>
        </div>
        <section className="mb-6">
          <h2 className="text-lg font-semibold text-gray-900 mb-2">Summary</h2>
          <p className="text-gray-700 leading-relaxed">{analysis.summary}</p>
        </section>
        <section className="mb-6">
          <h2 className="text-lg font-semibold text-gray-900 mb-2">Key Insights</h2>
          <ul className="list-disc list-inside space-y-1">
            {analysis.keyInsights.map((insight, index) => (
              <li key={index} className="text-gray-700">{insight}</li>
            ))}
          </ul>
        </section>
        <section>
          <button onClick={() => setShowSource(!showSource)} className="text-indigo-600 hover:text-indigo-800 text-sm font-medium">
            {showSource ? "Hide" : "Show"} Original Text
          </button>
          {showSource && (
            <div className="mt-3 p-4 bg-gray-50 rounded-md">
              <p className="text-gray-600 text-sm whitespace-pre-wrap">{analysis.sourceText}</p>
            </div>
          )}
        </section>
        <p className="text-xs text-gray-400 mt-6">Created: {new Date(analysis.createdAt).toLocaleString()}</p>
      </div>
    </div>
  );
}

What does this component do?

This is the most feature-rich component in the app. Here is what each section handles:

  • useParams extracts the analysis id from the URL, and the useEffect fetches the full record on mount. If the fetch fails, the user is redirected back to the dashboard.
  • handleSaveTitle calls the PATCH endpoint to update the title, then swaps back from editing mode to display mode.
  • handleDelete shows a browser confirmation dialog before calling the DELETE endpoint. After deletion, the user is navigated back to the dashboard.
  • The showSource state toggles a collapsible section that reveals the original pasted text. This keeps the page clean while still letting the user reference the source.
  • Save AnalysisDetailPage.tsx.
  • In your browser at http://localhost:5173, click on any analysis card in the dashboard.
  • You should see the full detail page with a back link, the title, a color-coded sentiment badge, the summary, a bulleted list of key insights, and a "Show Original Text" toggle at the bottom.

Page shows "Loading..." and never finishes?

  • Make sure your backend server is still running in the server folder. The detail page fetches from /api/analyses/:id, which requires the Express server on port 3001.
  • Check the browser console for errors. A 404 means the analysis ID in the URL does not match any record in the database. Try clicking a card from the dashboard instead of typing a URL manually.
  • If the page redirects back to the dashboard immediately, the .catch(() => navigate("/")) is firing. Open your browser's Network tab and check the response from the API for error details.

Help me debug why my analysis detail page isn't loading.

✔️ Awesome, I've got everything!

Make sure you have saved AnalysisDetailPage.tsx before moving on.

ⓧ I'd like to double check the full code

Here is the complete AnalysisDetailPage.tsx file for comparison.

import { useState, useEffect } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { fetchAnalysisById, updateAnalysis, deleteAnalysis } from "../api/client";
import { Analysis } from "../types";

export default function AnalysisDetailPage() {
  const { id } = useParams<{ id: string }>();
  const navigate = useNavigate();
  const [analysis, setAnalysis] = useState<Analysis | null>(null);
  const [loading, setLoading] = useState(true);
  const [editing, setEditing] = useState(false);
  const [editTitle, setEditTitle] = useState("");
  const [showSource, setShowSource] = useState(false);

  useEffect(() => {
    if (!id) return;
    fetchAnalysisById(id)
      .then((data) => { setAnalysis(data); setEditTitle(data.title); })
      .catch(() => navigate("/"))
      .finally(() => setLoading(false));
  }, [id, navigate]);

  async function handleSaveTitle() {
    if (!id || !editTitle.trim()) return;
    const updated = await updateAnalysis(id, { title: editTitle });
    setAnalysis(updated);
    setEditing(false);
  }

  async function handleDelete() {
    if (!id) return;
    if (!window.confirm("Are you sure you want to delete this analysis?")) return;
    await deleteAnalysis(id);
    navigate("/");
  }

  if (loading) return <p className="text-gray-500">Loading...</p>;
  if (!analysis) return <p className="text-gray-500">Analysis not found</p>;

  function getSentimentColor(sentiment: string): string {
    switch (sentiment.toLowerCase()) {
      case "positive": return "bg-green-100 text-green-800";
      case "negative": return "bg-red-100 text-red-800";
      default: return "bg-yellow-100 text-yellow-800";
    }
  }

  return (
    <div>
      <Link to="/" className="text-indigo-600 hover:text-indigo-800 text-sm mb-4 inline-block">&larr; Back to Dashboard</Link>
      <div className="bg-white rounded-lg shadow p-6">
        <div className="flex items-start justify-between mb-6">
          {editing ? (
            <div className="flex items-center gap-2 flex-1">
              <input type="text" value={editTitle} onChange={(e) => setEditTitle(e.target.value)} className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500" />
              <button onClick={handleSaveTitle} className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700">Save</button>
              <button onClick={() => setEditing(false)} className="text-gray-500 px-4 py-2">Cancel</button>
            </div>
          ) : (
            <h1 className="text-2xl font-bold text-gray-900">{analysis.title}</h1>
          )}
          <div className="flex gap-2 ml-4">
            {!editing && <button onClick={() => setEditing(true)} className="text-indigo-600 hover:text-indigo-800 text-sm">Edit</button>}
            <button onClick={handleDelete} className="text-red-600 hover:text-red-800 text-sm">Delete</button>
          </div>
        </div>
        <div className="flex items-center gap-3 mb-6">
          <span className={`text-sm font-medium px-3 py-1 rounded-full ${getSentimentColor(analysis.sentiment)}`}>{analysis.sentiment} ({analysis.sentimentScore.toFixed(1)})</span>
          <span className="text-sm text-gray-500">{analysis.readingDifficulty} difficulty</span>
          <span className="text-sm text-gray-500">{analysis.wordCount} words</span>
          <span className="text-sm text-gray-500">{analysis.estimatedReadTime} min read</span>
        </div>
        <section className="mb-6">
          <h2 className="text-lg font-semibold text-gray-900 mb-2">Summary</h2>
          <p className="text-gray-700 leading-relaxed">{analysis.summary}</p>
        </section>
        <section className="mb-6">
          <h2 className="text-lg font-semibold text-gray-900 mb-2">Key Insights</h2>
          <ul className="list-disc list-inside space-y-1">
            {analysis.keyInsights.map((insight, index) => (
              <li key={index} className="text-gray-700">{insight}</li>
            ))}
          </ul>
        </section>
        <section>
          <button onClick={() => setShowSource(!showSource)} className="text-indigo-600 hover:text-indigo-800 text-sm font-medium">
            {showSource ? "Hide" : "Show"} Original Text
          </button>
          {showSource && (
            <div className="mt-3 p-4 bg-gray-50 rounded-md">
              <p className="text-gray-600 text-sm whitespace-pre-wrap">{analysis.sourceText}</p>
            </div>
          )}
        </section>
        <p className="text-xs text-gray-400 mt-6">Created: {new Date(analysis.createdAt).toLocaleString()}</p>
      </div>
    </div>
  );
}
Run the final end-to-end test

Every feature is in place. Before wrapping up, you need to confirm that every part of the app works together: creating, reading, editing, deleting, searching, and sorting.

  • Make sure your backend server is running. If it stopped, open a terminal, navigate to the server folder, and start it by running:
npm run dev
  • Make sure your frontend server is running. If it stopped, open a second terminal, navigate to the client folder, and start it by running:
npm run dev

Now walk through every CRUD operation to make sure nothing is broken.

  • Open http://localhost:5173 in your browser.
  • Paste a paragraph of text from a news article into the analysis form and click **Analyze Content**.
  • Wait for the AI to process. A new card should appear in the grid below with a sentiment badge, reading difficulty, and word count.
  • Create 2 more analyses with different types of content. Try a technical document and a casual blog post.
  • Type a keyword from one of your analysis titles into the search bar. Only matching cards should appear.
  • Change the sort dropdown to **Oldest First**, then to **By Sentiment**. Verify the card order changes.
  • Click on any analysis card to open the detail page.
  • Click **Edit**, change the title to something new, and click **Save**. The title should update immediately.
  • Click **Show Original Text** and verify you see the raw text you pasted.
  • Click **Delete**. A browser confirmation dialog should appear. Click **OK**.
  • You should be redirected back to the dashboard, and the deleted analysis should no longer appear in the grid.

What about different screen sizes?

The dashboard grid uses Tailwind CSS responsive classes: 1 column on mobile, 2 on tablet, 3 on desktop. Try resizing your browser window to see the cards reflow. You can also open DevTools and toggle the device toolbar to simulate a phone screen.

Edit or delete not working?

  • If clicking **Edit** does nothing, check that setEditing(true) is wired to the Edit button's onClick handler.
  • If clicking **Save** after editing the title does not persist the change, check the Network tab for a failed PATCH request. The backend requires the title to be at least 1 character.
  • If delete redirects but the card still appears, do a hard refresh on the dashboard. The loadAnalyses callback re-fetches on mount, so a page reload should clear it.

Help me debug why editing or deleting an analysis is not working.

Your full-stack AI content analyzer is complete. You have a working React frontend, an Express API, a PostgreSQL database, and Gemini AI integration all talking to each other. Next up is an optional Secret Mission where you can extend the app with a tagging system.

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely on your local machine with the free Gemini API tier, so there are no ongoing costs.

Resources you used:

  • PostgreSQL database (briefai) running on localhost port 5432.
  • Express.js backend server in the server/ directory.
  • React frontend app in the client/ directory.
  • Google AI Studio API key (free tier, no billing).
  • Prisma migrations and generated client in server/prisma/.
  • npm dependencies in both server/node_modules/ and client/node_modules/.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with BriefAI.

  • Your PostgreSQL database continues running in the background and uses minimal system resources.
  • Restart your dev servers any time by running npm run dev in both the server/ and client/ directories.
  • Your Gemini API key remains active on the free tier with no charges.

Pause - I'll come back to this later

Shut down running processes to free up memory and ports. Your project files and database stay intact for when you return.

  • Stop the backend and frontend dev servers by pressing Ctrl+C in each terminal window.
  • PostgreSQL can stay running in the background. It uses very little memory when idle.
  • Your project files, database records, and API key are all preserved. When you come back, just run npm run dev in each directory to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your analyses and project code.

  • Stop the backend and frontend dev servers by pressing Ctrl+C in each terminal window.
  • Drop the database by opening a terminal and running psql -U postgres, then entering DROP DATABASE briefai; followed by \q to exit.
  • Delete the entire project folder by navigating to your Desktop and removing the briefai folder.
  • Optionally revoke your Gemini API key by visiting Google AI Studio at https://aistudio.google.com/ and deleting the key from your project settings.

Mission accomplished

That's a Wrap!

Nice work! You just built a complete full-stack AI application from scratch.

You've learned how to:

  • Built a full-stack application with an Express.js backend, PostgreSQL database with Prisma ORM, and a complete CRUD REST API with Zod validation and centralized error handling.
  • Integrated Google Gemini AI with structured JSON output to generate reliable, typed content analyses that save directly to the database.
  • Created a React + TypeScript frontend with a responsive dashboard featuring search, sort, detail view, and inline editing, all styled with Tailwind CSS.
  • Secret Mission: Added a tagging system with many-to-many database relations and tag-based filtering on the dashboard.

Ready to quiz yourself?