Local Multi-Model Text Processor
Build a local AI bridge that routes text and URLs through free AI models.
Introduction
30 Second Summary
You find a perfect webpage or a long document you want to feed into an AI assistant. You paste the URL and get blocked. You try uploading the whole file and hit a token limit. The content exists, but there is no clean way to bridge it into the AI tools you use every day.
In this project, you will build a local AI bridge tool that fetches any URL server-side, accepts file uploads, splits large content into safe chunks, and processes everything through free AI models. All results are saved locally so you never lose them.
What You'll Build
You run a single command, your app boots alongside a local database, and you paste a URL that no cloud AI can visit. Seconds later, a formatted summary appears in your output panel, ready to copy into any other tool.
By the end of this project, you'll have:
- A local web app that fetches any URL server-side, bypassing restrictions that cloud AIs face, and processes the content through your choice of free AI model.
- A multi-model selector routing to DeepSeek R1, NVIDIA Nemotron, and other free models through OpenRouter with a single dropdown.
- A smart chunking pipeline that automatically splits large content at paragraph boundaries, processes each piece individually, and reassembles the results into copy-ready output stored in your local MongoDB database.
- Secret Mission: Add recursive summarization that condenses summaries of summaries for truly massive content.
Are there any prerequisites?
You should have basic familiarity with Next.js, TypeScript, and React components. No prior experience with MongoDB or AI APIs is needed.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your workflow.
Set Up MongoDB and Scaffold the Project
Your AI Bridge tool needs two foundations: a database to store processing history, and a web framework to build the interface.
In this step, you will install MongoDB locally from a tarball (no cloud dependency), scaffold a Next.js app with Shadcn UI components, and create an auto-start script so one command boots everything.
In this step, get ready to:
- Install MongoDB 8.0 from a tgz tarball and verify it runs on port 27017.
- Scaffold a Next.js app with Shadcn UI components.
- Create an auto-start dev script and environment file.
Download and install MongoDB 8.0
MongoDB stores your processing history locally. You will download the server binary directly from a tarball, copy it into your system PATH, and create the directories it needs.
- Open your browser and go to the MongoDB Community Server download page:
https://www.mongodb.com/try/download/community
- In the Version dropdown, select 8.0 (any 8.0.x release is fine).
- In the Platform dropdown, select macOS x64.
- In the Package dropdown, select tgz.
- Click Download.
Why tgz instead of Homebrew?
The tgz tarball gives you a standalone binary with no dependency on Homebrew or any package manager. You control exactly where the binary lives and which version runs.
- Press Cmd+Space (macOS) to open Spotlight search.
- Type Terminal and press Enter to open it.
- Navigate to your Downloads folder by running this command:
cd ~/Downloads
- Extract the downloaded tarball by running this command:
tar -zxvf mongodb-macos-x86_64-8.0*.tgz
What does this command do?
The tar -zxvf command extracts (unzips) the compressed archive. The wildcard * matches whatever patch version you downloaded (e.g. 8.0.28, 8.0.29).
- Copy the MongoDB binaries into your system PATH by running this command:
sudo cp mongodb-macos-x86_64-8.0*/bin/* /usr/local/bin/
Why does this need sudo?
The /usr/local/bin/ directory is a system-level location. You need administrator privileges to write into it. Enter your Mac password when prompted.
- Create the data and log directories that MongoDB needs by running these commands:
sudo mkdir -p ~/data/db
sudo mkdir -p ~/data/log/mongodb
- Verify MongoDB is installed correctly by running:
mongod --version
✔️ I see a version number starting with 8.0
MongoDB is installed and ready. You can move on to the next substep.
ⓧ Command not found
- Make sure you ran the sudo cp command above without any typos.
- Confirm the binaries exist by running:
ls /usr/local/bin/mongod
If you see the file listed, close and reopen your terminal, then try mongod --version again.
- Test that MongoDB starts successfully by running:
mongod --dbpath ~/data/db --logpath ~/data/log/mongodb/mongo.log --fork
You should see a message containing child process started successfully and port 27017.
Seeing a permission error?
- If you see a permissions error on ~/data/db, fix ownership by running: sudo chown -R $(whoami) ~/data.
- Then retry the mongod command above.
help me fix a MongoDB startup error on macOS
- Stop the test instance so it does not interfere with the auto-start script later:
mongod --shutdown --dbpath ~/data/db
Scaffold the Next.js app with Shadcn UI
Now you need a web framework to build the interface. The Shadcn CLI can scaffold a complete Next.js project with TypeScript, Tailwind, and pre-configured UI components in a single command.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold a new Next.js project with Shadcn UI by running this command:
npx shadcn@latest init -t next -n ai-bridge
What does this command do?
This uses the Shadcn CLI to scaffold a full Next.js project named ai-bridge with TypeScript, Tailwind CSS, and the Shadcn UI system already configured.
The -t next flag tells it to use the Next.js template. The -n ai-bridge flag sets the project folder name.
What prompts will I see?
The CLI may ask you to choose a base component library and a preset. Accept the defaults (Radix base, default preset). If it asks about peer dependencies with React 19, select Use --force.
- Move into your new project folder by running:
cd ai-bridge
- Install the Shadcn UI components needed for this project by running:
npx shadcn@latest add button input select card textarea scroll-area
You should see each component added to the src/components/ui/ directory.
- Confirm the components were installed by running:
ls src/components/ui/
You should see files like button.tsx, card.tsx, input.tsx, select.tsx, textarea.tsx, and scroll-area.tsx listed.
Components not appearing?
- Make sure you are inside the ai-bridge folder when running the add command.
- Run pwd to check your current directory. It should end with /ai-bridge.
help me troubleshoot shadcn component installation
Create the auto-start dev script and environment file
Right now, starting your project means remembering to launch MongoDB first, then Next.js. The auto-start script handles both with a single command.
- Press Cmd+Space (macOS) to open Spotlight search.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the ai-bridge folder on your Desktop, then click Open.
- Install the openai and mongoose packages by running this in the VS Code terminal (Ctrl+` to open it):
npm install openai mongoose
You should see both packages added to your package.json dependencies.
- Create a scripts folder and the dev script file by running:
mkdir scripts
- In the VS Code Explorer sidebar, right-click the scripts folder and select New File.
- Name it dev.mjs.
- Paste the following code into scripts/dev.mjs:
import { execSync, spawn } from "child_process";
const DB_PATH = `${process.env.HOME}/data/db`;
const LOG_PATH = `${process.env.HOME}/data/log/mongodb/mongo.log`;
function isMongoRunning() {
try {
execSync("pgrep mongod", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
What does this code do?
- DB_PATH and LOG_PATH point to the directories you created earlier.
- isMongoRunning() uses pgrep mongod to check if a MongoDB process is already active. If it is, no need to start another one.
- Add the startup logic below the isMongoRunning function in the same file:
if (isMongoRunning()) {
console.log("[dev] MongoDB already running.");
} else {
console.log("[dev] Starting MongoDB...");
try {
execSync(
`mongod --dbpath ${DB_PATH} --logpath ${LOG_PATH} --fork`,
{ stdio: "inherit" }
);
console.log("[dev] MongoDB started on port 27017.");
} catch (error) {
console.error("[dev] Failed to start MongoDB. Is mongod in your PATH?");
process.exit(1);
}
}
What does this code do?
- If MongoDB is already running, it skips the startup and prints a message.
- Otherwise, it launches mongod with --fork so it runs in the background. If it fails, the script exits with an error.
- Add the Next.js launch code at the bottom of the same file:
console.log("[dev] Starting Next.js...");
const next = spawn("npx", ["next", "dev"], {
stdio: "inherit",
env: { ...process.env },
});
next.on("close", (code) => {
process.exit(code ?? 0);
});
What does this code do?
After MongoDB is confirmed running, the script spawns the Next.js dev server. The stdio: "inherit" option means Next.js output appears directly in your terminal. When Next.js exits, the script exits too.
- Save scripts/dev.mjs.
✔️ Awesome, I've got everything!
Great. Make sure you have saved scripts/dev.mjs before continuing.
ⓧ I'd like to double check the full code
Here is the complete scripts/dev.mjs file. Compare yours against it:
import { execSync, spawn } from "child_process";
const DB_PATH = `${process.env.HOME}/data/db`;
const LOG_PATH = `${process.env.HOME}/data/log/mongodb/mongo.log`;
function isMongoRunning() {
try {
execSync("pgrep mongod", { stdio: "ignore" });
return true;
} catch {
return false;
}
}
if (isMongoRunning()) {
console.log("[dev] MongoDB already running.");
} else {
console.log("[dev] Starting MongoDB...");
try {
execSync(
`mongod --dbpath ${DB_PATH} --logpath ${LOG_PATH} --fork`,
{ stdio: "inherit" }
);
console.log("[dev] MongoDB started on port 27017.");
} catch (error) {
console.error("[dev] Failed to start MongoDB. Is mongod in your PATH?");
process.exit(1);
}
}
console.log("[dev] Starting Next.js...");
const next = spawn("npx", ["next", "dev"], {
stdio: "inherit",
env: { ...process.env },
});
next.on("close", (code) => {
process.exit(code ?? 0);
});
- Now create the environment file. In the VS Code Explorer sidebar, right-click the project root and select New File.
- Name it .env.local.
- Paste the following into .env.local:
OPENROUTER_API_KEY=your-api-key-here
MONGODB_URI=mongodb://127.0.0.1:27017/ai-bridge
What does this file do?
- OPENROUTER_API_KEY is where your OpenRouter API key will go (you will get this in the next step).
- MONGODB_URI tells Mongoose where to connect. Port 27017 is MongoDB's default port.
- Save .env.local.
- Open package.json in the VS Code Explorer.
- Find the "scripts" section. It will look something like this:
"scripts": {
"dev": "next dev",
- Replace the "dev" line with the following so it runs your auto-start script instead:
"scripts": {
"dev": "node scripts/dev.mjs",
- Save package.json.
Before you run the dev command, what do you expect to see in the terminal? Think about what the auto-start script does first.
- Test the full setup by running:
npm run dev
You should see [dev] Starting MongoDB... followed by [dev] MongoDB started on port 27017. and then [dev] Starting Next.js... as the Next.js dev server boots.
- Open your browser and go to http://localhost:3000.
You should see the default Next.js welcome page. Both MongoDB and Next.js are now running from a single command.
Not seeing the Next.js page?
- If you see a MongoDB error, check that mongod is in your PATH by running which mongod in a new terminal tab.
- If Next.js fails to start, make sure package.json has "dev": "node scripts/dev.mjs" (not the old "next dev").
- If port 3000 is already in use, press Ctrl+C and retry. Next.js will suggest port 3001 if 3000 is taken.
help me debug my dev script not starting MongoDB or Next.js
Your environment is fully wired up. MongoDB auto-starts alongside Next.js, and your UI components are ready to use. Next up, you will connect to a free AI model and build the chat interface.
Build the Chat Interface with Model Switching
Your database and dev server are running. Now you need to prove the AI connection actually works.
In this step, you'll wire up OpenRouter as your AI gateway and build a simple chat interface. By the end, you'll type a message, pick a free model from a dropdown, and see the response appear in your app.
In this step, get ready to:
- Create the OpenRouter client utility and chat API route.
- Build the page interface with a model selector dropdown.
- Get your free OpenRouter API key and test the full flow.
Create the OpenRouter client and API route
OpenRouter gives you access to hundreds of AI models through a single API endpoint. You'll configure the openai package to point at OpenRouter instead of OpenAI directly.
- Create a new file at src/lib/openrouter.ts and add the following code:
import OpenAI from "openai";
// Point the OpenAI SDK at OpenRouter's endpoint
export const openrouter = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: process.env.OPENROUTER_API_KEY,
});
// Free models available through OpenRouter
export const FREE_MODELS = [
{ id: "nvidia/nemotron-3-super-120b-a12b:free", name: "NVIDIA Nemotron 3 Super" },
{ id: "nvidia/nemotron-3-nano-30b-a3b:free", name: "NVIDIA Nemotron 3 Nano" },
{ id: "deepseek/deepseek-r1:free", name: "DeepSeek R1" },
{ id: "openrouter/free", name: "Free Router (auto-pick)" },
];
What does this code do?
The openai package speaks the same protocol that OpenRouter uses. By changing the baseURL to https://openrouter.ai/api/v1, every request goes through OpenRouter instead of OpenAI.
The FREE_MODELS array lists models that cost nothing to use. The :free suffix on each model ID tells OpenRouter to route to the free variant.
- Now create the API route at src/app/api/chat/route.ts by adding this code:
import { NextRequest, NextResponse } from "next/server";
import { openrouter } from "@/lib/openrouter";
export async function POST(req: NextRequest) {
// Extract the message content and model choice from the request
const { content, model, source, sourceType } = await req.json();
if (!content || !model) {
return NextResponse.json({ error: "Missing content or model" }, { status: 400 });
}
try {
// Send the content to the chosen model via OpenRouter
const completion = await openrouter.chat.completions.create({
model,
messages: [
{
role: "system",
content: "You are a helpful assistant. Process and summarize the following content clearly.",
},
{ role: "user", content },
],
});
// Return the model's response
const output = completion.choices[0]?.message?.content || "";
return NextResponse.json({ output });
} catch (error: any) {
console.error("Chat error:", error);
return NextResponse.json(
{ error: error.message || "Processing failed" },
{ status: 500 }
);
}
}
What does this route do?
This is a Next.js API route that acts as a bridge between your frontend and OpenRouter. It receives the user's text and chosen model, sends them to OpenRouter, and returns the AI's response.
The model variable is passed directly to openrouter.chat.completions.create. This means the frontend can switch between any model in the dropdown and the route handles it without any code change.
- Save both files.
- Check your terminal where npm run dev is running. You should see no error messages.
Seeing a module error?
If the terminal shows an error about @/lib/openrouter not being found, make sure the file path is exactly src/lib/openrouter.ts (inside the src folder, not the project root).
help me fix a module import error in my Next.js API route
Set up the page layout and interface
The Next.js scaffold created a default layout and page. You'll replace them with a dark-themed layout and a chat interface that connects to your API route.
- Open src/app/layout.tsx and replace its entire contents with this code:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
// Load the Inter font for clean readability
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AI Bridge",
description: "Local multi-model text processor",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="dark">
<body className={inter.className}>{children}</body>
</html>
);
}
What changed here?
The key addition is className="dark" on the <html> element. This activates the dark color scheme from your Shadcn UI configuration in globals.css. The metadata sets a custom browser tab title.
- Save layout.tsx and refresh your browser at localhost:3000.
- The page background should now be dark instead of white.
Still seeing a white background?
Open src/app/globals.css and verify it contains a .dark section with CSS variables like --background: 0 0% 3.9%. This was generated by Shadcn UI during initialization. If it's missing, re-run npx shadcn@latest init and select the default theme.
help me fix my Shadcn dark theme not applying
Now you'll build the main page. This is where the learner types a message, picks a model, and sees the AI response. You'll add the code in sections below. Create each section directly below the previous one in the same file.
- Open src/app/page.tsx and delete all its existing content. Add the imports, model list, and component state:
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area";
// Models available in the dropdown
const FREE_MODELS = [
{ id: "nvidia/nemotron-3-super-120b-a12b:free", name: "Nemotron 3 Super" },
{ id: "nvidia/nemotron-3-nano-30b-a3b:free", name: "Nemotron 3 Nano" },
{ id: "deepseek/deepseek-r1:free", name: "DeepSeek R1" },
{ id: "openrouter/free", name: "Free Router (auto)" },
];
export default function Home() {
const [content, setContent] = useState("");
const [model, setModel] = useState(FREE_MODELS[0].id);
const [output, setOutput] = useState("");
const [loading, setLoading] = useState(false);
const [source, setSource] = useState("");
const [sourceType, setSourceType] = useState<"text" | "url" | "file">("text");
const [error, setError] = useState("");
What does this section set up?
The "use client" directive tells Next.js this component runs in the browser (needed for useState and event handlers). The FREE_MODELS array drives the dropdown menu. Each state variable tracks a piece of UI: the typed content, chosen model, AI output, loading state, and any errors.
- Directly below the state declarations, add the event handler functions:
// Send content to the API route for processing
async function handleProcess() {
if (!content.trim()) return;
setLoading(true);
setOutput("");
setError("");
try {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content, model, source, sourceType }),
});
const data = await res.json();
if (data.error) {
setError(data.error);
} else {
setOutput(data.output);
}
} catch {
setError("Processing failed");
}
setLoading(false);
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text);
}
What do these handlers do?
The handleProcess function sends a POST request to your /api/chat route with the content and selected model. It then sets either the output or an error based on the response.
The copyToClipboard helper copies any text to the system clipboard so you can paste AI results elsewhere.
- Below the handlers, add the first part of the return statement with the page container and model selector:
return (
<div className="min-h-screen p-6">
<h1 className="text-2xl font-bold mb-6">AI Bridge</h1>
<div className="space-y-4 max-w-3xl">
{/* Model selector dropdown */}
<Card>
<CardContent className="pt-4">
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREE_MODELS.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
What is this rendering?
This creates the page heading and a card containing the model selector dropdown. The Select component from Shadcn UI maps over your FREE_MODELS array to create one option per model.
- Continue below with the textarea card for input and the error display:
{/* Text input area */}
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Content to Process</CardTitle>
</CardHeader>
<CardContent>
<Textarea
placeholder="Type or paste content here..."
value={content}
onChange={(e) => { setContent(e.target.value); setSourceType("text"); setSource(""); }}
rows={8}
/>
<Button onClick={handleProcess} disabled={loading || !content.trim()} className="mt-3 w-full">
{loading ? "Processing..." : "Process with AI"}
</Button>
</CardContent>
</Card>
{/* Error display */}
{error && (
<Card className="border-destructive">
<CardContent className="pt-4 text-destructive">{error}</CardContent>
</Card>
)}
What does this section add?
A textarea where you type or paste content, plus a full-width Process with AI button that calls handleProcess. The button disables itself while loading. Below it, a red-bordered error card appears only when something goes wrong.
- Finally, add the output panel and closing tags to complete the component:
{/* Output panel with copy button */}
{output && (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle className="text-sm font-medium">Output</CardTitle>
<Button variant="secondary" size="sm" onClick={() => copyToClipboard(output)}>Copy</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[300px]">
<pre className="whitespace-pre-wrap text-sm">{output}</pre>
</ScrollArea>
</CardContent>
</Card>
)}
</div>
</div>
);
}
What does the output panel do?
This card only renders when the AI returns a response. It displays the formatted output inside a scrollable area and includes a Copy button so you can paste the result anywhere.
- Save page.tsx and refresh your browser.
- You should see the dark-themed page with the heading "AI Bridge", a model selector dropdown defaulting to "Nemotron 3 Super", a textarea, and a Process with AI button.
Page not rendering?
Check your terminal for syntax errors. The most common issue is a missing closing brace or parenthesis between sections. Make sure each section you pasted sits directly below the previous one with no duplicate brackets.
help me fix a syntax error in my React page component
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Your page.tsx should contain all five sections you pasted above, in order:
- Imports, FREE_MODELS array, and state declarations.
- The handleProcess and copyToClipboard functions.
- The return statement opening with the model selector card.
- The textarea card and error display.
- The output panel and all closing tags.
Scroll up and confirm each section is present in order. If anything looks off, compare section by section against the code blocks above.
Get your OpenRouter API key and test
The interface is built, but it needs a real API key to talk to OpenRouter. The free tier gives you 50 requests per day with no credit card required.
- Open your browser and go to openrouter.ai.
- Click the sign-up option and create an account. You can use Google, GitHub, or email.
- Once signed in, navigate to openrouter.ai/settings/keys.
- Click Create Key.
- Copy the generated key. It starts with sk-or-.
Why is this free?
OpenRouter offers a free tier with 50 requests per day and 20 requests per minute. No credit card is needed. The free models in your dropdown (like Nemotron and DeepSeek R1) are specifically available at zero cost through this tier.
- Switch back to VS Code and open .env.local in the root of your project.
- Find the line that says:
OPENROUTER_API_KEY=your-api-key-here
- Replace your-api-key-here with the key you just copied (the one starting with sk-or-).
- Save .env.local.
- Stop the dev server with Ctrl+C and restart it so the new key is loaded:
npm run dev
Before you test, think about what you expect to happen. If you type a short message and pick a model, do you think the AI will respond instantly or take a few seconds?
- Open localhost:3000 in your browser.
- Type a short message in the textarea, like Explain what an API is in one sentence.
- Select Nemotron 3 Super from the model dropdown.
- Click Process with AI.
After a few seconds, the AI response appears in the output panel below. You now have a working AI bridge on your local machine.
Seeing a "Processing failed" error?
- Make sure you restarted the dev server after editing .env.local. Next.js only reads env files on startup.
- Check that your API key starts with sk-or- and has no extra spaces around it.
- Open the terminal and look for a more specific error message (like "401 Unauthorized" which means the key is invalid).
help me debug my OpenRouter API connection
Your AI connection is proven. You can type any message, swap between free models with the dropdown, and get responses. Next up, you'll add URL scraping so your server can fetch content that cloud AIs cannot reach.
Add URL Scraping - Your Server as a Bridge
Cloud AI tools like ChatGPT or Claude cannot visit most websites directly. They are blocked by paywalls, login walls, and robots.txt restrictions. Your local Next.js server has no such limitation.
This is where the "bridge" concept comes alive. You fetch content that cloud AIs cannot reach, then feed it to them through your app. In this step, you will build the server-side scraping route and wire it into your UI.
In this step, get ready to:
- Create a scrape API route that fetches URLs server-side and strips HTML to plain text.
- Add a URL input field with a Fetch button to the page.
- Test the full bridge flow from URL to AI summary.
Create the scrape API route
Your Next.js server can fetch any URL using Node's native fetch. You will create a new route handler that accepts a URL, fetches its HTML, and strips it down to clean text.
- Create a new folder and file at src/app/api/scrape/route.ts by running this command:
mkdir -p src/app/api/scrape && touch src/app/api/scrape/route.ts
- Open src/app/api/scrape/route.ts in VS Code and add the following code to set up the route handler with URL fetching:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { url } = await req.json();
// Reject requests with no URL
if (!url) {
return NextResponse.json({ error: "Missing URL" }, { status: 400 });
}
try {
// Fetch the page server-side with a browser-like User-Agent
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
},
});
if (!response.ok) {
return NextResponse.json(
{ error: `Failed to fetch: ${response.status}` },
{ status: 400 }
);
}
What does this code do?
This route accepts a POST request containing a URL. It fetches that URL from your server, not from the browser. The User-Agent header mimics a browser so sites are less likely to block the request.
If the target site returns an error status (like 403 or 404), the route reports that back to the UI instead of crashing.
- Now add the HTML parsing and error handling below the code you just wrote, still inside the try block:
// Strip scripts, styles, and all HTML tags to extract clean text
const html = await response.text();
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/\s+/g, " ")
.trim();
return NextResponse.json({ content: text });
} catch (error: any) {
return NextResponse.json(
{ error: error.message || "Scrape failed" },
{ status: 500 }
);
}
}
What does this code do?
The regex chain removes JavaScript (<script> tags), CSS (<style> tags), then all remaining HTML tags. Finally it collapses extra whitespace into single spaces.
The result is clean, readable text ready for an AI model to process.
- Save src/app/api/scrape/route.ts.
- Confirm the file exists by running:
ls src/app/api/scrape/
You should see route.ts in the output.
Don't see route.ts?
Make sure you created the folder structure correctly. The path must be src/app/api/scrape/route.ts with the scrape folder nested inside api. Check for typos in folder names.
help me find where my scrape route file should go in the Next.js app directory
Add URL input to the page
The scrape route is ready on the server. Now you need a way for the user to paste a URL and trigger the fetch from the browser.
- Switch back to src/app/page.tsx in VS Code.
- Find this import line near the top:
import { Button } from "@/components/ui/button";
- Add the Input import directly below it, so both lines are present:
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
- Next, find this line inside the Home function:
const [content, setContent] = useState("");
- Add the new state variables and the handleScrape function directly below it. Copy this code after the content state line:
const [url, setUrl] = useState("");
const [model, setModel] = useState(FREE_MODELS[0].id);
const [output, setOutput] = useState("");
const [loading, setLoading] = useState(false);
const [fetchingUrl, setFetchingUrl] = useState(false);
const [source, setSource] = useState("");
const [sourceType, setSourceType] = useState<"text" | "url" | "file">("text");
const [error, setError] = useState("");
async function handleScrape() {
if (!url.trim()) return;
setFetchingUrl(true);
setError("");
try {
const res = await fetch("/api/scrape", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
const data = await res.json();
if (data.error) {
setError(data.error);
} else {
// Populate textarea with fetched content
setContent(data.content);
setSource(url);
setSourceType("url");
}
} catch {
setError("Failed to fetch URL");
}
setFetchingUrl(false);
}
What does handleScrape do?
When the user clicks Fetch, this function sends the URL to your /api/scrape route. The route fetches the page server-side and returns clean text. The function then puts that text into the textarea, ready for AI processing.
The fetchingUrl state drives the button's loading indicator so the user knows something is happening.
- Now delete the old state declarations that previously existed (model, output, loading, source, sourceType, error) since they are now included in the block you just pasted. You should have each state variable declared exactly once.
- Now find the first <Card> inside the return statement (the model selector card). Add this URL input card above it:
<Card>
<CardContent className="pt-4">
<div className="flex gap-2">
<Input
placeholder="Paste a URL to fetch its content..."
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
<Button onClick={handleScrape} disabled={fetchingUrl}>
{fetchingUrl ? "Fetching..." : "Fetch"}
</Button>
</div>
</CardContent>
</Card>
What does this UI add?
This card places a text input and a Fetch button side by side at the top of the page. The button shows "Fetching..." while the request is in progress, giving the user clear feedback.
- Finally, find the existing Textarea component and update its placeholder prop from "Type or paste content here..." to:
placeholder="Type, paste, or load content here..."
- Also update the CardTitle for the content card to show a character count. Find:
<CardTitle className="text-sm font-medium">Content to Process</CardTitle>
- Replace it with:
<CardTitle className="text-sm font-medium">Content to Process{content && <span className="ml-2 text-muted-foreground font-normal">({content.length} chars)</span>}</CardTitle>
- Save src/app/page.tsx.
- Return to your browser at localhost:3000. You should see a new URL input field with a **Fetch** button at the top of the page.
Page not showing the new input?
Check that you imported Input from @/components/ui/input at the top of the file. If the page shows an error, look at the terminal for a TypeScript compilation error pointing to the missing import.
help me fix my page.tsx so the URL input appears
✔️ Awesome, I've got everything!
Great. Make sure you saved both src/app/api/scrape/route.ts and src/app/page.tsx.
ⓧ I'd like to double check the full code
Your src/app/api/scrape/route.ts should contain two sections you added: the fetch/validation logic (import, POST function, URL validation, fetch with User-Agent, response.ok check) followed by the HTML parsing (regex chain, return, catch block). Scroll up and confirm both are present in order.
Your src/app/page.tsx should now have: the Input import, the url and fetchingUrl state declarations, the handleScrape function, the URL input Card above the model selector, and the updated placeholder text. Confirm each piece is in place.
Test the full bridge flow
Everything is wired up. Time to prove the bridge works end-to-end.
- In your browser, paste a URL into the new input field. Try a Wikipedia article like https://en.wikipedia.org/wiki/Artificial_intelligence.
- Click **Fetch**.
Before you continue, predict what you expect to happen. Will the textarea stay empty, or will content appear?
The page content from that URL should now fill the textarea. You will see a character count appear next to "Content to Process" showing how much text was pulled in.
- With the fetched content in the textarea, pick a model from the dropdown (e.g. Nemotron 3 Super).
- Click **Process with AI**.
The AI response should appear in the Output panel below. You just fetched content that a cloud AI could not access directly, and processed it through your own local bridge.
Why is this a bridge?
Cloud AI services cannot browse the web freely. They are blocked by login walls, rate limits, and robots.txt rules. Your local server has none of these restrictions.
By fetching content server-side and piping it to the AI, you act as the bridge between restricted web content and AI processing power.
Fetch button stuck on "Fetching..."?
Some sites block requests without a full browser User-Agent string. Try a different URL (Wikipedia works reliably). Also check the terminal running your dev server for error messages.
If you see a network error, make sure your dev server is still running at localhost:3000.
help me debug why my URL fetch is failing
Your server is now a fully working bridge. You can fetch any publicly accessible webpage, extract its text, and send it straight to a free AI model. Next up, you will add file uploads so you can process local documents the same way.
Add File Upload and Hit the Token Limit
Your app already fetches content from URLs, but most of the text you want to bridge into AI tools lives in local files. Text files, markdown notes, CSV exports, JSON data. These are the primary inputs your tool needs to handle.
In this step, you'll add file upload support so you can drop any text file into the app and send it to your chosen model. You'll also discover the core limitation that makes this entire tool necessary: AI models have token limits, and large files blow right past them.
In this step, get ready to:
- Create an API route that accepts uploaded files and extracts their text content.
- Add a file upload zone to the UI that populates the textarea.
- Hit the token limit wall with a large file and see it fail.
Create the file upload API route
The upload route receives a file via FormData, validates its extension, reads the text content, and returns it to the frontend. This keeps file handling on the server where it belongs.
- Create a new file at src/app/api/upload/route.ts and paste in the following code:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
// Only allow text-based file types
const allowedTypes = [".txt", ".md", ".csv", ".json"];
const ext = "." + file.name.split(".").pop()?.toLowerCase();
if (!allowedTypes.includes(ext)) {
return NextResponse.json(
{ error: `Unsupported file type: ${ext}. Use ${allowedTypes.join(", ")}` },
{ status: 400 }
);
}
// Read the file as text and return it
const content = await file.text();
return NextResponse.json({ content, filename: file.name });
}
What does this code do?
The route extracts the uploaded file from the incoming FormData request. It checks the file extension against a whitelist of text-based formats.
If the extension is valid, it reads the entire file as a string with file.text() and returns both the content and the original filename. The frontend uses the filename to track what was uploaded.
- Save src/app/api/upload/route.ts.
- In the VS Code Explorer sidebar, confirm you can see the new upload folder inside src/app/api/ with route.ts inside it.
Don't see the upload folder?
Make sure you created the file at the correct path: src/app/api/upload/route.ts. The upload folder sits alongside the existing chat and scrape folders.
Help me figure out why my upload route file isn't showing up.
Add the file upload zone to the UI
The upload zone is a styled file input that sends the selected file to your new route. When the server returns the text content, it populates the textarea the same way URL scraping does.
- In src/app/page.tsx, find the handleScrape function's closing brace. Add the following handleFileUpload function directly below it:
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setError("");
const formData = new FormData();
formData.append("file", file);
try {
const res = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const data = await res.json();
if (data.error) {
setError(data.error);
} else {
setContent(data.content);
setSource(data.filename);
setSourceType("file");
}
} catch {
setError("Failed to upload file");
}
}
What does this function do?
When the learner selects a file, this function wraps it in a FormData object and sends it to /api/upload. On success, it sets the textarea content to the file's text and tracks the filename as the source.
Now you need a file input in the UI that triggers this function. You'll place it alongside the model selector in the second Card.
- In the JSX, find the second <Card> that currently contains only the Select dropdown. It looks like this:
<Card>
<CardContent className="pt-4">
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREE_MODELS.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
- Replace that entire Card with this version, which wraps the file input and model selector together in a flex row:
<Card>
<CardContent className="pt-4">
<div className="flex gap-2 items-center">
<Input
type="file"
accept=".txt,.md,.csv,.json"
onChange={handleFileUpload}
className="flex-1"
/>
<Select value={model} onValueChange={setModel}>
<SelectTrigger className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREE_MODELS.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
What changed here?
You wrapped the existing Select in a flex container and added a file Input beside it. The accept attribute restricts the file picker to the four supported text formats.
- Save src/app/page.tsx and switch to your browser at localhost:3000.
- Create a small test file on your Desktop. Open a text editor, type a few sentences (keep it under 100 words), and save it as test.txt.
- In the app, click the file input (labeled **Choose File** or **Browse**) and select your test.txt.
The textarea should now show your file's text content, and the character count should update next to "Content to Process".
- Click Process with AI to send your small file to the model.
You should see the AI's summary appear in the Output panel below. File upload works for small content.
File content not appearing in the textarea?
Check that your file ends in .txt, .md, .csv, or .json. Other file types are rejected by the upload route. Also confirm that handleFileUpload is connected to the Input's onChange prop.
Help me debug why my file upload isn't populating the textarea.
Add the token limit guard and hit the wall
AI models have a maximum context window measured in tokens. If you send content that exceeds it, the API returns a cryptic error or silently truncates your input. A guard that checks content length before sending gives you a clear, actionable error instead.
- In src/app/page.tsx, find the handleProcess function. Locate this line at the top:
if (!content.trim()) return;
- Add this line directly below it, so the function checks content size before doing anything else:
if (content.length > 4000) { setError("Content too large for selected model."); return; }
Now add the same guard on the server side so the API also rejects oversized content.
- Open src/app/api/chat/route.ts. Find the line with the existing imports:
import { openrouter } from "@/lib/openrouter";
- Add a blank line below that import, then add the constant:
const MAX_CHARS = 4000;
- Next, find the existing validation check inside the POST function:
if (!content || !model) {
return NextResponse.json({ error: "Missing content or model" }, { status: 400 });
}
- Add this block directly below it, before the try block:
if (content.length > MAX_CHARS) {
return NextResponse.json({ error: "Content too large for selected model." }, { status: 400 });
}
Why 4000 characters?
This is a conservative approximation. One token is roughly 4 characters of English text, so 4000 characters is about 1000 tokens. Free models typically have context windows of 4096-8192 tokens, but you need room for the system prompt and the model's response.
A strict check like this is intentionally limiting. In the next step, you'll replace this hard wall with smart chunking that splits large content into processable pieces.
- Save both src/app/page.tsx and src/app/api/chat/route.ts.
Now it's time to hit the wall. You need a file large enough to exceed 4000 characters.
- Open a text editor and paste in a long block of text. Copy a few paragraphs from any Wikipedia article, a news article, or any text source. Make sure it's well over 4000 characters (a few paragraphs of a Wikipedia article will do).
- Save it as large-test.txt on your Desktop.
- Return to your app in the browser and upload large-test.txt using the file input.
The textarea populates and the character count should show a number well over 4000. Before you click the button, what do you think will happen when you try to process this content?
- Click Process with AI.
You'll see a red error card appear: "Content too large for selected model." The content never leaves your machine. Your tool works perfectly for small content, but large content is completely blocked.
This is the core problem
This is exactly the limitation that makes your AI Bridge tool necessary. Cloud AI tools hit the same wall when you paste large content. The difference is that your tool can solve this problem by splitting the content into smaller pieces and processing them one at a time. That's what you'll build in the next step.
Not seeing the error?
Check the character count displayed next to "Content to Process" in the UI. If it shows a number under 4000, your test file is too small. Copy more text into it until you're well over 4000 characters.
If the character count is over 4000 but no error appears, confirm the if (content.length > 4000) check is inside handleProcess before the setLoading(true) line.
Help me figure out why the token limit check isn't working.
✔️ Awesome, I've got everything!
Great. Double check you've saved both src/app/page.tsx and src/app/api/chat/route.ts.
ⓧ I'd like to double check the full code
Here is the full src/app/api/upload/route.ts file:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const allowedTypes = [".txt", ".md", ".csv", ".json"];
const ext = "." + file.name.split(".").pop()?.toLowerCase();
if (!allowedTypes.includes(ext)) {
return NextResponse.json(
{ error: `Unsupported file type: ${ext}. Use ${allowedTypes.join(", ")}` },
{ status: 400 }
);
}
const content = await file.text();
return NextResponse.json({ content, filename: file.name });
}
Here is the full src/app/api/chat/route.ts file:
import { NextRequest, NextResponse } from "next/server";
import { openrouter } from "@/lib/openrouter";
const MAX_CHARS = 4000;
export async function POST(req: NextRequest) {
const { content, model, source, sourceType } = await req.json();
if (!content || !model) {
return NextResponse.json({ error: "Missing content or model" }, { status: 400 });
}
if (content.length > MAX_CHARS) {
return NextResponse.json({ error: "Content too large for selected model." }, { status: 400 });
}
try {
const completion = await openrouter.chat.completions.create({
model,
messages: [
{
role: "system",
content: "You are a helpful assistant. Process and summarize the following content clearly.",
},
{ role: "user", content },
],
});
const output = completion.choices[0]?.message?.content || "";
return NextResponse.json({ output });
} catch (error: any) {
console.error("Chat error:", error);
return NextResponse.json(
{ error: error.message || "Processing failed" },
{ status: 500 }
);
}
}
For src/app/page.tsx, confirm these three changes are in place:
- The handleFileUpload function exists between handleScrape and handleProcess.
- The second Card contains both the file Input and the model Select wrapped in a flex div.
- Inside handleProcess, the line if (content.length > 4000) appears right after if (!content.trim()) return;.
Your AI Bridge now handles three input methods: typed text, URLs, and file uploads. But that hard 4000-character wall means the tool is useless for the large content that needs it most. Next up, you'll smash through that wall with smart chunking that splits content at paragraph boundaries and processes it piece by piece.
Smart Chunking and MongoDB History
The token limit error from the previous step is the core problem your bridge tool exists to solve. Large content needs to reach the AI, but models reject anything that exceeds their context window.
Smart chunking splits large text at paragraph boundaries, processes each piece through the AI individually, then reassembles the results into one clean output. In this step, you'll build that chunking pipeline and wire it into MongoDB so every processed result is saved locally and survives page refreshes.
In this step, get ready to:
- Build a text chunking utility that splits content at paragraph boundaries.
- Update the chat API to process chunks sequentially and save results to MongoDB.
- Add a history sidebar that loads previous jobs on page load.
Build the chunking utility
The chunker needs to split text at natural paragraph boundaries (double newlines) and group paragraphs into chunks that stay well under the model's token limit. This leaves room for the AI's response in its context window.
- Create a new file at src/lib/chunker.ts and paste the following code:
const MAX_CHARS = 3500;
export function chunkText(text: string): string[] {
const paragraphs = text.split(/\n\s*\n/);
const chunks: string[] = [];
let current = "";
for (const paragraph of paragraphs) {
if (current.length + paragraph.length + 2 > MAX_CHARS) {
if (current.trim()) {
chunks.push(current.trim());
}
current = paragraph;
} else {
current += (current ? "\n\n" : "") + paragraph;
}
}
if (current.trim()) {
chunks.push(current.trim());
}
return chunks.length > 0 ? chunks : [text];
}
export function needsChunking(text: string): boolean {
return text.length > MAX_CHARS;
}
What does this code do?
MAX_CHARS = 3500 sets the threshold at roughly 875 tokens of English text. This conservative limit leaves room for the system prompt and the model's response.
chunkText splits on double newlines (paragraph boundaries), then groups paragraphs together until adding another would exceed the limit. If a single paragraph is larger than the limit, it becomes its own chunk.
needsChunking is a quick check so the API route can decide whether to chunk at all.
- Save src/lib/chunker.ts.
✔️ Awesome, I've got everything!
Great. Save the file and move on to the next chunk.
ⓧ I'd like to double check the full code
const MAX_CHARS = 3500;
export function chunkText(text: string): string[] {
const paragraphs = text.split(/\n\s*\n/);
const chunks: string[] = [];
let current = "";
for (const paragraph of paragraphs) {
if (current.length + paragraph.length + 2 > MAX_CHARS) {
if (current.trim()) {
chunks.push(current.trim());
}
current = paragraph;
} else {
current += (current ? "\n\n" : "") + paragraph;
}
}
if (current.trim()) {
chunks.push(current.trim());
}
return chunks.length > 0 ? chunks : [text];
}
export function needsChunking(text: string): boolean {
return text.length > MAX_CHARS;
}
Connect to MongoDB and update the chat route
The chat route needs two supporting pieces before you can update it: a reusable MongoDB connection helper and a Mongoose schema for storing processing history.
- Create a new file at src/lib/mongodb.ts and paste the following:
import mongoose from "mongoose";
const MONGODB_URI = process.env.MONGODB_URI || "mongodb://127.0.0.1:27017/ai-bridge";
let cached = (global as any).mongooseCache;
if (!cached) {
cached = (global as any).mongooseCache = { conn: null, promise: null };
}
export async function connectDB() {
if (cached.conn) return cached.conn;
if (!cached.promise) {
cached.promise = mongoose.connect(MONGODB_URI);
}
cached.conn = await cached.promise;
return cached.conn;
}
Why cache the connection?
Next.js API routes run as serverless functions. Without caching, every request would open a new database connection, quickly exhausting your machine's connection pool.
Storing the connection promise on global ensures it persists across hot reloads during development.
- Save src/lib/mongodb.ts.
- Create a new file at src/models/History.ts and paste the following:
import mongoose, { Schema, Document } from "mongoose";
export interface IHistory extends Document {
source: string;
sourceType: "text" | "url" | "file";
model: string;
output: string;
chunks: number;
createdAt: Date;
}
const HistorySchema = new Schema<IHistory>({
source: { type: String, required: true },
sourceType: { type: String, enum: ["text", "url", "file"], required: true },
model: { type: String, required: true },
output: { type: String, required: true },
chunks: { type: Number, default: 1 },
createdAt: { type: Date, default: Date.now },
});
export const History =
mongoose.models.History || mongoose.model<IHistory>("History", HistorySchema);
What does this schema store?
Each history entry records the source (URL, filename, or first 100 chars of text), which model was used, the full output, and how many chunks it was split into.
The mongoose.models.History || pattern prevents Mongoose from redefining the model on hot reload.
- Save src/models/History.ts.
- Confirm both new files exist by running this in your terminal:
ls src/lib/ src/models/
You should see chunker.ts, mongodb.ts, and openrouter.ts in src/lib/, plus History.ts in src/models/.
Now you can rewrite the chat route to use chunking and save history. The current route rejects large content with an error. The new version will split it, process each chunk, and store results.
- Open src/app/api/chat/route.ts and replace the entire contents with the following code. Start by pasting this at the top of the file:
import { NextRequest, NextResponse } from "next/server";
import { openrouter } from "@/lib/openrouter";
import { chunkText, needsChunking } from "@/lib/chunker";
import { connectDB } from "@/lib/mongodb";
import { History } from "@/models/History";
export async function POST(req: NextRequest) {
const { content, model, source, sourceType } = await req.json();
if (!content || !model) {
return NextResponse.json({ error: "Missing content or model" }, { status: 400 });
}
try {
await connectDB();
let fullOutput = "";
if (needsChunking(content)) {
const chunks = chunkText(content);
const results: string[] = [];
- Directly below, paste the chunk-processing loop:
for (let i = 0; i < chunks.length; i++) {
const completion = await openrouter.chat.completions.create({
model,
messages: [
{
role: "system",
content: `You are a summarization assistant. Summarize the following content clearly and concisely. This is chunk ${i + 1} of ${chunks.length}.`,
},
{ role: "user", content: chunks[i] },
],
});
const result = completion.choices[0]?.message?.content || "";
results.push(result);
}
fullOutput = results.join("\n\n---\n\n");
await History.create({
source: source || content.slice(0, 100),
sourceType: sourceType || "text",
model,
output: fullOutput,
chunks: chunks.length,
});
- Complete the file by pasting the else branch and error handling below:
} else {
const completion = await openrouter.chat.completions.create({
model,
messages: [
{
role: "system",
content: "You are a helpful assistant. Process and summarize the following content clearly.",
},
{ role: "user", content },
],
});
fullOutput = completion.choices[0]?.message?.content || "";
await History.create({
source: source || content.slice(0, 100),
sourceType: sourceType || "text",
model,
output: fullOutput,
chunks: 1,
});
}
return NextResponse.json({ output: fullOutput });
} catch (error: any) {
console.error("Chat error:", error);
return NextResponse.json(
{ error: error.message || "Processing failed" },
{ status: 500 }
);
}
}
What changed in the chat route?
- The old 4000-character rejection is gone. Instead, needsChunking() checks whether chunking is needed.
- If the content is large, chunkText() splits it into paragraph-safe pieces. Each chunk gets its own API call with a system prompt noting its position (e.g. "chunk 2 of 5").
- Results are joined with --- separators for readability.
- Every completed job (chunked or not) is saved to the History collection in MongoDB.
- Save src/app/api/chat/route.ts.
Before you test, think back to the large file that failed in the previous step. What do you expect will happen now when you upload it?
- Switch to your browser at localhost:3000.
- Upload the same large .txt file that triggered the "Content too large" error previously.
- Select a model and click Process with AI.
This time the content processes successfully. The output panel shows the combined summaries separated by --- dividers, one per chunk.
Seeing an error instead of output?
- Check your terminal for a Mongoose connection error. Make sure MongoDB is still running (your npm run dev script auto-starts it, but if you restarted manually you may need to run npm run dev again).
- If you see a "Cannot find module" error, double check that src/lib/chunker.ts, src/lib/mongodb.ts, and src/models/History.ts are saved in the correct paths.
Help me debug my chunking route
✔️ Awesome, I've got everything!
Great. Save the file and confirm the large file test worked.
ⓧ I'd like to double check the full code
Scroll up and confirm your src/app/api/chat/route.ts contains these three sections in order:
- Imports (5 import lines) + POST function start through const results: string[] = [];.
- The for loop processing each chunk and the History.create call for chunked content.
- The else branch for small content, its own History.create, the return statement, and the catch block.
Add the history sidebar
The chunking pipeline works and saves results to MongoDB. Now you need a way to see and reuse those results. A history sidebar loads previous jobs on page mount and shows a copy button for each.
- Create a new file at src/app/api/history/route.ts and paste this code:
import { NextResponse } from "next/server";
import { connectDB } from "@/lib/mongodb";
import { History } from "@/models/History";
export async function GET() {
try {
await connectDB();
const entries = await History.find().sort({ createdAt: -1 }).limit(20).lean();
return NextResponse.json({ entries });
} catch (error: any) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
}
What does this route do?
It returns the 20 most recent history entries sorted by newest first. The .lean() call returns plain JavaScript objects instead of full Mongoose documents, which is faster for read-only responses.
- Save src/app/api/history/route.ts.
Now update src/app/page.tsx to fetch and display history. You'll make several targeted edits to the existing file.
- In src/app/page.tsx, find the import line at the top:
import { useState } from "react";
- Replace it with this to add useEffect:
import { useState, useEffect } from "react";
- Find the line export default function Home() { and add the following interface directly above it:
interface HistoryEntry {
_id: string;
source: string;
sourceType: string;
model: string;
output: string;
chunks: number;
createdAt: string;
}
- Inside the component, find the line const [error, setError] = useState(""); and add this new state and effect directly below it:
const [history, setHistory] = useState<HistoryEntry[]>([]);
useEffect(() => { loadHistory(); }, []);
async function loadHistory() {
try {
const res = await fetch("/api/history");
const data = await res.json();
if (data.entries) setHistory(data.entries);
} catch {}
}
What does this add?
The history state holds an array of previous processing jobs. The useEffect calls loadHistory() once on mount, which fetches from your new /api/history endpoint.
- Find the handleProcess function. Locate and delete these two lines that block large content on the client:
if (content.length > 4000) { setError("Content too large for selected model."); return; }
- In the same function, find the success branch where output is set. Update it to also reload history after processing:
if (data.error) { setError(data.error); } else { setOutput(data.output); loadHistory(); }
- In the return statement, find this wrapper div:
<div className="space-y-4 max-w-3xl">
- Replace it with a grid layout that makes room for the sidebar:
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
- Directly inside the new grid, wrap all your existing cards (URL input, file upload, textarea, error, output) in a column div. Add this opening tag right after the grid div:
<div className="lg:col-span-3 space-y-4">
- Close that column div after the output card's closing tag (after the last )} for the output card conditional), then add the history sidebar. Paste this code as the second column, before the grid's closing </div>:
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">History</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px]">
<div className="space-y-3">
{history.length === 0 && (
<p className="text-sm text-muted-foreground">
No history yet. Process some content to get started.
</p>
)}
- Directly below, paste the history entry mapping and closing tags:
{history.map((entry) => (
<div
key={entry._id}
className="p-3 rounded border text-sm space-y-1"
>
<p className="font-medium truncate">
{entry.source}
</p>
<p className="text-muted-foreground text-xs">
{entry.sourceType} | {entry.chunks} chunk(s)
</p>
<p className="text-muted-foreground text-xs">
{new Date(entry.createdAt).toLocaleDateString()}
</p>
<Button
variant="secondary"
size="sm"
className="w-full mt-1"
onClick={() => copyToClipboard(entry.output)}
>
Copy Output
</Button>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
What does the sidebar show?
Each history entry displays the source (URL or filename), the source type and chunk count, the date it was processed, and a Copy Output button. Clicking the button copies the full AI output to your clipboard in a paste-ready format.
- Save src/app/page.tsx.
Page not loading or showing errors?
- Check your terminal for a syntax error. The most common issue is a mismatched </div> tag. Make sure your grid has exactly two children: the lg:col-span-3 div and the lg:col-span-1 div.
- If you see "loadHistory is not defined", make sure the loadHistory function is declared inside the component, after the state declarations.
Help me fix my page.tsx layout
Time to see everything working together. What do you expect to see in the sidebar after your earlier test processing?
- Refresh your browser at localhost:3000.
The page now shows a grid layout with a History sidebar on the right. The large file you processed earlier already appears as a history entry with its source name, chunk count, and date.
- Click Copy Output on any history entry, then paste somewhere (a text editor, a notes app) to confirm the full AI output is on your clipboard.
- Process another piece of content (type something short and click Process with AI). The new entry appears at the top of the sidebar without a page refresh.
- Hard-refresh the browser (Cmd+Shift+R on macOS). Your history is still there, loaded fresh from MongoDB.
OpenRouter rate limits
The free tier allows 50 requests per day. A 10-chunk document uses 10 of those requests. Keep this in mind during testing.
Your bridge tool now handles content of any size and remembers every result. Next up, you'll take on the Secret Mission: recursive summarization that condenses summaries of summaries for truly massive content.
Secret mission
Recursive Summarization for Massive Content
What happens when even your chunk summaries are too large for a single AI context window? In this extension, you build a recursive pipeline that summarizes summaries until everything fits. One toggle, multiple passes, truly massive content handled.
Clean Up Your Resources
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 locally with a free OpenRouter account, so there are no ongoing costs.
Resources you used:
- MongoDB data directory at ~/data/db (local disk, persists between sessions).
- MongoDB log directory at ~/data/log/mongodb (local disk).
- Your AI Bridge project folder on the Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep processing content.
- Your MongoDB data stays on disk at ~/data/db. It takes no memory when mongod is not running.
- Run npm run dev anytime to start the app. MongoDB auto-launches alongside Next.js.
- Your OpenRouter account remains on the free tier with no charges. The 50 requests per day limit resets daily.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and data stay on disk for next time.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal where it is running.
- If MongoDB is still running in the background (started with --fork), stop it by running the following command:
kill $(pgrep mongod)
- Your project folder, MongoDB data, and logs all remain on disk. Next time you want to use the app, just run npm run dev from your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your processing history and all local files.
- Stop the Next.js dev server by pressing Ctrl+C in the terminal.
- Stop MongoDB if it is running:
kill $(pgrep mongod)
- Delete the MongoDB data and log directories:
rm -rf ~/data/db
rm -rf ~/data/log/mongodb
- Delete your AI Bridge project folder from the Desktop:
cd ~/Desktop
rm -rf ai-bridge
- Your OpenRouter account is free and incurs no charges. You can leave it as-is or delete it from your account settings at openrouter.ai if you prefer.
Nice Work!
Nice Work!
You just built a fully local AI bridge tool that turns any URL or text file into clean, copy-ready summaries. Your app runs entirely on your machine, costs nothing, and gives you access to multiple free AI models through a single interface.
You've learned how to:
- Build a local AI bridge webapp that fetches content from any URL server-side and routes it through free AI models via OpenRouter.
- Install and configure MongoDB 8.0 locally from a tgz tarball with an auto-start script that boots the database alongside Next.js.
- Implement smart text chunking that splits large content at paragraph boundaries, processes each chunk through a chosen AI model, and reassembles results into copy-ready output stored in a persistent history.
- Secret Mission: Added recursive summarization that condenses massive content through multiple AI passes until the output fits a single context window.
Ready to quiz yourself?