Build an AI Legal Chat API
Stream Indian law AI responses from Gemini via Server-Sent Events.
Introduction
30 Second Summary
When someone has a legal question, they often spend hours searching through dense legal documents or paying expensive consultation fees for a simple answer. AI can change that by making legal information instantly accessible.
In this project, you will build a backend API that takes legal questions, sends them to Google Gemini AI with Indian law context, and streams answers back to a chat interface in real-time.
What You'll Build
A full-stack AI chat application with an Express.js TypeScript backend that streams Google Gemini AI responses to a Next.js frontend using Server-Sent Events.
By the end of this project, you'll have:
- An Express.js TypeScript backend with a POST /api/chat endpoint that accepts legal questions and returns AI-generated answers via SSE streaming.
- A legal system prompt that constrains Gemini to answer only about Indian law with proper section and article citations.
- A Next.js chat interface that consumes the SSE stream and displays AI responses token-by-token in real-time.
- Secret Mission: Add conversation memory so the chatbot remembers context across multiple questions in a session.
Are there any prerequisites?
This project assumes you have familiarity with TypeScript and Next.js on the frontend side. You also need Node.js 22 installed and a Gemini API key from Google AI Studio.
Set Up the Express TypeScript Backend
Every feature of NyayaSahayak depends on a backend server that can receive questions and talk to an AI model. Before you can stream legal answers or integrate Gemini, you need a working Express.js server written in TypeScript.
In this step, you will create the project folder, install all dependencies, configure TypeScript, and boot up a minimal server. Once the server responds on http://localhost:3001, you will have a solid foundation to build on.
In this step, get ready to:
- Initialize a Node.js project with TypeScript support.
- Create the project file structure including the server entry point.
- Verify the server starts and responds at http://localhost:3001.
Initialize the project and install dependencies
You need a dedicated folder for the backend and a package.json file to manage your project's dependencies and scripts.
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir nyayasahayak-api
cd nyayasahayak-api
- Initialize the project by running:
npm init -y
This creates a default package.json in your folder.
- Open the project in VS Code. In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the nyayasahayak-api folder on your Desktop, then click Open.
Now you need to configure package.json for ES modules and add a dev script. The default file created by npm init -y has fields you do not need.
- Open package.json in the VS Code editor.
- Replace the entire contents of the file with this:
{
"name": "nyayasahayak-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx --watch src/server.ts"
}
}
What does this code do?
Setting "type": "module" tells Node.js to use ES module syntax (import/export) instead of the older CommonJS (require).
The dev script uses tsx to run your TypeScript code directly without a separate compile step. The --watch flag restarts the server automatically whenever you save a file.
- Save package.json.
Next, install the production packages your server needs.
- Install production dependencies by running this in the VS Code terminal (Ctrl+` to open it):
npm install express cors @google/genai dotenv
What are these packages?
- express is the web framework that handles HTTP requests and routing.
- cors allows your frontend (on a different port) to talk to your backend.
- @google/genai is the official Google SDK for calling the Gemini API.
- dotenv loads environment variables from a .env file so you can keep secrets out of your code.
- Install dev dependencies by running:
npm install -D typescript @types/express @types/cors @types/node tsx
Why dev dependencies?
These packages are only needed during development. typescript provides the TypeScript compiler, the @types/* packages add type definitions for your production packages, and tsx runs TypeScript files directly using esbuild.
- Confirm installation succeeded by checking that a node_modules folder appeared in your project. Run:
ls
You should see node_modules, package.json, and package-lock.json in the output.
Installation failed?
Make sure you are inside the nyayasahayak-api folder. Run pwd in your terminal to check your current location.
If you see a permissions error, try closing and reopening your terminal, then run the install command again.
help me fix my npm install error
Create the project file structure
TypeScript needs a configuration file to know how to process your code. You will also create the server entry point and environment files.
- In the VS Code Explorer sidebar, click the new file icon at the top of the file tree.
- Name the file tsconfig.json and press Enter.
- Paste the following configuration into tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
What does this configuration do?
- "target": "ES2022" compiles to modern JavaScript that Node.js 22 fully supports.
- "module": "NodeNext" and "moduleResolution": "NodeNext" enable Node.js-compatible ES module imports (with .js extensions in import paths).
- "strict": true activates all TypeScript strict checks, catching bugs before runtime.
- "rootDir" and "outDir" separate source files from compiled output.
- Save tsconfig.json.
Now create the server entry point file.
- Click the new file icon again in the Explorer sidebar.
- Type src/server.ts as the file name and press Enter. VS Code automatically creates the src/ folder for you.
- Paste the following code into src/server.ts:
import express from "express";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const PORT = 3001;
app.get("/", (req, res) => {
res.send("NyayaSahayak API is running");
});
app.listen(PORT, () => {
console.log(`NyayaSahayak backend running on http://localhost:${PORT}`);
});
What does this code do?
- dotenv.config() loads environment variables from your .env file into process.env.
- express() creates the app instance that handles all incoming HTTP requests.
- The app.get("/") route responds to GET requests at the root URL with a simple health-check message.
- app.listen(PORT) starts the server on port 3001 and logs a confirmation to your terminal.
- Save src/server.ts.
Finally, create two small configuration files to protect your secrets and keep your repository clean.
- Create a new file called .env in the project root (not inside src/).
- Paste the following into .env:
GEMINI_API_KEY=your-api-key-here
- Replace your-api-key-here with the Gemini API key you obtained from Google AI Studio.
- Save .env.
- Create another new file called .gitignore in the project root.
- Paste the following into .gitignore:
node_modules
.env
dist
- Save .gitignore.
Why a .gitignore?
This file tells Git to ignore node_modules (massive dependency folder), .env (contains your secret API key), and dist (compiled output). None of these belong in version control.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete content of each file at this point.
{
"name": "nyayasahayak-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx --watch src/server.ts"
},
"dependencies": {
"@google/genai": "^2.10.0",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.19.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/node": "^22.10.0",
"tsx": "^4.22.4",
"typescript": "^5.7.3"
}
}
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"]
}
import express from "express";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const PORT = 3001;
app.get("/", (req, res) => {
res.send("NyayaSahayak API is running");
});
app.listen(PORT, () => {
console.log(`NyayaSahayak backend running on http://localhost:${PORT}`);
});
GEMINI_API_KEY=your-api-key-here
node_modules
.env
dist
Version numbers may differ
Your package.json version numbers (e.g. ^2.10.0) depend on what was latest when you ran npm install. Slightly different patch or minor versions are fine.
Verify the server starts
With all files in place, you can boot the server and confirm everything is wired correctly.
- Start the development server by running:
npm run dev
You should see this message in your terminal:
NyayaSahayak backend running on http://localhost:3001
- Open your browser and visit http://localhost:3001.
You should see the text NyayaSahayak API is running displayed in the browser.
Don't see the message?
Check your terminal for red error text. A common issue is a typo in src/server.ts preventing the file from running.
If you see a "Cannot find module" error, make sure package.json has "type": "module" set. Also confirm you ran both npm install commands earlier.
If port 3001 is already in use, close any other servers running on that port and try again.
help me troubleshoot my Express server not starting
Your backend is alive and responding. Next up, you will connect this server to Google Gemini and build the AI chat service that answers legal questions.
Build the Gemini AI Chat Service
Your server is running and responding to HTTP requests. Now it needs intelligence.
In this step, you'll build the AI service layer that connects your backend to Google Gemini. This includes a system prompt that constrains the model to Indian law topics and a streaming function that sends questions to Gemini and returns tokens as they arrive.
In this step, get ready to:
- Define a system prompt that constrains the AI to Indian law.
- Create a Gemini service module for streaming AI responses.
- Verify streaming works by testing in the terminal.
Define the legal system prompt
A system prompt is a set of instructions you give to an LLM before the user's question. It defines the AI's persona, boundaries, and output rules.
For NyayaSahayak, the system prompt needs to lock the AI down to Indian law topics and enforce citation of specific legal sections.
- Create a new file called src/prompts.ts in your project by running:
touch src/prompts.ts
- Open src/prompts.ts in VS Code and paste the following code:
export const LEGAL_SYSTEM_PROMPT = `You are NyayaSahayak, an AI legal assistant specializing exclusively in Indian law. Your role is to help citizens and legal practitioners understand Indian legal concepts.
Rules:
1. Only answer questions related to Indian law (Constitution of India, Bharatiya Nyaya Sanhita (BNS), Bharatiya Nagarik Suraksha Sanhita (BNSS), Bharatiya Sakshya Adhiniyam (BSA), civil law, labour law, family law, property law, and other Indian statutes).
2. When citing law, always mention the specific Section, Article, or Rule number.
3. If a question is not related to Indian law, politely decline and redirect to legal topics.
4. Always include a disclaimer: "This is informational only and does not constitute legal advice. Please consult a qualified advocate for your specific situation."
5. If you are unsure about a legal provision, say so clearly rather than guessing.
6. Use simple language that a non-lawyer can understand, while still being legally accurate.`;
What does this prompt do?
The prompt defines six rules that constrain the AI's behaviour. Rule 1 limits answers to Indian law statutes. Rule 2 forces citations with specific Section or Article numbers.
Rule 4 is especially important. It requires a legal disclaimer on every response, protecting both your users and your platform from liability.
- Save src/prompts.ts.
Why a separate file for the prompt?
System prompts tend to grow and change as you refine the AI's behaviour. Keeping the prompt in its own file means you can update it without touching service logic. It also makes it easy to swap prompts for different legal domains later.
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/prompts.ts before moving on.
ⓧ I'd like to double check the full code
export const LEGAL_SYSTEM_PROMPT = `You are NyayaSahayak, an AI legal assistant specializing exclusively in Indian law. Your role is to help citizens and legal practitioners understand Indian legal concepts.
Rules:
1. Only answer questions related to Indian law (Constitution of India, Bharatiya Nyaya Sanhita (BNS), Bharatiya Nagarik Suraksha Sanhita (BNSS), Bharatiya Sakshya Adhiniyam (BSA), civil law, labour law, family law, property law, and other Indian statutes).
2. When citing law, always mention the specific Section, Article, or Rule number.
3. If a question is not related to Indian law, politely decline and redirect to legal topics.
4. Always include a disclaimer: "This is informational only and does not constitute legal advice. Please consult a qualified advocate for your specific situation."
5. If you are unsure about a legal provision, say so clearly rather than guessing.
6. Use simple language that a non-lawyer can understand, while still being legally accurate.`;
Create the Gemini streaming service
The @google/genai SDK provides a generateContentStream method that returns tokens one at a time instead of waiting for the full response. This is what makes real-time streaming possible.
- Create a new file called src/gemini.ts by running:
touch src/gemini.ts
- Open src/gemini.ts in VS Code and paste the following code:
import { GoogleGenAI } from "@google/genai";
import { LEGAL_SYSTEM_PROMPT } from "./prompts.js";
// Initialize the Gemini client with your API key
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
// Send a legal question to Gemini and return a streaming response
export async function streamLegalChat(question: string) {
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: question }],
},
],
config: {
systemInstruction: LEGAL_SYSTEM_PROMPT,
},
});
return response;
}
What does this code do?
- GoogleGenAI is the SDK client that handles authentication and API communication with Google's servers.
- process.env.GEMINI_API_KEY! reads your API key from the .env file. The ! tells TypeScript you guarantee this value exists.
- generateContentStream returns an async iterable. Each iteration yields a chunk containing a .text property with a small piece of the response.
- The config.systemInstruction field passes your legal system prompt to Gemini so it behaves as an Indian law assistant.
Why does the import use .js instead of .ts?
When your tsconfig.json uses NodeNext module resolution, TypeScript requires explicit file extensions in import paths. You write ./prompts.js because that is what the file will be called after compilation. TypeScript resolves this to ./prompts.ts during development.
- Save src/gemini.ts.
Seeing a red underline on the import?
If VS Code shows an error on the ./prompts.js import, make sure you saved src/prompts.ts in the previous substep. The import resolves to that file.
Help me debug this import error
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/gemini.ts before moving on.
ⓧ I'd like to double check the full code
import { GoogleGenAI } from "@google/genai";
import { LEGAL_SYSTEM_PROMPT } from "./prompts.js";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
export async function streamLegalChat(question: string) {
const response = await ai.models.generateContentStream({
model: "gemini-2.5-flash",
contents: [
{
role: "user",
parts: [{ text: question }],
},
],
config: {
systemInstruction: LEGAL_SYSTEM_PROMPT,
},
});
return response;
}
Test the service in isolation
Before wiring this into an API endpoint, you should confirm that Gemini actually streams tokens back. You'll add a temporary test call to src/server.ts that logs chunks to your terminal.
- In src/server.ts, find this line at the top of the file:
import express from "express";
- Add this import directly below it:
import { streamLegalChat } from "./gemini.js";
- Now scroll to the very bottom of src/server.ts (below the app.listen block) and add this temporary test function:
// --- TEMPORARY TEST (remove after verifying) ---
async function testStream() {
console.log("\nTesting Gemini stream...\n");
const response = await streamLegalChat("What is Article 21 of the Indian Constitution?");
for await (const chunk of response) {
process.stdout.write(chunk.text || "");
}
console.log("\n\n--- Stream complete ---");
}
testStream();
What does this test do?
The testStream function calls your streamLegalChat service with a sample legal question. The for await loop iterates over each chunk from Gemini and writes it directly to the terminal using process.stdout.write. You should see text appearing piece by piece.
- Save src/server.ts. Since tsx --watch is running, the server restarts automatically and executes the test.
- Switch to your terminal. You should see "Testing Gemini stream..." followed by text appearing token-by-token about Article 21 and the right to life.
Not seeing any streamed text?
- Check your .env file has a valid API key. You can verify your key at https://aistudio.google.com/apikey.
- If you see an authentication error, make sure dotenv.config() runs before the test function is called. It should already be near the top of src/server.ts.
- If your terminal shows a connection timeout, confirm you have internet access and the Gemini API is reachable from your location.
Help me debug my streaming test
Once you see the streamed response complete with "--- Stream complete ---" in your terminal, the service works.
- Now remove the temporary test code from src/server.ts. Delete the import { streamLegalChat } line at the top and the entire testStream function at the bottom.
- Save src/server.ts. Your server should restart cleanly with just the original health check route.
Why remove the test code?
The test function calls Gemini every time the server restarts. That wastes API calls and slows down development. In the next step, you'll create a proper API endpoint that only calls Gemini when a user submits a question.
✔️ Awesome, I've got everything!
Great. Double check that src/server.ts is back to its original state with no test code remaining.
ⓧ I'd like to double check the full code
After removing the test code, src/server.ts should look exactly like this:
import express from "express";
import dotenv from "dotenv";
dotenv.config();
const app = express();
const PORT = 3001;
app.get("/", (req, res) => {
res.send("NyayaSahayak API is running");
});
app.listen(PORT, () => {
console.log(`NyayaSahayak backend running on http://localhost:${PORT}`);
});
Your AI service layer is built and verified. You have a system prompt constraining Gemini to Indian law and a streaming function ready to feed tokens to any consumer. Next up, you'll create the SSE endpoint that pipes these tokens to the browser in real-time.
Create the SSE Streaming Endpoint
Your Gemini service can generate streaming responses, but there's no way for a browser to reach it yet. The missing piece is an API endpoint that accepts a question over HTTP and pushes AI tokens back in real-time.
In this step, you will create a POST endpoint that uses Server-Sent Events to stream Gemini's response directly to the client. You will also wire the route into your Express server and confirm everything works with a curl command.
In this step, get ready to:
- Create a POST /api/chat route that streams AI responses via SSE.
- Wire the route into the Express server with CORS and JSON middleware.
- Test the endpoint with curl to confirm streaming works.
Create the chat route file
Server-Sent Events (SSE) let the server push data to the client over a single HTTP connection. Instead of the client polling repeatedly, the server writes chunks as they arrive from Gemini.
- Create a src/routes/ directory inside your project by running this command:
mkdir src/routes
- Create a new file called chat.ts inside src/routes/ by running:
touch src/routes/chat.ts
- Open src/routes/chat.ts in VS Code and paste the following code:
import { Router, Request, Response } from "express";
import { streamLegalChat } from "../gemini.js";
export const chatRouter = Router();
chatRouter.post("/api/chat", async (req: Request, res: Response) => {
const { question } = req.body;
// Validate that the request contains a question string
if (!question || typeof question !== "string") {
res.status(400).json({ error: "A question string is required" });
return;
}
// Set SSE headers so the browser keeps the connection open
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
try {
const stream = await streamLegalChat(question);
// Forward each AI token to the client as an SSE event
for await (const chunk of stream) {
const text = chunk.text;
if (text) {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
// Signal that the stream is complete
res.write("data: [DONE]\n\n");
res.end();
} catch (error) {
console.error("Gemini streaming error:", error);
res.write(`data: ${JSON.stringify({ error: "AI service error" })}\n\n`);
res.end();
}
});
What does this code do?
The route validates the incoming request body, then switches the HTTP response into SSE mode by setting four headers. Content-Type: text/event-stream tells the browser to treat the response as a continuous event stream.
Inside the try block, a for await loop reads tokens from Gemini one at a time. Each token is written to the response in the SSE format: data: {"text":"..."} . When the AI finishes, the server sends data: [DONE] so the client knows to stop listening.
If Gemini throws an error mid-stream, the catch block sends an error event and closes the connection gracefully.
What is Server-Sent Events (SSE)?
SSE is a standard that lets a server push updates to the browser over a single long-lived HTTP connection. Unlike WebSockets, SSE is one-way (server to client) and works over plain HTTP.
Every LLM API that streams tokens (OpenAI, Anthropic, Google) uses the same data: ...\n\n format you just implemented. Learning this pattern once means you can wire up any AI streaming API.
- Save src/routes/chat.ts.
Seeing red underlines in the file?
If TypeScript shows an error on the ../gemini.js import, make sure your src/gemini.ts file exists and exports the streamLegalChat function. The .js extension is required for NodeNext module resolution even though the source file is .ts.
help me fix TypeScript import errors in my chat route file
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/routes/chat.ts before moving on.
ⓧ I'd like to double check the full code
import { Router, Request, Response } from "express";
import { streamLegalChat } from "../gemini.js";
export const chatRouter = Router();
chatRouter.post("/api/chat", async (req: Request, res: Response) => {
const { question } = req.body;
if (!question || typeof question !== "string") {
res.status(400).json({ error: "A question string is required" });
return;
}
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
res.flushHeaders();
try {
const stream = await streamLegalChat(question);
for await (const chunk of stream) {
const text = chunk.text;
if (text) {
res.write(`data: ${JSON.stringify({ text })}\n\n`);
}
}
res.write("data: [DONE]\n\n");
res.end();
} catch (error) {
console.error("Gemini streaming error:", error);
res.write(`data: ${JSON.stringify({ error: "AI service error" })}\n\n`);
res.end();
}
});
Wire the route into the server
The route exists, but Express does not know about it yet. You need to import the router, add middleware for JSON parsing and CORS, then mount it.
- Open src/server.ts and replace its entire contents with the following code:
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { chatRouter } from "./routes/chat.js";
dotenv.config();
const app = express();
const PORT = 3001;
// Enable cross-origin requests from the Next.js frontend
app.use(cors());
// Parse incoming JSON request bodies
app.use(express.json());
app.get("/", (req, res) => {
res.send("NyayaSahayak API is running");
});
// Mount the chat route
app.use(chatRouter);
app.listen(PORT, () => {
console.log(`NyayaSahayak backend running on http://localhost:${PORT}`);
});
What changed in server.ts?
You added three things. cors() allows your Next.js frontend (running on port 3000) to call the backend on port 3001 without being blocked by the browser's same-origin policy.
express.json() parses the JSON body from incoming POST requests so req.body.question is available in your route handler. Finally, app.use(chatRouter) mounts the /api/chat endpoint.
- Save src/server.ts.
- Check your terminal. Since tsx --watch is running, the server auto-restarts. You should see the message NyayaSahayak backend running on http://localhost:3001 printed again.
Server crashed on restart?
If you see an error about a missing module, confirm the import path ends with .js (./routes/chat.js). NodeNext resolution requires the .js extension even for TypeScript source files.
help me fix my server.ts import error after adding the chat route
✔️ Awesome, I've got everything!
Great. Make sure src/server.ts is saved and the server restarted cleanly in your terminal.
ⓧ I'd like to double check the full code
import express from "express";
import cors from "cors";
import dotenv from "dotenv";
import { chatRouter } from "./routes/chat.js";
dotenv.config();
const app = express();
const PORT = 3001;
app.use(cors());
app.use(express.json());
app.get("/", (req, res) => {
res.send("NyayaSahayak API is running");
});
app.use(chatRouter);
app.listen(PORT, () => {
console.log(`NyayaSahayak backend running on http://localhost:${PORT}`);
});
Test the streaming endpoint
Time to confirm the full pipeline works end-to-end. You will send a legal question to the API and watch SSE events arrive in real-time.
- Open a new terminal tab (keep the server running in the first one).
- Test the streaming endpoint by running this command:
curl -X POST http://localhost:3001/api/chat -H "Content-Type: application/json" -d "{\"question\":\"What is Article 21 of the Indian Constitution?\"}" --no-buffer
What does --no-buffer do?
The --no-buffer flag tells curl to output data immediately as it arrives instead of buffering it. Without it, you would not see tokens appear one at a time.
You should see lines appearing one after another in this format:
data: {"text":"Article 21 of the..."}
data: {"text":" Constitution of India..."}
...
data: [DONE]
Each data: line is one SSE event carrying a token from Gemini. The final data: [DONE] confirms the stream completed successfully.
Seeing an empty response or connection error?
- Make sure your first terminal still shows the server running on port 3001.
- Check your .env file has a valid GEMINI_API_KEY value (not the placeholder text).
- If you see A question string is required, double-check that the JSON in your curl command has the question key spelled correctly.
help me debug my /api/chat endpoint not returning streaming data
Your backend API is fully operational. Next up, you will build a Next.js frontend page that consumes this stream and displays the AI response in real-time as the user watches.
Build the Next.js Chat Frontend
Your backend streams legal answers token-by-token over SSE. Now you need a frontend that can consume that stream and render it live as the AI "thinks."
In this step, you'll create a Next.js page that sends questions to your Express API and displays the response in real-time. The result is a complete full-stack AI chat experience.
In this step, get ready to:
- Create a chat page component with state management and Tailwind styling.
- Implement the SSE stream consumer that parses tokens in real-time.
- Test the full end-to-end flow from question to streamed answer.
Create the chat page component
Next.js uses file-based routing. Creating a file at app/legal-chat/page.tsx automatically makes a page accessible at /legal-chat in the browser.
- In your Next.js project, create an app/legal-chat/ directory.
- Create a file called page.tsx inside that directory.
- Paste the following component code into app/legal-chat/page.tsx:
"use client";
import { useState, useRef } from "react";
export default function LegalChat() {
const [question, setQuestion] = useState("");
const [response, setResponse] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!question.trim() || isStreaming) return;
setResponse("");
setIsStreaming(true);
abortControllerRef.current = new AbortController();
try {
const res = await fetch("http://localhost:3001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
signal: abortControllerRef.current.signal,
});
if (!res.ok) throw new Error("API request failed");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
setResponse((prev) => prev + parsed.text);
}
} catch {
// Skip malformed JSON lines
}
}
}
}
} catch (error) {
if ((error as Error).name !== "AbortError") {
setResponse("Error: Failed to get response from the AI service.");
}
} finally {
setIsStreaming(false);
}
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center p-8">
<div className="w-full max-w-2xl">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
NyayaSahayak
</h1>
<p className="text-gray-600 mb-8">
AI Legal Assistant for Indian Law
</p>
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Ask a legal question about Indian law..."
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isStreaming}
/>
<button
type="submit"
disabled={isStreaming || !question.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStreaming ? "Thinking..." : "Ask"}
</button>
</div>
</form>
{(response || isStreaming) && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="prose max-w-none">
<p className="whitespace-pre-wrap text-gray-800">
{response}
{isStreaming && (
<span className="inline-block w-2 h-5 bg-blue-500 animate-pulse ml-1" />
)}
</p>
</div>
</div>
)}
</div>
</div>
);
}
What does this component do?
This is a "use client" component because it uses browser-only features (state, event handlers, fetch). Here's what each section handles:
- State variables: question holds the user's input, response accumulates the streamed AI answer, isStreaming tracks whether a request is in progress.
- abortControllerRef: lets you cancel an in-flight request if needed (prevents memory leaks).
- handleSubmit: sends the question to your backend and reads the SSE stream (explained in detail below).
- JSX return: renders a heading, input field, submit button, and a response display area with a pulsing cursor animation during streaming.
- Save app/legal-chat/page.tsx.
Seeing TypeScript errors in your editor?
Make sure your Next.js project has the react and @types/react packages installed. These provide the type definitions for useState, useRef, and React.FormEvent.
help me fix TypeScript errors in my Next.js chat component
Understand the SSE stream consumer
The handleSubmit function is where the real-time streaming magic happens. It uses the browser's native ReadableStream API to process tokens as they arrive from your backend.
How does the SSE parsing work?
Your backend sends data in Server-Sent Events format. Each event is a line starting with data: followed by a JSON payload. Here's the flow inside handleSubmit:
- fetch POSTs the question to http://localhost:3001/api/chat and gets a streaming response back.
- response.body!.getReader() opens a reader on the raw byte stream.
- TextDecoder converts raw bytes into text strings, then the code splits on newlines to isolate individual SSE lines.
- Each line starting with data: gets parsed as JSON. The parsed.text value is appended to the response state, triggering a re-render that shows the new token.
- When the backend sends [DONE], the loop breaks and streaming stops.
Why ReadableStream instead of EventSource?
The browser's built-in EventSource API only supports GET requests. Your chat endpoint uses POST (to send the question in the body), so you need the lower-level fetch + ReadableStream approach to consume the SSE data.
Test the full end-to-end flow
Time to see everything working together. You need both your Express backend and the Next.js frontend running simultaneously.
- Make sure your backend is still running on port 3001. If you stopped it, restart it by running:
- In the terminal where your backend project runs:
npm run dev
- In a separate terminal, start your Next.js frontend (typically on port 3000) by running:
npm run dev
- Open http://localhost:3000/legal-chat in your browser.
- Type a legal question in the input field. Try: What is Article 21 of the Indian Constitution?.
- Click the **Ask** button.
You should see the AI response appear token-by-token with a pulsing blue cursor. The button text changes to "Thinking..." while streaming.
- Try a non-legal question like What is the weather today? to verify the system prompt correctly redirects to Indian law topics.
Not seeing any response?
Check your browser's developer console (F12 or Cmd+Option+I) for errors. Common causes:
- Your Express backend isn't running. Confirm you see NyayaSahayak backend running on http://localhost:3001 in its terminal.
- CORS is blocking the request. Make sure cors() middleware is mounted in src/server.ts before the route.
- The fetch URL doesn't match. Your component must POST to http://localhost:3001/api/chat (port 3001, not 3000).
help me debug why my Next.js frontend can't connect to my Express backend
✔️ Awesome, I've got everything!
Great. Double check you've saved app/legal-chat/page.tsx and that both servers are running.
ⓧ I'd like to double check the full code
"use client";
import { useState, useRef } from "react";
export default function LegalChat() {
const [question, setQuestion] = useState("");
const [response, setResponse] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!question.trim() || isStreaming) return;
setResponse("");
setIsStreaming(true);
abortControllerRef.current = new AbortController();
try {
const res = await fetch("http://localhost:3001/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
signal: abortControllerRef.current.signal,
});
if (!res.ok) throw new Error("API request failed");
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
if (data === "[DONE]") break;
try {
const parsed = JSON.parse(data);
if (parsed.text) {
setResponse((prev) => prev + parsed.text);
}
} catch {
// Skip malformed JSON lines
}
}
}
}
} catch (error) {
if ((error as Error).name !== "AbortError") {
setResponse("Error: Failed to get response from the AI service.");
}
} finally {
setIsStreaming(false);
}
}
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center p-8">
<div className="w-full max-w-2xl">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
NyayaSahayak
</h1>
<p className="text-gray-600 mb-8">
AI Legal Assistant for Indian Law
</p>
<form onSubmit={handleSubmit} className="mb-6">
<div className="flex gap-2">
<input
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="Ask a legal question about Indian law..."
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isStreaming}
/>
<button
type="submit"
disabled={isStreaming || !question.trim()}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isStreaming ? "Thinking..." : "Ask"}
</button>
</div>
</form>
{(response || isStreaming) && (
<div className="bg-white border border-gray-200 rounded-lg p-6">
<div className="prose max-w-none">
<p className="whitespace-pre-wrap text-gray-800">
{response}
{isStreaming && (
<span className="inline-block w-2 h-5 bg-blue-500 animate-pulse ml-1" />
)}
</p>
</div>
</div>
)}
</div>
</div>
);
}
Your full-stack AI chat is live. You've connected a Next.js frontend to your Express backend, and legal answers stream in real-time. Next up, you'll take on the Secret Mission and add conversation memory so the chatbot remembers context across multiple questions.
Secret mission
Add Conversation Memory
Right now, every question you ask NyayaSahayak is completely independent. The AI has no memory of what you discussed 10 seconds ago. Can you add session-based conversation memory so the chatbot remembers context across multiple questions?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Your Gemini API usage stays within the free tier (1,000 requests/day).
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A local Node.js server running on port 3001 (no cost).
- The nyayasahayak-api backend project folder on your Desktop.
- The app/legal-chat/page.tsx file added to your Next.js project.
- A Gemini API key created at Google AI Studio.
Keep everything running
No action needed. Choose this if you are still actively building on the NyayaSahayak backend or plan to add more features soon.
- Your Express server keeps running on port 3001.
- Your Gemini API key stays active for future projects at no cost.
- Your project files remain on your Desktop, ready to edit.
Pause - I'll come back to this later
Stop the running server to free up your terminal. You can restart it anytime.
- Switch to the terminal where your backend is running.
- Press Ctrl+C to stop the server.
- When you are ready to resume, navigate back to your nyayasahayak-api folder and run:
npm run dev
Your API key and project files stay intact. Nothing is lost.
Delete - I don't want to use this again
Remove all project files and revoke your API key to start completely fresh.
- Stop the backend server by pressing Ctrl+C in the terminal.
- Delete your backend project folder by running:
macOS
cd ~/Desktop
rm -rf nyayasahayak-api
Windows
cd ~\Desktop
rmdir /s /q nyayasahayak-api
- Delete the app/legal-chat/page.tsx file from your Next.js project.
- To revoke your Gemini API key, visit https://aistudio.google.com/apikey and delete the key you created for this project.
Nice Work!
Nice Work!
You just built a full-stack AI legal chat application from scratch. NyayaSahayak now takes legal questions, streams them through Google Gemini with Indian law context, and delivers real-time answers to the browser.
You've learned how to:
- Build an Express.js TypeScript backend that integrates the Google Gemini API for domain-specific AI responses constrained to Indian law.
- Implement Server-Sent Events (SSE) to stream AI-generated tokens in real-time from your backend to the browser.
- Connect a Next.js frontend to your API using the Fetch API and ReadableStream for a complete full-stack AI chat experience.
- Secret Mission: Added conversation memory with session management so the chatbot remembers context across multi-turn legal consultations.
Ready to quiz yourself?