AI Project Workspace Generator
Use Gemini AI and the Notion API to generate structured project workspaces.
Introduction
30 Second Summary
Starting a new project is exciting, but setting up the workspace can drain hours before you write a single line of code. Researching tools, creating task lists, and organizing preparation steps all take time away from actually building.
In this project, you will build a web app called WorkspaceForge that turns a one-sentence project idea into a fully structured Notion workspace using Google Gemini AI. You will deploy it to a live URL so anyone can use it.
What You'll Build
A full-stack Next.js web application that accepts a project idea in plain English and generates a complete Notion workspace with a guide page, tools list, preparation steps, and a Kanban-ready task database.
By the end of this project, you'll have:
- A deployed web app that generates comprehensive project guides from a single prompt using AI.
- A working Notion API integration that creates an interactive project workspace with a Steps database in your Notion account.
- A live URL on Vercel anyone can visit, demonstrating a complete AI-to-productivity-tool pipeline.
- Secret Mission: Add a Code Snippets database to the generated workspace that includes AI-generated starter code for each step.
Are there any prerequisites?
You should have intermediate JavaScript knowledge and basic familiarity with React. You will also need a Google account, a Notion account, and a GitHub account.
Build the Prompt Interface
Your development environment is set up with all the tools and API keys you need. Now it's time to build the page your users will actually see and interact with.
WorkspaceForge needs a single-page interface where someone can describe their project idea, click a button, and watch as the app generates their Notion workspace. In this step, you'll build that interface using React state management and Tailwind CSS for styling.
In this step, get ready to:
- Create a React component with form state, loading states, and error handling.
- Style the interface with Tailwind CSS for a minimal, professional card layout.
- Add visual feedback that changes the button text during generation.
Create the main page UI
The default app/page.js file contains Next.js boilerplate. You'll replace it entirely with a client-side React component that manages form submission, tracks loading progress across two API calls, and displays success or error states.
- In the VS Code Explorer sidebar, open app/page.js.
- Select all the existing code and delete it.
- Replace it with the following component code:
"use client";
import { useState } from "react";
export default function Home() {
// Track the user's input and app state
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState("idle");
const [workspaceUrl, setWorkspaceUrl] = useState("");
const [error, setError] = useState("");
// Handle form submission: call both API routes in sequence
async function handleSubmit(e) {
e.preventDefault();
if (!prompt.trim()) return;
setStatus("generating");
setError("");
setWorkspaceUrl("");
try {
// Step 1: Send the user's prompt to the AI generation route
const genResponse = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!genResponse.ok) {
throw new Error("Failed to generate project plan");
}
const projectPlan = await genResponse.json();
setStatus("creating");
// Step 2: Send the AI-generated plan to the Notion workspace creator
const notionResponse = await fetch("/api/create-workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectPlan }),
});
if (!notionResponse.ok) {
throw new Error("Failed to create Notion workspace");
}
const { url } = await notionResponse.json();
setWorkspaceUrl(url);
setStatus("done");
} catch (err) {
setError(err.message);
setStatus("idle");
}
}
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-2xl bg-white rounded-xl shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">WorkspaceForge</h1>
<p className="text-gray-600 mb-6">
Describe your project idea and get a fully structured Notion workspace
with steps, tools, and preparation guides.
</p>
{/* Form with textarea and submit button */}
<form onSubmit={handleSubmit}>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Build a full-stack e-commerce app with Next.js, Stripe payments, and a PostgreSQL database..."
className="w-full h-32 p-4 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
disabled={status === "generating" || status === "creating"}
/>
{/* Button text changes based on current status */}
<button
type="submit"
disabled={status === "generating" || status === "creating" || !prompt.trim()}
className="mt-4 w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{status === "generating" && "Generating project plan..."}
{status === "creating" && "Creating Notion workspace..."}
{(status === "idle" || status === "done") && "Generate Workspace"}
</button>
</form>
{/* Error message display */}
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
)}
{/* Success message with link to the created Notion workspace */}
{status === "done" && workspaceUrl && (
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800 font-semibold">Workspace created!</p>
<a
href={workspaceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline mt-1 inline-block"
>
Open in Notion
</a>
</div>
)}
</div>
</main>
);
}
What does this code do?
This component manages four pieces of state: the user's text input (prompt), the current status (idle, generating, creating, or done), a URL for the finished workspace, and any error message.
The handleSubmit function runs a two-step process. First, it POSTs the user's prompt to /api/generate to get an AI-generated project plan. Then it sends that plan to /api/create-workspace to build the Notion workspace. The status updates between each call so the button text keeps the user informed.
The "use client" directive at the top tells Next.js this is a client component that uses browser features like useState and event handlers.
- Save app/page.js.
Seeing a blank page or error?
- Make sure the very first line is "use client"; with the semicolon. Without it, Next.js treats this as a server component and useState will throw an error.
- Check that you deleted ALL the previous boilerplate before pasting. Leftover imports or JSX from the default page cause syntax errors.
- If your terminal shows a compilation error, look at the line number it mentions and compare against the code above.
Help me debug my app/page.js component
Style the interface with Tailwind
The Tailwind utility classes you just added create a centered card layout with built-in visual feedback. Let's verify everything renders correctly and walk through what the styling achieves.
- Return to your browser at http://localhost:3000.
- You should see a white card centered on a light gray background, with the title "WorkspaceForge", a description, a text area, and a blue "Generate Workspace" button.
- Try clicking the button without typing anything. It should stay grayed out because the disabled attribute checks for an empty prompt.
- Type any text in the textarea, then click the button. The button text changes to "Generating project plan..." and the button turns gray.
How does the visual feedback work?
- The button's disabled:bg-gray-400 and disabled:cursor-not-allowed classes make it obvious when the app is processing.
- The button label renders different text for each status value, so users always know which stage the generation is at.
- The error div only appears when error is non-empty. The success div only appears when status is done and a URL exists.
The button will show an error after a moment because the API routes don't exist yet. That's expected. The red error box appearing confirms your error state is working correctly.
Page not updating after save?
- Next.js uses Fast Refresh. If the page doesn't update, check your terminal for compilation errors.
- Make sure your dev server is still running (npm run dev in the terminal).
- Try a hard refresh with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
Help me fix my page not rendering correctly
✔️ Awesome, I've got everything!
Great. Double check you've saved app/page.js and your browser shows the WorkspaceForge form.
ⓧ I'd like to double check the full code
Here's the complete app/page.js file. Make sure yours matches exactly:
"use client";
import { useState } from "react";
export default function Home() {
const [prompt, setPrompt] = useState("");
const [status, setStatus] = useState("idle");
const [workspaceUrl, setWorkspaceUrl] = useState("");
const [error, setError] = useState("");
async function handleSubmit(e) {
e.preventDefault();
if (!prompt.trim()) return;
setStatus("generating");
setError("");
setWorkspaceUrl("");
try {
const genResponse = await fetch("/api/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt }),
});
if (!genResponse.ok) {
throw new Error("Failed to generate project plan");
}
const projectPlan = await genResponse.json();
setStatus("creating");
const notionResponse = await fetch("/api/create-workspace", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ projectPlan }),
});
if (!notionResponse.ok) {
throw new Error("Failed to create Notion workspace");
}
const { url } = await notionResponse.json();
setWorkspaceUrl(url);
setStatus("done");
} catch (err) {
setError(err.message);
setStatus("idle");
}
}
return (
<main className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-2xl bg-white rounded-xl shadow-lg p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">WorkspaceForge</h1>
<p className="text-gray-600 mb-6">
Describe your project idea and get a fully structured Notion workspace
with steps, tools, and preparation guides.
</p>
<form onSubmit={handleSubmit}>
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="e.g. Build a full-stack e-commerce app with Next.js, Stripe payments, and a PostgreSQL database..."
className="w-full h-32 p-4 border border-gray-300 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
disabled={status === "generating" || status === "creating"}
/>
<button
type="submit"
disabled={status === "generating" || status === "creating" || !prompt.trim()}
className="mt-4 w-full py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{status === "generating" && "Generating project plan..."}
{status === "creating" && "Creating Notion workspace..."}
{(status === "idle" || status === "done") && "Generate Workspace"}
</button>
</form>
{error && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-700">{error}</p>
</div>
)}
{status === "done" && workspaceUrl && (
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800 font-semibold">Workspace created!</p>
<a
href={workspaceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline mt-1 inline-block"
>
Open in Notion
</a>
</div>
)}
</div>
</main>
);
}
Your interface is ready to accept project ideas and show real-time progress. Next up, you'll build the API route that takes the user's prompt and sends it to Gemini to generate a structured project plan.
Build the AI Generation Route
Your frontend is sending requests to /api/generate, but nothing is listening there yet. Right now that endpoint returns a 404 error.
In this step, you'll build a serverless API route that receives the user's project idea, sends it to Google Gemini with a structured JSON schema, and returns a complete project plan.
In this step, get ready to:
- Create a Next.js Route Handler at app/api/generate/route.js.
- Define a JSON schema that tells Gemini exactly what structure to return.
- Call the Gemini API and return the structured project plan.
Create the API route file
Next.js uses file-based routing for API endpoints. A file named route.js inside the app/api/ directory automatically becomes a serverless endpoint at the matching URL path.
- In VS Code, right-click on the app folder in the Explorer sidebar.
- Click New Folder and name it api.
- Right-click the new api folder, click New Folder again, and name it generate.
- Right-click the generate folder, click New File, and name it route.js.
Why this file path?
The folder structure app/api/generate/route.js maps directly to the URL /api/generate. This is the same path your handleSubmit function in page.js is already calling with fetch.
Define the JSON schema and prompt
Gemini can return raw text, but you need structured data your app can work with. By providing a JSON schema, you force Gemini to return its response in an exact shape that matches your frontend's expectations.
- In app/api/generate/route.js, add the following code that imports the Gemini SDK, initializes it with your API key, and defines the response schema:
import { GoogleGenAI } from "@google/genai";
// Initialize the Gemini client with your API key from .env.local
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
// Define the exact shape of the JSON response Gemini must return
const responseSchema = {
type: "object",
properties: {
project_name: { type: "string" },
overview: { type: "string" },
tools: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
purpose: { type: "string" },
free_tier: { type: "string" },
},
required: ["name", "purpose", "free_tier"],
},
},
preparation_steps: {
type: "array",
items: { type: "string" },
},
development_steps: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
details: { type: "string" },
priority: { type: "string" },
},
required: ["title", "description", "details", "priority"],
},
},
},
required: ["project_name", "overview", "tools", "preparation_steps", "development_steps"],
};
What does this code do?
GoogleGenAI is the official SDK class for calling the Gemini API. You initialize it once at the module level so every request reuses the same client.
The responseSchema object describes the exact JSON structure Gemini must follow. It includes a project name, overview, a tools array (each tool has a name, purpose, and free tier info), preparation steps as strings, and development steps as objects with title, description, details, and priority.
- Save app/api/generate/route.js.
- You won't see a change in the browser yet. The next chunk adds the handler that actually processes requests.
Seeing a red squiggly under the import?
Make sure you ran npm install @google/genai in Step 1. If you skipped it, run it now in your terminal from the workspace-forge folder.
Help me fix this import error
Call Gemini and return structured JSON
The schema defines the shape, but you still need a handler function that receives the user's prompt, sends it to Gemini with instructions, and returns the result.
- Below the responseSchema object in app/api/generate/route.js, add the POST handler function:
export async function POST(request) {
try {
const { prompt } = await request.json();
if (!prompt) {
return Response.json({ error: "Prompt is required" }, { status: 400 });
}
// Instruct Gemini to act as a senior project planner
const systemPrompt = `You are a senior project planner and technical architect. Given a project idea, generate a comprehensive, actionable development guide. Include:
- A clear project name and overview
- All tools, APIs, and integrations needed (with free tier info)
- Pre-development preparation steps (account setup, environment config, workspace tips)
- Detailed development steps broken into manageable tasks with priority levels (High, Medium, Low)
- Each step should include specific implementation details, not just vague descriptions
Be thorough but practical. Focus on what a developer actually needs to start building immediately.`;
// Call Gemini with the schema to enforce structured JSON output
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `${systemPrompt}\n\nProject idea: ${prompt}`,
config: {
responseMimeType: "application/json",
responseSchema: responseSchema,
},
});
// Parse the JSON string from Gemini's response
const projectPlan = JSON.parse(response.text);
return Response.json(projectPlan);
} catch (err) {
console.error("Generation error:", err);
return Response.json({ error: "Failed to generate project plan" }, { status: 500 });
}
}
What does this code do?
The POST export tells Next.js this route handles POST requests. It extracts the prompt from the request body and validates it.
The systemPrompt gives Gemini its role and detailed instructions about what to include in the project plan.
Setting config.responseMimeType to "application/json" combined with responseSchema forces Gemini to return data matching your exact schema instead of freeform text. The try/catch block returns a 500 error if anything goes wrong, preventing your frontend from hanging.
- Save app/api/generate/route.js.
- Open your browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
- Test the route by pasting this into the console and pressing Enter:
fetch('/api/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: 'Build a todo app with React' }) }).then(r => r.json()).then(console.log)
- Wait 5-10 seconds. You should see a JSON object logged in the console with fields like project_name, overview, tools, preparation_steps, and development_steps.
Seeing a 500 error or empty response?
Check that your GEMINI_API_KEY in .env.local is correct and has no extra spaces. You may also need to restart the dev server after adding environment variables.
Stop the server with Ctrl+C in the terminal, then run npm run dev again.
Help me debug this 500 error
✔️ Awesome, I've got everything!
Great. Make sure you've saved app/api/generate/route.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete app/api/generate/route.js file:
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const responseSchema = {
type: "object",
properties: {
project_name: { type: "string" },
overview: { type: "string" },
tools: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
purpose: { type: "string" },
free_tier: { type: "string" },
},
required: ["name", "purpose", "free_tier"],
},
},
preparation_steps: {
type: "array",
items: { type: "string" },
},
development_steps: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
details: { type: "string" },
priority: { type: "string" },
},
required: ["title", "description", "details", "priority"],
},
},
},
required: ["project_name", "overview", "tools", "preparation_steps", "development_steps"],
};
export async function POST(request) {
try {
const { prompt } = await request.json();
if (!prompt) {
return Response.json({ error: "Prompt is required" }, { status: 400 });
}
const systemPrompt = `You are a senior project planner and technical architect. Given a project idea, generate a comprehensive, actionable development guide. Include:
- A clear project name and overview
- All tools, APIs, and integrations needed (with free tier info)
- Pre-development preparation steps (account setup, environment config, workspace tips)
- Detailed development steps broken into manageable tasks with priority levels (High, Medium, Low)
- Each step should include specific implementation details, not just vague descriptions
Be thorough but practical. Focus on what a developer actually needs to start building immediately.`;
const response = await ai.models.generateContent({
model: "gemini-2.5-flash",
contents: `${systemPrompt}\n\nProject idea: ${prompt}`,
config: {
responseMimeType: "application/json",
responseSchema: responseSchema,
},
});
const projectPlan = JSON.parse(response.text);
return Response.json(projectPlan);
} catch (err) {
console.error("Generation error:", err);
return Response.json({ error: "Failed to generate project plan" }, { status: 500 });
}
}
Your AI backend is now generating structured project plans on demand. Next up, you'll build the second API route that takes this plan and turns it into a real Notion workspace.
Build the Notion Workspace Creator
Your AI route generates a beautifully structured project plan, but right now it has nowhere to go. The learner sees JSON in the browser and then has to manually copy everything into their workspace.
In this step, you will build a second API route that takes that structured plan and creates a real Notion workspace from it. The workspace includes a guide page with sections and an inline database for tracking progress.
In this step, get ready to:
- Create a new API route that connects to the Notion API.
- Build a multi-section guide page with headings, lists, and a divider.
- Generate an inline Steps database populated with your AI-generated tasks.
Create the Notion API route file
The Notion API lets you create pages, databases, and content blocks programmatically. Your route will receive the structured JSON plan from Gemini and translate it into Notion's block format.
- Create a new folder and file at app/api/create-workspace/route.js by right-clicking the api folder in the Explorer sidebar and selecting New Folder.
- Name the folder create-workspace, then create a file called route.js inside it.
- Paste the following code into app/api/create-workspace/route.js to set up the route and build the guide page:
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_API_KEY });
export async function POST(request) {
try {
const { projectPlan } = await request.json();
if (!projectPlan) {
return Response.json({ error: "Project plan is required" }, { status: 400 });
}
const parentPageId = process.env.NOTION_PAGE_ID;
// Create the main project guide page
const page = await notion.pages.create({
parent: { type: "page_id", page_id: parentPageId },
icon: { type: "emoji", emoji: "🚀" },
properties: {
title: {
title: [{ type: "text", text: { content: projectPlan.project_name } }],
},
},
children: [
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Overview" } }],
},
},
{
object: "block",
type: "paragraph",
paragraph: {
rich_text: [{ type: "text", text: { content: projectPlan.overview } }],
},
},
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Tools & Integrations" } }],
},
},
...projectPlan.tools.map((tool) => ({
object: "block",
type: "bulleted_list_item",
bulleted_list_item: {
rich_text: [
{ type: "text", text: { content: tool.name }, annotations: { bold: true } },
{ type: "text", text: { content: ` - ${tool.purpose} (${tool.free_tier})` } },
],
},
})),
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Pre-Development Preparation" } }],
},
},
...projectPlan.preparation_steps.map((step) => ({
object: "block",
type: "numbered_list_item",
numbered_list_item: {
rich_text: [{ type: "text", text: { content: step } }],
},
})),
{
object: "block",
type: "divider",
divider: {},
},
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Development Steps" } }],
},
},
],
});
What does this code do?
This first section sets up your Notion route and creates the main guide page using notion.pages.create.
- The Client from @notionhq/client connects to the Notion API using your integration token stored in NOTION_API_KEY.
- The parentPageId tells Notion where to place the new page (under the "WorkspaceForge Projects" page you connected to your integration in Step 1).
- The children array defines the page content as structured blocks: headings, paragraphs, bulleted lists for tools, numbered lists for preparation steps, and a divider.
- The spread operators (...projectPlan.tools.map(...)) dynamically generate one Notion block per item in the AI output.
You won't see a change yet. The next chunk adds the database and closes the function.
Create the Steps database and populate it
Notion databases let you track tasks with properties like status and priority. Your route will create an inline database directly on the guide page, then fill it with each development step from the AI plan.
- In app/api/create-workspace/route.js, paste the following code directly below the }); that closes the notion.pages.create call:
// Create the Steps database as inline on the page
const database = await notion.databases.create({
parent: { type: "page_id", page_id: page.id },
title: [{ type: "text", text: { content: "Project Steps" } }],
is_inline: true,
initial_data_source: {
properties: {
Step: { title: {} },
Status: {
status: {
options: [
{ name: "Not started", color: "default" },
{ name: "In progress", color: "blue" },
{ name: "Done", color: "green" },
],
},
},
Priority: {
select: {
options: [
{ name: "High", color: "red" },
{ name: "Medium", color: "yellow" },
{ name: "Low", color: "green" },
],
},
},
Description: { rich_text: {} },
},
},
});
const dataSourceId = database.data_sources[0].id;
// Add each development step as a row in the database
for (const step of projectPlan.development_steps) {
await notion.pages.create({
parent: { type: "data_source_id", data_source_id: dataSourceId },
properties: {
Step: {
title: [{ type: "text", text: { content: step.title } }],
},
Status: {
status: { name: "Not started" },
},
Priority: {
select: { name: step.priority },
},
Description: {
rich_text: [{ type: "text", text: { content: step.description } }],
},
},
});
}
return Response.json({ url: page.url });
} catch (err) {
console.error("Notion creation error:", err);
return Response.json({ error: "Failed to create workspace" }, { status: 500 });
}
}
What does this code do?
This section creates the tracking database and fills it with tasks from the AI plan.
- notion.databases.create builds an inline database (it appears embedded on the page, not as a separate page). The initial_data_source.properties object defines the columns: Step (title), Status (with Kanban-friendly options), Priority (select), and Description (rich text).
- database.data_sources[0].id retrieves the ID needed to add rows to the database.
- The for loop creates one database row per development step. Each row gets a title, status set to "Not started", the AI-assigned priority, and a description.
- return Response.json({ url: page.url }) sends the new Notion page URL back to the frontend so the user can click through to their workspace.
Why use initial_data_source instead of properties directly?
The Notion API version 2026-03-11 introduced initial_data_source as the way to define database schemas when creating a database. Rows are then added using data_source_id as the parent type, which replaced the older database_id pattern.
- Save app/api/create-workspace/route.js.
Seeing a syntax error in your editor?
- Make sure the second code block was pasted directly below the }); that closes notion.pages.create. There should be no duplicate closing braces.
- Check that the file has exactly one export async function POST declaration and that the try/catch block wraps everything.
- Use the "double check the full code" tab below to compare your file against the complete version.
help me fix a syntax error in my create-workspace route.js file
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/api/create-workspace/route.js before moving on.
ⓧ I'd like to double check the full code
import { Client } from "@notionhq/client";
const notion = new Client({ auth: process.env.NOTION_API_KEY });
export async function POST(request) {
try {
const { projectPlan } = await request.json();
if (!projectPlan) {
return Response.json({ error: "Project plan is required" }, { status: 400 });
}
const parentPageId = process.env.NOTION_PAGE_ID;
// Create the main project guide page
const page = await notion.pages.create({
parent: { type: "page_id", page_id: parentPageId },
icon: { type: "emoji", emoji: "🚀" },
properties: {
title: {
title: [{ type: "text", text: { content: projectPlan.project_name } }],
},
},
children: [
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Overview" } }],
},
},
{
object: "block",
type: "paragraph",
paragraph: {
rich_text: [{ type: "text", text: { content: projectPlan.overview } }],
},
},
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Tools & Integrations" } }],
},
},
...projectPlan.tools.map((tool) => ({
object: "block",
type: "bulleted_list_item",
bulleted_list_item: {
rich_text: [
{ type: "text", text: { content: tool.name }, annotations: { bold: true } },
{ type: "text", text: { content: ` - ${tool.purpose} (${tool.free_tier})` } },
],
},
})),
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Pre-Development Preparation" } }],
},
},
...projectPlan.preparation_steps.map((step) => ({
object: "block",
type: "numbered_list_item",
numbered_list_item: {
rich_text: [{ type: "text", text: { content: step } }],
},
})),
{
object: "block",
type: "divider",
divider: {},
},
{
object: "block",
type: "heading_2",
heading_2: {
rich_text: [{ type: "text", text: { content: "Development Steps" } }],
},
},
],
});
// Create the Steps database as inline on the page
const database = await notion.databases.create({
parent: { type: "page_id", page_id: page.id },
title: [{ type: "text", text: { content: "Project Steps" } }],
is_inline: true,
initial_data_source: {
properties: {
Step: { title: {} },
Status: {
status: {
options: [
{ name: "Not started", color: "default" },
{ name: "In progress", color: "blue" },
{ name: "Done", color: "green" },
],
},
},
Priority: {
select: {
options: [
{ name: "High", color: "red" },
{ name: "Medium", color: "yellow" },
{ name: "Low", color: "green" },
],
},
},
Description: { rich_text: {} },
},
},
});
const dataSourceId = database.data_sources[0].id;
// Add each development step as a row in the database
for (const step of projectPlan.development_steps) {
await notion.pages.create({
parent: { type: "data_source_id", data_source_id: dataSourceId },
properties: {
Step: {
title: [{ type: "text", text: { content: step.title } }],
},
Status: {
status: { name: "Not started" },
},
Priority: {
select: { name: step.priority },
},
Description: {
rich_text: [{ type: "text", text: { content: step.description } }],
},
},
});
}
return Response.json({ url: page.url });
} catch (err) {
console.error("Notion creation error:", err);
return Response.json({ error: "Failed to create workspace" }, { status: 500 });
}
}
Test the full flow from the frontend
Both API routes are now connected. Your frontend already calls /api/generate and then /api/create-workspace in sequence, so you can test the entire pipeline from the browser.
- Return to your browser at http://localhost:3000.
- Type a project idea into the text area, for example: Build a recipe sharing app with user authentication and image uploads.
- Click Generate Workspace.
- Wait for the button text to change from "Generating project plan..." to "Creating Notion workspace..." and finally show the green success message with an Open in Notion link.
- Click the Open in Notion link to verify your workspace was created.
You should see a Notion page with an Overview section, a bulleted Tools list, numbered Preparation steps, and a "Project Steps" database with rows for each development task.
What should I see in Notion?
The generated page has a rocket emoji icon and a title matching the AI-generated project name. Below the title, you will see four sections: Overview (paragraph), Tools & Integrations (bulleted list with bold tool names), Pre-Development Preparation (numbered list), and Development Steps (heading above the inline database).
The Project Steps database appears as an embedded table with four columns: Step, Status, Priority, and Description. Each row corresponds to one development step from the AI plan, with Status set to "Not started".
Seeing "Failed to create Notion workspace"?
- Verify your NOTION_PAGE_ID in .env.local is the 32-character string from your Notion page URL (no dashes). Open your "WorkspaceForge Projects" page in Notion and copy the ID from the URL bar.
- Make sure you connected the WorkspaceForge integration to that page. Open the page in Notion, click the three-dot menu, select Connections, and confirm your integration appears.
- Check your terminal for the specific error. If you see a 401 status, your NOTION_API_KEY may be incorrect. If you see a 404 status, the page ID is wrong or the integration is not connected.
- Restart your dev server after any .env.local changes by pressing Ctrl+C in the terminal and running npm run dev again.
help me debug why my Notion workspace creation is failing in WorkspaceForge
Notion API has a rate limit
The Notion API allows an average of 3 requests per second. If your generated plan has many steps, the for loop that creates rows may briefly exceed this. The @notionhq/client SDK handles rate limiting automatically with exponential backoff, so the requests will complete even if some are briefly delayed.
Your app now transforms a one-sentence idea into a full project workspace. Next up, you will deploy everything to Vercel so anyone can access your tool from a live URL.
Deploy to Vercel
Your app works perfectly on your local machine. The next step is making it accessible to anyone with an internet connection.
Vercel is a hosting platform built by the same team behind Next.js. It takes your code from a GitHub repository, builds it, and serves it at a public URL. Every future push to your repository triggers a fresh deployment automatically.
In this step, get ready to:
- Push your code to a GitHub repository.
- Deploy the app to Vercel with environment variables configured.
- Test the full flow on your live public URL.
Push to GitHub and connect Vercel
Before Vercel can deploy your app, it needs access to your code through a Git repository on GitHub. You will create a local Git repository, push it to GitHub, then import it into Vercel.
- Open a new terminal panel in your editor by pressing Ctrl+` (macOS and Windows). Your dev server is still running in the first terminal, so you need a fresh one for Git commands.
- Initialize a Git repository and create your first commit by running these commands:
git init
git add .
git commit -m "Initial commit"
What do these commands do?
- git init creates a new Git repository in your project folder.
- git add . stages all your project files for the commit.
- git commit -m "Initial commit" saves a snapshot of your code with a descriptive message.
Why is .env.local safe to commit?
It is not committed. Next.js automatically includes .env.local in the project's .gitignore file. Your API keys stay on your machine and never reach GitHub.
- Open your browser and go to github.com/new to create a new repository.
- Enter workspace-forge as the repository name.
- Leave all checkboxes unchecked (no README, no .gitignore, no license). The repository must be empty for the push to succeed.
- Click Create repository.
- GitHub shows a Quick Setup page with your repository URL. Copy the HTTPS URL shown at the top of the page.
- Back in your terminal, connect your local repo to GitHub and push your code by running these commands:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/workspace-forge.git"]]
git branch -M main
git push -u origin main
What do these commands do?
- git remote add origin links your local repository to the GitHub repository you just created.
- git branch -M main renames your default branch to main (the standard name Vercel looks for).
- git push -u origin main uploads all your committed code to GitHub.
- Refresh your GitHub repository page. You should see all your project files listed.
Push rejected or authentication failed?
- If you see an authentication error, make sure you are signed into GitHub in your terminal. GitHub requires a personal access token or SSH key for push access.
- If the push is rejected because the remote contains files you did not pull, you likely initialized the GitHub repo with a README or .gitignore. Delete the GitHub repo, recreate it with all checkboxes unchecked, and try the push again.
Help me push my code to GitHub
- Now sign up for Vercel. Open vercel.com in your browser.
- Click Sign Up and choose Continue with GitHub. This connects your GitHub account so Vercel can access your repositories.
- Authorize the Vercel app when GitHub prompts you.
What is Vercel's Hobby plan?
Vercel's Hobby plan is free forever with no credit card required. It includes 1,000,000 serverless function invocations per month, which is far more than this project needs.
The Hobby plan is restricted to non-commercial, personal use. This is perfect for a portfolio or learning project.
Configure environment variables and deploy
Vercel needs your API keys to run the serverless functions in production. Since .env.local was never pushed to GitHub, you will add the same three environment variables directly in the Vercel dashboard.
- In the Vercel dashboard, click the Add New... button and select Project.
- You will see a list of your GitHub repositories. Find workspace-forge and click Import next to it.
- Vercel detects that your project uses Next.js and pre-fills the framework settings. Leave the defaults as they are.
- Expand the Environment Variables section on the configuration page.
- Add your first variable: type GEMINI_API_KEY in the name field and paste your Gemini API key in the value field.
- Add the second variable: NOTION_API_KEY with your Notion integration secret as the value.
- Add the third variable: NOTION_PAGE_ID with your Notion page ID as the value.
- Click Deploy.
Vercel builds and deploys your app automatically. This takes about 30-60 seconds. Once the build finishes, Vercel shows a success page with your live URL.
- Copy your live Vercel URL from the success page. It looks like https://workspace-forge-xxx.vercel.app.
- Open that URL in a new browser tab.
- Enter a project idea in the text area (for example, "Build a REST API with Express.js") and click Generate Workspace.
- Wait for the green success message to appear with a link to your new Notion workspace.
- Click the Open in Notion link and confirm the workspace was created with an Overview section, Tools list, Preparation steps, and a Steps database.
Seeing a 500 error or build failure?
- Check that all three environment variables are spelled exactly as GEMINI_API_KEY, NOTION_API_KEY, and NOTION_PAGE_ID in Vercel's settings. A typo means the variable is undefined at runtime.
- Make sure your Notion integration still has access to the parent page. If you removed the connection, the API call will fail with a 403 error.
- Open your Vercel project dashboard and click Deployments to view the build logs for specific error messages.
Help me debug my Vercel deployment
✔️ Awesome, I've got everything!
Great. Your app is live on the internet and generating Notion workspaces from a public URL.
ⓧ I'd like to double check my environment variables
In the Vercel dashboard, click your project name, then go to Settings in the top navigation. Select Environment Variables from the left sidebar. You should see these three variables listed:
- GEMINI_API_KEY
- NOTION_API_KEY
- NOTION_PAGE_ID
If any are missing or misspelled, add or update them, then click Deployments and redeploy the latest commit. Environment variable changes only take effect on new deployments.
Secret mission
Add a Code Snippets Database
Your generated workspaces are already impressive with task tracking. But what if they also included copy-pasteable starter code for each step? In this mission, you'll extend both the AI schema and the Notion workspace to generate a second database filled with language-tagged code snippets.
Clean Up Your Resources
Clean Up Your Resources
This project uses entirely free services. The Gemini API free tier, Notion free plan, and Vercel Hobby plan all cost nothing to maintain.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local workspace-forge project folder on your Desktop.
- Vercel project deployment (free Hobby plan).
- GitHub repository.
- Google AI Studio API key (Gemini API).
- Notion integration (WorkspaceForge) and generated workspace pages.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with WorkspaceForge.
- Your Vercel deployment stays live at its URL. The Hobby plan is free with no expiration.
- Your Gemini API key remains active on the free tier. No charges accrue.
- Your Notion integration stays connected and the generated pages remain in your workspace.
- Your local project files remain on your Desktop.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files, deployment, and accounts stay intact.
- Stop the local dev server by pressing Ctrl+C in the terminal where npm run dev is running.
- Close VS Code or Cursor.
- Your Vercel deployment and Notion integration will stay active at no cost until you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Delete your local project folder by running this command (macOS/Linux):
- Delete your Vercel project by opening the Vercel dashboard, selecting your workspace-forge project, clicking Settings in the sidebar, scrolling to the bottom of the General page, clicking Delete in the Delete Project section, then entering the project name to confirm.
- Delete your GitHub repository by opening the repo on GitHub, clicking Settings, scrolling to the Danger Zone at the bottom, and clicking Delete this repository.
- Delete your Gemini API key by opening the Google Cloud API Credentials page, finding the key you created, clicking the Actions icon, and selecting Delete API key.
- Delete your Notion integration by going to notion.so/profile/integrations, finding the WorkspaceForge integration, and deleting it from the My Integrations dashboard.
- Delete generated workspace pages in Notion by opening your WorkspaceForge Projects page, selecting the generated project pages, and moving them to trash.
rm -rf ~/Desktop/workspace-forge
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\workspace-forge
Nice Work!
Nice Work!
You just built and deployed WorkspaceForge, a full-stack web app that turns a single sentence into a fully structured Notion workspace. That is a complete AI-to-productivity-tool pipeline, live on the internet for anyone to use.
Here's what you learned:
- Built a full-stack Next.js web app with serverless API routes that integrate two external APIs into a single user-facing product.
- Used Gemini 2.5 Flash with a JSON response schema to generate structured project plans from natural language prompts.
- Created Notion workspaces programmatically using the Notion API, including inline databases with status tracking, and deployed the app to Vercel for free.
- Secret Mission: Extended the generated workspace with an AI-powered Code Snippets database containing starter code for each project step, tagged by language and step number.
Ready to quiz yourself?