Build a Full-Stack AI Project Mentor
Build an AI app that turns project ideas into structured roadmaps.
Introduction
30 Second Summary
Starting a new project is exciting until you realize you have no idea where to begin. Breaking a big idea into concrete, ordered milestones is one of the most valuable skills in software development.
In this project, you will build an AI-powered Project Mentor that takes any project idea and generates a structured execution roadmap with milestones, required skills, and complexity estimates.
What You'll Build
A full-stack web application with a Bun/Hono backend and React/shadcn/ui frontend that streams AI-generated project plans from the NVIDIA NIM API.
By the end of this project, you'll have:
- A Hono API server running on Bun with a streaming /api/analyze endpoint that connects to the NVIDIA NIM API.
- A React frontend with shadcn/ui components that renders an interactive roadmap of milestone cards with skill badges and complexity indicators.
- A system prompt that instructs an LLM to reliably produce structured JSON plans from vague project ideas.
- Secret Mission: A conversational "deep dive" feature where users can click a milestone and ask follow-up questions about it in a threaded chat.
Are there any prerequisites?
You should have basic JavaScript/TypeScript familiarity. You also need Bun installed and an NVIDIA NIM API key (free for prototyping via the NVIDIA Developer Program at build.nvidia.com).
Set Up the Project Structure
Every full-stack AI app needs two pieces: a backend that talks to the AI model, and a frontend that the user interacts with.
In this step, you will scaffold a Hono API server for the backend and a React app (powered by Vite) for the frontend. You will also install Tailwind CSS and shadcn/ui so the UI looks polished from the start.
In this step, get ready to:
- Scaffold the backend and frontend apps in a single project folder.
- Configure Tailwind CSS and shadcn/ui in the React frontend.
- Verify both apps run on their own ports.
Create the monorepo structure with a Hono backend and Vite React frontend
Your project will live in a single root folder with two sub-folders: server/ for the Hono backend and client/ for the React frontend.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir project-mentor
cd project-mentor
- Scaffold the backend server by running these commands:
mkdir -p server && cd server && bun init
What does bun init do?
This command scaffolds a new Bun project in the current directory. It creates a package.json, a tsconfig.json, and a starter index.ts file.
The CLI will ask you a couple of prompts (package name, entry point). Press Enter to accept the defaults for each.
- Install the backend dependencies by running:
bun add hono openai
What are these packages?
Hono is a lightweight web framework built on Web Standards. It runs natively on Bun without adapters and has built-in streaming helpers.
The openai package is the standard SDK for calling any OpenAI-compatible API. NVIDIA NIM exposes an OpenAI-compatible endpoint, so this one package handles authentication, streaming, and typing for you.
Now you need to update the package.json that bun init generated so it includes a dev script for hot-reloading.
- Open server/package.json in VS Code and replace its entire contents with the following:
{
"name": "project-mentor-server",
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"hono": "^4.7.0",
"openai": "^6.44.0"
}
}
What does the dev script do?
The --hot flag enables hot reloading. Whenever you save a file in the server, Bun restarts the process automatically so you see changes instantly.
- Save package.json.
- Go back to the project root by running:
cd ..
- Scaffold the React frontend using Vite by running:
bunx --bun create vite@latest client -- --template react-ts
What does this command do?
This uses Bun's package executor (bunx) to run the Vite scaffolding tool. The --template react-ts flag tells Vite to generate a React project with TypeScript. The result is a ready-to-run frontend app in a client/ folder.
- Move into the client folder and install its dependencies by running:
cd client && bun install
- Confirm both folders exist by running:
cd .. && ls
You should see both server and client listed in the output.
Only seeing one folder?
Make sure you ran cd .. to return to the project-mentor/ root before running ls. If you are still inside client/ or server/, navigate up one level and try again.
Help me figure out where I am
Configure shadcn/ui in the React frontend
shadcn/ui gives you beautifully styled, copy-paste components built on top of Tailwind CSS. Before you can use them, you need to install Tailwind, set up import aliases, and run the shadcn initializer.
- Move into the client folder by running:
cd client
- Install Tailwind CSS and its Vite plugin by running:
bun add tailwindcss @tailwindcss/vite
- Install the Node.js type definitions (needed for the path alias in vite.config.ts) by running:
bun add -d @types/node
Next, you need to configure path aliases so that imports like @/components/ui/button resolve correctly.
- In VS Code, open client/tsconfig.json from the Explorer sidebar.
- Add the following compilerOptions block to the file (alongside the existing files and references keys):
{
"files": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.node.json"
}
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
What are path aliases?
The @/* alias maps to your src/ folder. Instead of writing relative imports like ../../components/ui/button, you can write @/components/ui/button. This keeps imports clean as your project grows.
- Open client/tsconfig.app.json and add baseUrl and paths to the existing compilerOptions section:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
Keep existing options
Add baseUrl and paths inside the existing compilerOptions block. Do not delete the other options already in the file.
- Open client/vite.config.ts and replace its entire contents with the following:
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
What does this config do?
The tailwindcss() plugin lets Vite process Tailwind utility classes at build time. The resolve.alias section tells Vite how to resolve the @/ import prefix at runtime, matching what you configured in tsconfig.
- Save vite.config.ts.
- Initialize shadcn/ui by running:
bunx --bun shadcn@latest init
What will the CLI ask?
The shadcn CLI will ask you to choose a style, base color, and whether to use CSS variables. Accept the defaults by pressing Enter for each prompt. It will automatically update your src/index.css with the correct Tailwind and shadcn imports.
- Add the UI components you will use in this project by running:
bunx --bun shadcn@latest add button card textarea badge skeleton
- Open client/src/index.css and verify it contains the following imports:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
If your file has extra content from the Vite template, replace everything with the three imports above.
- Save index.css.
shadcn init failed?
Make sure you saved vite.config.ts with the Tailwind plugin and path alias BEFORE running shadcn init. The initializer reads your Vite config to detect Tailwind.
Also check that you are inside the client/ directory when running the command.
Help me debug the shadcn init error
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete vite.config.ts:
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
Here is the complete index.css:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
Here is the complete server/package.json:
{
"name": "project-mentor-server",
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts"
},
"dependencies": {
"hono": "^4.7.0",
"openai": "^6.44.0"
}
}
Verify both apps run
Before writing any real logic, confirm that both the backend and frontend start without errors.
- Move to the server directory by running:
cd ../server
You need a minimal Hono server file so the backend has something to serve.
- Create the src directory and an entry file by running:
mkdir -p src && touch src/index.ts
- Open server/src/index.ts in VS Code and paste the following:
import { Hono } from "hono";
const app = new Hono();
// Simple health check route
app.get("/", (c) => c.text("Project Mentor API is running"));
export default {
port: 3001,
fetch: app.fetch,
};
What does this code do?
This creates a minimal Hono app with a single route at / that returns a text response. The export default block tells Bun to serve the app on port 3001.
- Save src/index.ts.
- Start the backend by running:
bun run --hot src/index.ts
- Open your browser and navigate to http://localhost:3001.
You should see the text "Project Mentor API is running" displayed in the browser.
Seeing "port already in use"?
Another process is using port 3001. Stop it by finding its process ID with lsof -i :3001 (macOS) or netstat -ano | findstr :3001 (Windows), then terminate that process. Then try starting the server again.
Help me fix the port conflict
- Open a new terminal tab or window (keep the backend running).
- Navigate to the client folder by running:
cd ~/Desktop/project-mentor/client
- Start the frontend dev server by running:
bun run dev
- Open your browser and navigate to http://localhost:5173.
You should see the default Vite + React welcome page with a counter button.
Frontend not loading?
Make sure you ran bun install inside the client/ folder earlier. If you skipped it, run it now and then try bun run dev again.
Help me troubleshoot my frontend
Both halves of your project are alive. Next up, you will build the AI analysis endpoint that turns project ideas into structured roadmaps.
Build the AI Analysis Endpoint
Your project structure is ready, and both apps can start up. Now it's time to build the brain of the application.
In this step, you'll create an API endpoint that receives a project idea, sends it to the NVIDIA NIM API for analysis, and streams the AI's structured response back to the caller. The endpoint uses a carefully crafted system prompt to ensure the model returns consistent, parseable JSON.
In this step, get ready to:
- Set up a Hono server with CORS middleware for cross-origin requests.
- Connect to the NVIDIA NIM API with a structured system prompt.
- Implement a streaming endpoint and verify it with curl.
Create the Hono server with CORS
Your frontend runs on port 5173 and your backend runs on port 3001. Browsers block requests between different origins by default. CORS (Cross-Origin Resource Sharing) middleware tells the browser that your backend explicitly allows requests from your frontend's origin.
- In VS Code, open server/src/index.ts.
- Replace any existing contents with the following code:
import { Hono } from "hono";
import { cors } from "hono/cors";
import { streamText } from "hono/streaming";
import OpenAI from "openai";
const app = new Hono();
// Allow the React frontend to make requests to this server
app.use(
"/api/*",
cors({
origin: "http://localhost:5173",
})
);
What does this code do?
The four imports bring in everything the server needs. Hono is the web framework. cors is middleware that adds the right headers. streamText is Hono's helper for sending streamed text responses. OpenAI is the SDK for talking to any OpenAI-compatible API.
The cors() call scopes permission to /api/* routes and allows only http://localhost:5173 (your Vite dev server) to call them.
Integrate the NVIDIA NIM API using the OpenAI SDK
NVIDIA NIM exposes an OpenAI-compatible API, which means you can use the standard openai npm package to connect to it. You just point the client at NIM's base URL instead of OpenAI's.
Before adding the client code, you need to make your API key available to the server. Bun automatically loads environment variables from a .env file in your project directory.
- Create a new file called .env inside the server/ folder.
- Add your NVIDIA NIM API key to it:
NIM_API_KEY=your-api-key-here
- Replace your-api-key-here with the actual API key from your NVIDIA developer account.
Where do I get my NIM API key?
If you don't have one yet, go to build.nvidia.com and sign up for the NVIDIA Developer Program. The NIM API is free for prototyping with no charges for development use.
Now you'll configure the OpenAI client and write the system prompt that tells the model exactly how to structure its output.
- Return to server/src/index.ts and add the following code below the CORS middleware:
// Connect to NVIDIA NIM using the OpenAI-compatible SDK
const client = new OpenAI({
baseURL: "https://integrate.api.nvidia.com/v1",
apiKey: process.env.NIM_API_KEY,
});
// Instruct the model to return a structured JSON execution plan
const SYSTEM_PROMPT = `You are a project mentor AI. When given a project idea, analyze it and return a structured JSON execution plan.
Return ONLY valid JSON in this exact format (no markdown, no code fences):
{
"title": "Project title",
"summary": "One-sentence summary of what the user will build",
"totalComplexity": "beginner" | "intermediate" | "advanced",
"milestones": [
{
"order": 1,
"title": "Milestone title",
"description": "What to accomplish in this milestone",
"skills": ["skill1", "skill2"],
"complexity": "low" | "medium" | "high",
"estimatedHours": 2
}
]
}
Guidelines:
- Create 4-6 milestones that build on each other
- Order milestones from foundational to advanced
- Be specific about skills needed per milestone
- Keep descriptions actionable and concise`;
What does this code do?
The OpenAI client is configured with NIM's base URL (https://integrate.api.nvidia.com/v1) instead of OpenAI's default. It reads your API key from the environment variable you just set.
The SYSTEM_PROMPT is the most critical piece of this project. It constrains the model to return a specific JSON schema with milestones, skills, and complexity ratings. Without this structure, the model would return freeform text that your frontend can't parse.
What is prompt engineering?
Prompt engineering is the practice of writing instructions that guide an LLM's output format and behavior. Here, the system prompt tells the model to return pure JSON (no markdown fences), specifies the exact schema shape, and gives guidelines about milestone count and ordering. This is what makes the AI's output predictable enough for a frontend to render.
Implement streaming response
Large language models generate text token by token. Instead of waiting for the entire response to finish before sending anything to the client, streaming sends each chunk as it arrives. This makes the app feel responsive even when the AI takes several seconds to generate a full plan.
- In server/src/index.ts, add the /api/analyze endpoint and server export below the SYSTEM_PROMPT:
app.post("/api/analyze", async (c) => {
const body = await c.req.json();
const idea = body.idea;
// Reject empty or missing project ideas
if (!idea || typeof idea !== "string" || idea.trim().length === 0) {
return c.json({ error: "Please provide a project idea" }, 400);
}
// Request a streaming chat completion from the NIM API
const stream = await client.chat.completions.create({
model: "meta/llama-3.3-70b-instruct",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: idea },
],
max_tokens: 2048,
temperature: 0.7,
stream: true,
});
// Forward each chunk to the client as it arrives
return streamText(c, async (textStream) => {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
await textStream.write(content);
}
}
});
});
export default {
port: 3001,
fetch: app.fetch,
};
What does this code do?
- The route handler first validates the incoming idea field. If it's missing or empty, the server returns a 400 error.
- It calls client.chat.completions.create() with stream: true. This tells the NIM API to send the response in chunks instead of all at once.
- Hono's streamText helper opens a streaming HTTP response. The for await loop reads chunks from the AI and writes each one to the client immediately.
- The export default at the bottom tells Bun to run the server on port 3001.
Why use Hono instead of Express?
Hono is built on Web Standards (the Fetch API) and works natively with Bun without adapters. Express would require extra compatibility layers. Hono also has built-in streaming helpers like streamText that make forwarding AI responses straightforward.
- Save server/src/index.ts.
Now test the endpoint to confirm streaming works. You'll use curl to send a POST request and watch the response arrive in chunks.
- Start (or restart) the server from the server/ directory by running:
cd server && bun run dev
- Open a second terminal window and test the endpoint by running:
curl -X POST http://localhost:3001/api/analyze \
-H "Content-Type: application/json" \
-d '{"idea": "A personal finance tracker"}'
- You should see JSON text streaming into your terminal character by character. The response contains a title, summary, complexity rating, and an array of milestones.
Not seeing any output?
- Check that your server/.env file has NIM_API_KEY set to a valid key with no extra spaces or quotes around it.
- Confirm the server is running in the other terminal. You should see no errors in its output.
- Make sure you started the server from inside the server/ directory so Bun can find the .env file.
Help me debug my NIM API connection
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
import { Hono } from "hono";
import { cors } from "hono/cors";
import { streamText } from "hono/streaming";
import OpenAI from "openai";
const app = new Hono();
app.use(
"/api/*",
cors({
origin: "http://localhost:5173",
})
);
const client = new OpenAI({
baseURL: "https://integrate.api.nvidia.com/v1",
apiKey: process.env.NIM_API_KEY,
});
const SYSTEM_PROMPT = `You are a project mentor AI. When given a project idea, analyze it and return a structured JSON execution plan.
Return ONLY valid JSON in this exact format (no markdown, no code fences):
{
"title": "Project title",
"summary": "One-sentence summary of what the user will build",
"totalComplexity": "beginner" | "intermediate" | "advanced",
"milestones": [
{
"order": 1,
"title": "Milestone title",
"description": "What to accomplish in this milestone",
"skills": ["skill1", "skill2"],
"complexity": "low" | "medium" | "high",
"estimatedHours": 2
}
]
}
Guidelines:
- Create 4-6 milestones that build on each other
- Order milestones from foundational to advanced
- Be specific about skills needed per milestone
- Keep descriptions actionable and concise`;
app.post("/api/analyze", async (c) => {
const body = await c.req.json();
const idea = body.idea;
if (!idea || typeof idea !== "string" || idea.trim().length === 0) {
return c.json({ error: "Please provide a project idea" }, 400);
}
const stream = await client.chat.completions.create({
model: "meta/llama-3.3-70b-instruct",
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: idea },
],
max_tokens: 2048,
temperature: 0.7,
stream: true,
});
return streamText(c, async (textStream) => {
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
await textStream.write(content);
}
}
});
});
export default {
port: 3001,
fetch: app.fetch,
};
Your backend is now a working AI analysis engine. It receives a project idea, passes it through a structured prompt to the NIM API, and streams the response back in real time. Next up, you'll build the React frontend that lets users type their ideas and see the results.
Build the Input Interface
Your backend can receive a project idea and stream back a structured roadmap. Now you need a way for users to actually submit their ideas and see the results.
In this step, you will build the React frontend that talks to your API. The interface includes a text input for the project idea, clickable example prompts for inspiration, and a card-based roadmap display that renders the AI response.
In this step, get ready to:
- Create the main layout with a Textarea, submit Button, and loading Skeleton.
- Connect the form to your backend API using streaming fetch.
- Add clickable example prompts and build the milestone card display.
Create the main layout and idea input form
The default Vite scaffold still shows in your browser. You will replace it with a clean form-based layout that uses the shadcn/ui components you installed earlier.
- In VS Code, open client/src/App.tsx.
- Select all the existing content and delete it.
- Start the new file with these imports, type definitions, and component state:
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 { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
// TypeScript interface for a single milestone in the plan
interface Milestone {
order: number;
title: string;
description: string;
skills: string[];
complexity: "low" | "medium" | "high";
estimatedHours: number;
}
// TypeScript interface for the full project plan response
interface ProjectPlan {
title: string;
summary: string;
totalComplexity: string;
milestones: Milestone[];
}
// Example prompts users can click for inspiration
const EXAMPLES = [
"Personal finance tracker that categorizes expenses automatically",
"AI chatbot for customer support with knowledge base",
"Fitness app that generates workout plans based on goals",
];
function App() {
const [idea, setIdea] = useState("");
const [plan, setPlan] = useState<ProjectPlan | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
What does this code do?
- The imports pull in shadcn/ui components you installed in Step 1: Button, Textarea, Card, Badge, and Skeleton.
- Milestone and ProjectPlan are TypeScript interfaces that describe the exact shape of the JSON your backend returns.
- EXAMPLES holds three sample prompts that become clickable chips in the UI.
- The four state variables track the user's input (idea), the parsed AI response (plan), a loading flag, and any error message.
Connect to the backend API with streaming fetch
Your backend streams the AI response as text chunks rather than sending one large payload. The frontend needs to read those chunks, accumulate them, and parse the final result as JSON.
- Directly below the state declarations, add the handleSubmit and handleReset functions:
async function handleSubmit() {
if (!idea.trim()) return;
setLoading(true);
setError("");
setPlan(null);
try {
// Send the idea to the backend streaming endpoint
const response = await fetch("http://localhost:3001/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idea }),
});
if (!response.ok) {
throw new Error("Failed to analyze project idea");
}
// Get a reader to consume the stream chunk by chunk
const reader = response.body?.getReader();
if (!reader) throw new Error("No response stream");
const decoder = new TextDecoder();
let accumulated = "";
// Read chunks until the stream finishes
while (true) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value, { stream: true });
}
// Parse the complete accumulated text as JSON
const parsed = JSON.parse(accumulated) as ProjectPlan;
setPlan(parsed);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
}
function handleReset() {
setPlan(null);
setIdea("");
setError("");
}
How does the streaming fetch work?
- response.body?.getReader() gives you a ReadableStream reader. Instead of waiting for the entire response, you read it piece by piece as it arrives.
- The while (true) loop calls reader.read() repeatedly. Each iteration returns a chunk of bytes until done is true.
- TextDecoder converts raw bytes into text. The { stream: true } option handles multi-byte characters that might split across chunks.
- Once the stream finishes, the accumulated string is the full JSON response. JSON.parse turns it into a typed ProjectPlan object.
Why not just use response.json()?
The standard response.json() method waits for the entire body to arrive before parsing. With a streaming endpoint, that works but hides the real-time nature of the response.
Using a ReadableStream reader means you could show partial text as it arrives (like a typing effect). For now you accumulate and parse at the end, but the infrastructure is ready for real-time display later.
Add example prompts and render the roadmap
The JSX return statement renders two views: an input form when no plan exists, and a roadmap display once the AI responds. The example chips give users a quick way to try the app without typing a full idea.
- Below the handleReset function, add the component's return statement:
return (
<div className="min-h-screen bg-background p-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-2 text-4xl font-bold tracking-tight">
Project Mentor
</h1>
<p className="mb-8 text-muted-foreground">
Describe any project idea and get a structured execution roadmap.
</p>
{/* Input form - shown when no plan exists */}
{!plan && (
<div className="space-y-4">
<Textarea
placeholder="Describe your project idea..."
value={idea}
onChange={(e) => setIdea(e.target.value)}
rows={4}
className="resize-none"
/>
{/* Clickable example chips */}
<div className="flex flex-wrap gap-2">
{EXAMPLES.map((example) => (
<button
key={example}
onClick={() => setIdea(example)}
className="rounded-full border px-3 py-1 text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
{example}
</button>
))}
</div>
<Button onClick={handleSubmit} disabled={loading || !idea.trim()}>
{loading ? "Analyzing..." : "Generate Roadmap"}
</Button>
{/* Skeleton placeholders while loading */}
{loading && (
<div className="space-y-4 pt-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
{/* Plan display - shown after AI responds */}
{plan && (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{plan.title}</h2>
<p className="text-muted-foreground">{plan.summary}</p>
<div className="flex items-center gap-2">
<Badge variant="outline">{plan.totalComplexity}</Badge>
<span className="text-sm text-muted-foreground">
{plan.milestones.length} milestones
</span>
</div>
</div>
<div className="space-y-4">
{plan.milestones.map((milestone) => (
<MilestoneCard key={milestone.order} milestone={milestone} />
))}
</div>
<Button variant="outline" onClick={handleReset}>
Generate another
</Button>
</div>
)}
</div>
</div>
);
}
What does the JSX render?
- The {!plan && (...)} block shows the input form. It contains the Textarea, example chips, submit Button, loading Skeletons, and error text.
- Each example chip calls setIdea(example) on click, which fills the textarea instantly.
- The Button is disabled when loading is true or when the textarea is empty. Its label switches to "Analyzing..." during the request.
- The {plan && (...)} block shows the roadmap: a summary header with a Badge for complexity, then a list of MilestoneCard components, and a "Generate another" button.
- Finally, add the MilestoneCard component and the default export below the closing } of the App function:
// Renders a single milestone as a styled card
function MilestoneCard({ milestone }: { milestone: Milestone }) {
const complexityColor = {
low: "bg-green-100 text-green-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800",
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
{/* Numbered circle for milestone order */}
<span className="flex size-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
{milestone.order}
</span>
<CardTitle className="text-lg">{milestone.title}</CardTitle>
</div>
{/* Complexity badge with color coding */}
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexityColor[milestone.complexity]}`}
>
{milestone.complexity}
</span>
</div>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
{milestone.description}
</p>
{/* Skill badges */}
<div className="flex flex-wrap gap-1.5">
{milestone.skills.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
~{milestone.estimatedHours}h estimated
</p>
</CardContent>
</Card>
);
}
export default App;
What does MilestoneCard do?
- It receives a single Milestone object and renders it as a shadcn Card with a numbered circle, title, description, skill badges, and a color-coded complexity indicator.
- The complexityColor object maps low/medium/high to green/yellow/red Tailwind classes so learners can quickly gauge difficulty at a glance.
- Save client/src/App.tsx.
- Switch to the terminal running your frontend dev server (port 5173). If it stopped, restart it by running:
cd client && bun run dev
- Open http://localhost:5173 in your browser. You should see the "Project Mentor" heading, a textarea, three clickable example chips below it, and a "Generate Roadmap" button.
- Click one of the example chips. The textarea should fill with that prompt text.
- Click Generate Roadmap with your backend still running on port 3001. After a few seconds, skeleton placeholders appear, then the roadmap cards render with milestone titles, skill badges, and complexity colors.
Not seeing the roadmap cards?
- Check that your backend server is still running on port 3001. If it stopped, run bun run dev again from the server/ folder.
- If you see a CORS error in the browser console, confirm your backend's CORS origin is set to http://localhost:5173 (not 5174 or another port).
- If the response comes back but cards don't render, open the browser console and check for JSON parse errors. The AI occasionally wraps its output in markdown code fences. Try submitting again.
Why isn't my Project Mentor frontend displaying the roadmap after I click Generate Roadmap?
✔️ Awesome, I've got everything!
Great. Double check you have saved client/src/App.tsx.
ⓧ I'd like to double check the full code
Here is the complete client/src/App.tsx file for comparison:
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 { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
interface Milestone {
order: number;
title: string;
description: string;
skills: string[];
complexity: "low" | "medium" | "high";
estimatedHours: number;
}
interface ProjectPlan {
title: string;
summary: string;
totalComplexity: string;
milestones: Milestone[];
}
const EXAMPLES = [
"Personal finance tracker that categorizes expenses automatically",
"AI chatbot for customer support with knowledge base",
"Fitness app that generates workout plans based on goals",
];
function App() {
const [idea, setIdea] = useState("");
const [plan, setPlan] = useState<ProjectPlan | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit() {
if (!idea.trim()) return;
setLoading(true);
setError("");
setPlan(null);
try {
const response = await fetch("http://localhost:3001/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idea }),
});
if (!response.ok) {
throw new Error("Failed to analyze project idea");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response stream");
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value, { stream: true });
}
const parsed = JSON.parse(accumulated) as ProjectPlan;
setPlan(parsed);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
}
function handleReset() {
setPlan(null);
setIdea("");
setError("");
}
return (
<div className="min-h-screen bg-background p-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-2 text-4xl font-bold tracking-tight">
Project Mentor
</h1>
<p className="mb-8 text-muted-foreground">
Describe any project idea and get a structured execution roadmap.
</p>
{!plan && (
<div className="space-y-4">
<Textarea
placeholder="Describe your project idea..."
value={idea}
onChange={(e) => setIdea(e.target.value)}
rows={4}
className="resize-none"
/>
<div className="flex flex-wrap gap-2">
{EXAMPLES.map((example) => (
<button
key={example}
onClick={() => setIdea(example)}
className="rounded-full border px-3 py-1 text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
{example}
</button>
))}
</div>
<Button onClick={handleSubmit} disabled={loading || !idea.trim()}>
{loading ? "Analyzing..." : "Generate Roadmap"}
</Button>
{loading && (
<div className="space-y-4 pt-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
{plan && (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{plan.title}</h2>
<p className="text-muted-foreground">{plan.summary}</p>
<div className="flex items-center gap-2">
<Badge variant="outline">{plan.totalComplexity}</Badge>
<span className="text-sm text-muted-foreground">
{plan.milestones.length} milestones
</span>
</div>
</div>
<div className="space-y-4">
{plan.milestones.map((milestone) => (
<MilestoneCard key={milestone.order} milestone={milestone} />
))}
</div>
<Button variant="outline" onClick={handleReset}>
Generate another
</Button>
</div>
)}
</div>
</div>
);
}
function MilestoneCard({ milestone }: { milestone: Milestone }) {
const complexityColor = {
low: "bg-green-100 text-green-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800",
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
{milestone.order}
</span>
<CardTitle className="text-lg">{milestone.title}</CardTitle>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexityColor[milestone.complexity]}`}
>
{milestone.complexity}
</span>
</div>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
{milestone.description}
</p>
<div className="flex flex-wrap gap-1.5">
{milestone.skills.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
~{milestone.estimatedHours}h estimated
</p>
</CardContent>
</Card>
);
}
export default App;
Your frontend is fully connected to the AI backend and rendering structured roadmaps. Next up, you will polish the roadmap visualization with better card layouts and a project summary header.
Build the Roadmap Visualization
Your backend streams AI-generated plans, and your frontend already consumes that stream and parses the JSON. Now it's time to understand how the visualization layer turns that raw data into an interactive, card-based roadmap.
In this step, you'll walk through the MilestoneCard component and roadmap rendering logic already in your code, then test the full application end-to-end.
In this step, get ready to:
- Understand how the MilestoneCard component renders each milestone as a styled card.
- Review how the roadmap view maps AI response data into ordered milestone cards.
- Test the full app end-to-end by submitting a project idea and verifying the roadmap appears.
Review the MilestoneCard component
Each milestone from the AI's JSON response needs its own visual representation. The MilestoneCard component at the bottom of your App.tsx handles this.
- In VS Code, switch back to client/src/App.tsx.
- Scroll to the bottom of the file and find the MilestoneCard function:
function MilestoneCard({ milestone }: { milestone: Milestone }) {
const complexityColor = {
low: "bg-green-100 text-green-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800",
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
{milestone.order}
</span>
<CardTitle className="text-lg">{milestone.title}</CardTitle>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexityColor[milestone.complexity]}`}
>
{milestone.complexity}
</span>
</div>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
{milestone.description}
</p>
<div className="flex flex-wrap gap-1.5">
{milestone.skills.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
~{milestone.estimatedHours}h estimated
</p>
</CardContent>
</Card>
);
}
What does this component do?
The MilestoneCard component takes a single milestone object and renders it as a shadcn Card with several visual elements.
- The complexityColor map assigns green, yellow, or red styling based on whether the milestone is low, medium, or high complexity.
- A circular span with milestone.order acts as a step number indicator so learners can see the sequence at a glance.
- Each skill in the skills array renders as a shadcn Badge component, making required skills easy to scan.
- The estimated hours line at the bottom gives a rough time commitment for each milestone.
Review the roadmap rendering logic
The MilestoneCard component is one card. The roadmap view ties everything together by rendering the full plan once the AI response arrives.
- Scroll up to the return statement inside the App function and find the {plan && ( conditional block:
{plan && (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{plan.title}</h2>
<p className="text-muted-foreground">{plan.summary}</p>
<div className="flex items-center gap-2">
<Badge variant="outline">{plan.totalComplexity}</Badge>
<span className="text-sm text-muted-foreground">
{plan.milestones.length} milestones
</span>
</div>
</div>
<div className="space-y-4">
{plan.milestones.map((milestone) => (
<MilestoneCard key={milestone.order} milestone={milestone} />
))}
</div>
<Button variant="outline" onClick={handleReset}>
Generate another
</Button>
</div>
)}
What's happening here?
This block only renders when plan is not null (after the AI responds). It has three sections.
- The summary header shows the project title, a one-line summary, the overall complexity as an outline Badge, and the total milestone count.
- The plan.milestones.map() call renders one MilestoneCard per milestone, ordered by their order property.
- The Generate another button calls handleReset, which clears the plan, idea, and error state so the user can start fresh.
Why conditional rendering instead of a separate page?
The app toggles between the input form ({!plan && ...}) and the roadmap ({plan && ...}) based on state alone. No router needed.
This keeps the app as a single page with a clear two-state flow: input, then results. The handleReset function flips back to the input state by nulling the plan.
Test the full application end-to-end
Everything is wired up. Your backend streams structured JSON, and your frontend parses it into visual milestone cards. Time to test the complete flow.
- Make sure both your server (port 3001) and client (port 5173) are still running in their respective terminals.
- Open your browser to http://localhost:5173.
- Click one of the example chips (e.g. "Personal finance tracker that categorizes expenses automatically") to populate the textarea.
- Click Generate Roadmap.
You should see the skeleton loading placeholders appear while the AI processes your idea. After a few seconds, the roadmap replaces the form with a project title, summary, complexity badge, and a series of numbered milestone cards.
- Verify each milestone card shows a numbered circle, title, complexity color (green/yellow/red), description, skill badges, and estimated hours.
- Click Generate another at the bottom of the roadmap.
- Confirm the form reappears with an empty textarea, ready for a new idea.
Not seeing the roadmap cards?
- Check your server terminal for errors. If you see an authentication error, verify your NIM_API_KEY environment variable is set correctly.
- If the loading skeletons appear but nothing replaces them, open the browser console (F12) and check for JSON parse errors. The AI may have returned malformed JSON. Try submitting again.
- If you see a CORS error in the console, make sure your server is running on port 3001 and that the CORS origin matches http://localhost:5173.
Help me debug why my roadmap isn't rendering after the AI responds.
Try different project ideas
Submit a few different ideas to see how the AI adapts its milestones. Vague ideas produce generic plans. Specific ideas ("a budgeting app with OCR receipt scanning") produce more targeted roadmaps with concrete skills.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and that both servers are running smoothly.
ⓧ I'd like to double check the full code
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 { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
interface Milestone {
order: number;
title: string;
description: string;
skills: string[];
complexity: "low" | "medium" | "high";
estimatedHours: number;
}
interface ProjectPlan {
title: string;
summary: string;
totalComplexity: string;
milestones: Milestone[];
}
const EXAMPLES = [
"Personal finance tracker that categorizes expenses automatically",
"AI chatbot for customer support with knowledge base",
"Fitness app that generates workout plans based on goals",
];
function App() {
const [idea, setIdea] = useState("");
const [plan, setPlan] = useState<ProjectPlan | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit() {
if (!idea.trim()) return;
setLoading(true);
setError("");
setPlan(null);
try {
const response = await fetch("http://localhost:3001/api/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idea }),
});
if (!response.ok) {
throw new Error("Failed to analyze project idea");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("No response stream");
const decoder = new TextDecoder();
let accumulated = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
accumulated += decoder.decode(value, { stream: true });
}
const parsed = JSON.parse(accumulated) as ProjectPlan;
setPlan(parsed);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong");
} finally {
setLoading(false);
}
}
function handleReset() {
setPlan(null);
setIdea("");
setError("");
}
return (
<div className="min-h-screen bg-background p-8">
<div className="mx-auto max-w-3xl">
<h1 className="mb-2 text-4xl font-bold tracking-tight">
Project Mentor
</h1>
<p className="mb-8 text-muted-foreground">
Describe any project idea and get a structured execution roadmap.
</p>
{!plan && (
<div className="space-y-4">
<Textarea
placeholder="Describe your project idea..."
value={idea}
onChange={(e) => setIdea(e.target.value)}
rows={4}
className="resize-none"
/>
<div className="flex flex-wrap gap-2">
{EXAMPLES.map((example) => (
<button
key={example}
onClick={() => setIdea(example)}
className="rounded-full border px-3 py-1 text-sm text-muted-foreground hover:border-primary hover:text-primary transition-colors"
>
{example}
</button>
))}
</div>
<Button onClick={handleSubmit} disabled={loading || !idea.trim()}>
{loading ? "Analyzing..." : "Generate Roadmap"}
</Button>
{loading && (
<div className="space-y-4 pt-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
)}
{plan && (
<div className="space-y-6">
<div className="space-y-2">
<h2 className="text-2xl font-semibold">{plan.title}</h2>
<p className="text-muted-foreground">{plan.summary}</p>
<div className="flex items-center gap-2">
<Badge variant="outline">{plan.totalComplexity}</Badge>
<span className="text-sm text-muted-foreground">
{plan.milestones.length} milestones
</span>
</div>
</div>
<div className="space-y-4">
{plan.milestones.map((milestone) => (
<MilestoneCard key={milestone.order} milestone={milestone} />
))}
</div>
<Button variant="outline" onClick={handleReset}>
Generate another
</Button>
</div>
)}
</div>
</div>
);
}
function MilestoneCard({ milestone }: { milestone: Milestone }) {
const complexityColor = {
low: "bg-green-100 text-green-800",
medium: "bg-yellow-100 text-yellow-800",
high: "bg-red-100 text-red-800",
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<span className="flex size-8 items-center justify-center rounded-full bg-primary text-sm font-medium text-primary-foreground">
{milestone.order}
</span>
<CardTitle className="text-lg">{milestone.title}</CardTitle>
</div>
<span
className={`rounded-full px-2 py-0.5 text-xs font-medium ${complexityColor[milestone.complexity]}`}
>
{milestone.complexity}
</span>
</div>
</CardHeader>
<CardContent>
<p className="mb-3 text-sm text-muted-foreground">
{milestone.description}
</p>
<div className="flex flex-wrap gap-1.5">
{milestone.skills.map((skill) => (
<Badge key={skill} variant="secondary" className="text-xs">
{skill}
</Badge>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
~{milestone.estimatedHours}h estimated
</p>
</CardContent>
</Card>
);
}
export default App;
Your Project Mentor is fully functional. Users can describe any idea, watch the AI think, and receive a visual roadmap broken into concrete milestones. Next up, you'll tackle the Secret Mission and add conversational deep-dives into individual milestones.
Secret mission
Add Milestone Deep Dive
Your roadmap shows milestones, but what if the learner wants to dig deeper into one? In this mission, you'll add a conversational Deep Dive feature. Clicking any milestone opens a chat thread where users can ask follow-up questions with full conversation history.
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, so there are no ongoing costs. The NVIDIA NIM API free tier has no charges for development use.
Resources you used:
- project-mentor/ folder on your Desktop (contains server/ and client/ subdirectories).
- NVIDIA NIM API key (stored as an environment variable, free tier).
Keep everything running
No action needed. Choose this if you're still actively building or want to expand the project later.
- Your project files stay on your Desktop, ready to pick up anytime.
- Your NVIDIA NIM API key remains available as an environment variable for future projects.
- To restart the apps later, open two terminal windows and run bun run dev in both the server/ and client/ directories.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files stay on disk for when you return.
- In each terminal window running the server and client, press Ctrl+C to stop the process.
- Your project files remain in ~/Desktop/project-mentor/ for next time.
- To resume, open terminals in server/ and client/ and run bun run dev in each.
Delete - I don't want to use this again
Remove all project files and start fresh. This cannot be undone.
- Stop both running processes by pressing Ctrl+C in each terminal window.
- Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/project-mentor
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\project-mentor
- Your NVIDIA NIM API key environment variable remains set in your shell profile. To remove it, delete the NIM_API_KEY line from your .bashrc, .zshrc, or system environment variables.
Nice Work!
Nice Work!
You just built a full-stack AI-powered project planner from scratch. Your app takes a vague project idea, sends it to an LLM, and renders a structured execution roadmap in real time.
You've learned how to:
- Build a full-stack AI application with a Bun/Hono backend and a React + shadcn/ui frontend.
- Engineer a system prompt that reliably produces structured JSON plans from vague project ideas using the NVIDIA NIM API.
- Implement real-time streaming from an OpenAI-compatible API to the browser using the Fetch API with ReadableStream.
- Secret Mission: Added a conversational deep-dive feature where users can ask follow-up questions about individual milestones in a threaded chat.
Ready to quiz yourself?