Build an AI Textbook Lesson Generator

Build a Next.js app that turns PDFs into grounded interactive lessons.

Introduction

30 Second Summary

A useful textbook chapter can still leave a learner wondering how to apply it. Ideas become easier to remember when reading leads to activities, questions, and visible progress.

In this project, you will build Source-to-Project, a local full-stack website that turns one permitted PDF and a chosen topic into a three-part interactive lesson. Gemini File Search grounds the generated content in the uploaded document.

What You'll Build

The finished app displays three source-grounded lesson cards with practical activities, hidden checkpoint answers, page references, and a progress bar that returns after a browser refresh.

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

  • A source-grounded lesson generator that turns a permitted PDF topic into a focused objective and three guided sections.
  • Clear source information and page references that show which document informed the lesson when page details are available.
  • Interactive answer reveals and saved progress that let learners work through checkpoints and resume after refreshing the browser.
  • A Secret Mission: Add a completion celebration at 100 percent and a start-over flow that clears the saved lesson.

Are there any prerequisites?

The project requires a Google account, internet access, VS Code, and one public-domain, self-authored, or otherwise permitted PDF under the 10 MB upload limit.

The main path stays free within the Gemini API Free Tier limits. Free Tier submissions may be used to improve Google products, which makes non-sensitive source material essential.

Before We Start

This step locks in a safe source and a clear learning goal before any hands-on work begins. Your source is one public-domain, self-authored, or otherwise permitted PDF smaller than 10 MB that contains your chosen topic.

Set Up the Local Next.js App

Your PDF choice gives the project something safe to teach from. The website still needs a compatible local runtime before it can display a lesson.

In this step, you will install Node.js 24.20.0 LTS. You will then create a minimal Next.js app in VS Code and run it locally.

In this step, get ready to:
  • Install Node.js 24.20.0 LTS.
  • Create the local Next.js project shell.
  • Start the development server and load the starter page.
Install and verify Node.js

Node.js runs the local development server that powers your Next.js app. The required release also supports every package in this project.

  • Press Cmd+Space to open Spotlight on your Mac.
  • Type VS Code into Spotlight.
  • Press Enter to open VS Code.
  • Select Terminal from the menu bar.
  • Select New Terminal.
  • Check the installed Node.js version by running:
node --version

What does this command check?

The command asks Node.js to print its installed version. The result tells you whether the required runtime is already available.

✔️ I see version 24.20.0 or higher

Your Node.js runtime meets the project requirement. Confirm that the included package manager is available next.

  • Check the npm installation by running:
npm --version

What should I see?

You should see an npm version number in the terminal. This confirms that the package manager is ready.

ⓧ I see an older version

Your current Node.js release is below the project pin. Install the compatible LTS package before creating the app.

  • Download node-v24.20.0.pkg from the official Node.js website.
  • Open node-v24.20.0.pkg from your Downloads folder.
  • Complete the installer using its standard options.
  • Close the existing VS Code terminal after the installation finishes.
  • Select Terminal from the VS Code menu bar.
  • Select New Terminal.
  • Verify both installations by running:
node --version
npm --version

What should I see?

The first line should show v24.20.0. The second line should show an npm version number.

ⓧ Command not found

Node.js is not installed yet. The official macOS package installs the runtime plus npm.

  • Download node-v24.20.0.pkg from the official Node.js website.
  • Open node-v24.20.0.pkg from your Downloads folder.
  • Complete the installer using its standard options.
  • Close the existing VS Code terminal after the installation finishes.
  • Select Terminal from the VS Code menu bar.
  • Select New Terminal.
  • Verify both installations by running:
node --version
npm --version

What should I see?

The first line should show v24.20.0. The second line should show an npm version number.

Still seeing the old version?

Close every open VS Code terminal after installing Node.js. A new terminal reloads the commands available on your Mac.

Restart VS Code if a fresh terminal still reports the previous version.

Ask for help with fixing the Node.js version shown in my VS Code terminal.

That is the runtime sorted. Your Mac can now run the exact Next.js release used by this project.

Create the project folder and install dependencies

A VS Code workspace keeps every project file under one named folder. The package.json file records the exact framework versions needed to reproduce the app.

VS Code may display a Workspace Trust prompt after opening the folder. This folder is yours, so you can trust its contents.

  • Select File from the VS Code menu bar.
  • Select Open Folder....
  • Select your Desktop in the folder picker.
  • Select New Folder.
  • Enter source-to-project as the folder name.
  • Select Open to open the new folder in VS Code.
  • Select Yes, I trust the authors if the Workspace Trust prompt appears.

Why open the whole folder?

VS Code treats the opened source-to-project folder as a workspace. Its Explorer and integrated terminal now stay focused on this app.

  • Select the Explorer view in the left Activity Bar.
  • Select the New File... button.
  • Enter package.json as the file name.
  • Configure the project packages by pasting this file:
{
  "name": "source-to-project",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@google/genai": "2.21.0",
    "next": "16.3.4",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

What does this file configure?

  • The scripts object defines the development, build, and start commands.
  • The dependencies object pins Next.js to 16.3.4.
  • React and React DOM are pinned to 19.2.8.
  • The Google Gen AI SDK is pinned to 2.21.0 for the server work later in the project.
  • Save package.json.
  • Confirm package.json appears under source-to-project in the Explorer.
  • Select Terminal from the menu bar.
  • Select New Terminal.

The install may take a few minutes while npm downloads the pinned packages. Quiet pauses during the first install are normal.

  • Install the project dependencies by running:
npm install

What does this command create?

npm reads package.json and downloads its dependencies. It stores the installed package tree in node_modules.

  • Wait for the terminal prompt to return.
  • Confirm node_modules appears in the VS Code Explorer.

Did the dependency install fail?

Check that the terminal path ends with source-to-project. The command needs to run beside package.json.

Confirm the terminal still reports Node.js v24.20.0 before retrying the install.

Ask for help with diagnosing my npm install failure.

Build and run the starter page

A Next.js App Router project needs a root layout plus a page component. These two server-rendered files create the smallest browser result for this project.

  • Select the New Folder button in the Explorer.
  • Enter app as the folder name.
  • Select the new app folder.
  • Select the New File... button.
  • Enter layout.js as the file name.
  • Define the document shell by pasting this file:
export const metadata = {
  title: "Source-to-Project",
  description: "Turn a source PDF into an interactive lesson.",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What does this layout do?

  • The metadata object gives the local page its title and description.
  • The RootLayout() component wraps every page in the required HTML document structure.
  • The children value marks where each page renders.
  • Save app/layout.js.
  • Select the app folder in the Explorer.
  • Select the New File... button.
  • Enter page.js as the file name.
  • Create the temporary starter page by pasting:
export default function Home() {
  return <main>Source-to-Project is running.</main>;
}

What does this page do?

The Home() component returns the content shown at the app's main address. Its starter message gives you a clear runtime check before the lesson interface replaces it.

  • Save app/page.js.

Before you start the server, what message do you expect the browser to display?

The development command keeps control of this terminal while the server runs. Leave the terminal open so the local website stays available.

  • Start the Next.js development server by running:
npm run dev

What does this command start?

npm runs the dev script from package.json. Next.js starts a local server that updates as you save project files.

  • Open a browser window.
  • Enter http://localhost:3000 in the address bar.

You should see “Source-to-Project is running.” in the browser. That first local page proves the project can render through Next.js.

Can’t load the starter page?

Keep the terminal running while you visit the local address. Closing the terminal stops the development server.

Check the terminal for a message that identifies a syntax problem in app/layout.js or app/page.js.

Ask for help with fixing my local Next.js starter page.

The starter works. A small global stylesheet now gives every later screen the same color foundation.

  • Return to the app folder in the VS Code Explorer.
  • Select the New File... button.
  • Enter globals.css as the file name.
  • Add the global color foundation by pasting:
:root {
  color-scheme: light;
  --ink: #172033;
  --muted: #657089;
  --paper: #ffffff;
  --canvas: #f4f7fb;
  --accent: #6558d3;
  --accent-dark: #4b3fc0;
  --accent-soft: #ece9ff;
  --success: #237a57;
  --success-soft: #e4f6ee;
  --danger: #a93434;
  --border: #dce2ec;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
}

What does this stylesheet establish?

The custom properties hold the colors used across the finished interface. The body rule applies the page background plus the default text styling.

The universal sizing rule keeps future card dimensions predictable. This becomes important when the lesson interface grows.

  • Save app/globals.css.
  • Open app/layout.js from the Explorer.
  • Find the current first line:
export const metadata = {

Why find this line?

This line marks the current start of app/layout.js. The stylesheet import belongs directly above it.

  • Add the stylesheet import above the metadata declaration so the file begins like this:
import "./globals.css";

export const metadata = {

What does the import change?

The import loads app/globals.css through the root layout. Every page now receives those global styles.

  • Save app/layout.js.
  • Return to the browser.
  • Refresh http://localhost:3000.

You should still see “Source-to-Project is running.” on a pale background. This proves the stylesheet loads without breaking the page.

Did the page lose its styles?

Confirm globals.css sits inside the app folder. The import path assumes both files share that folder.

Check that import "./globals.css"; is the first line of app/layout.js.

Ask for help with fixing my Next.js global stylesheet import.

Generated packages and local environment files do not belong in source control. A .gitignore file records those exclusions before later Git work begins.

  • Select the source-to-project folder in the Explorer.
  • Select the New File... button.
  • Enter .gitignore as the file name.
  • Define the ignored project files by pasting:
node_modules/
.next/
.env*
!.env.local.example
.DS_Store

What does this file protect?

The file excludes installed packages plus generated Next.js output. It also keeps local environment files out of future commits.

The exception preserves .env.local.example when that safe template is added later.

  • Save .gitignore.
  • Confirm .gitignore appears beside package.json in the Explorer.

Can’t see the .gitignore file?

Confirm the filename begins with a dot. A name such as gitignore creates a different file.

Ask for help with finding my .gitignore file in VS Code.

Use this comparison to catch a missing line before the final browser check.

✔️ Awesome, I've got everything!

Great. Save every open file before checking the browser one more time.

ⓧ I'd like to double check the full code

Check package.json
{
  "name": "source-to-project",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@google/genai": "2.21.0",
    "next": "16.3.4",
    "react": "19.2.8",
    "react-dom": "19.2.8"
  }
}

What should match?

Compare the project name, scripts, and four dependency versions with your saved package.json file.

Check .gitignore
node_modules/
.next/
.env*
!.env.local.example
.DS_Store

What should match?

Your .gitignore file should contain these five patterns in this order.

Check app/layout.js
import "./globals.css";

export const metadata = {
  title: "Source-to-Project",
  description: "Turn a source PDF into an interactive lesson.",
};

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  );
}

What should match?

Confirm the stylesheet import sits above the metadata. The layout should render children inside the page body.

Check app/page.js
export default function Home() {
  return <main>Source-to-Project is running.</main>;
}

What should match?

The temporary page should render the exact starter message used by the browser check.

Check app/globals.css
:root {
  color-scheme: light;
  --ink: #172033;
  --muted: #657089;
  --paper: #ffffff;
  --canvas: #f4f7fb;
  --accent: #6558d3;
  --accent-dark: #4b3fc0;
  --accent-soft: #ece9ff;
  --success: #237a57;
  --success-soft: #e4f6ee;
  --danger: #a93434;
  --border: #dce2ec;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
    "Segoe UI", sans-serif;
}

What should match?

Compare the color variables plus the universal sizing rule. Confirm the body uses the canvas background and ink text color.

Before the final refresh, do you expect the development server to preserve the starter message after every file change?

  • Confirm the terminal still shows the running development server.
  • Refresh http://localhost:3000 in your browser.

You should see “Source-to-Project is running.” on the page. Your local Next.js shell is ready for the lesson experience.

Your full-stack shell now runs locally with pinned dependencies. Next up, you will turn this starter message into the first visible lesson experience.

Build the Mock Lesson Experience

Your local Next.js shell is running. It now needs a lesson experience that turns a document choice into something visible and testable.

A fixed mock lesson lets you prove the learner flow before retrieval exists. Testing a second document then reveals whether the selected PDF affects the result.

In this step, get ready to:
  • Build a PDF form with browser-managed loading and error states.
  • Render a fixed three-card lesson with source information.
  • Style the experience and test whether a different PDF changes the lesson.
Build the PDF form and mock lesson

A Client Component can respond to file selections and form submissions inside the browser. React state keeps the selected file, topic, lesson, loading status, and error message synchronized with the interface.

  • Switch back to app/page.js in VS Code.
  • Place this fixed lesson data above the existing starter component:
"use client";

import { useState } from "react";

// Keep one fixed lesson so the interface can be tested before retrieval exists.
const mockLesson = {
  title: "How Plants Make Food",
  overview: "Explore how plants capture light and turn it into stored energy.",
  objective: "Explain the main stages of photosynthesis through three short activities.",
  source: { fileName: "Mock botany textbook.pdf", pages: [12, 14] },
  steps: [
    { title: "Find the ingredients", explanation: "Plants use light, water, and carbon dioxide to begin making food.", activity: "List where a plant gets each ingredient.", checkpoint: "Which ingredient enters through the leaves?", answer: "Carbon dioxide enters through openings in the leaves." },
    { title: "Follow the energy", explanation: "Chlorophyll captures light energy inside leaf cells.", activity: "Sketch a leaf and label where light is captured.", checkpoint: "What captures the light energy?", answer: "Chlorophyll captures the light energy." },
    { title: "Describe the result", explanation: "The captured energy helps the plant produce glucose and release oxygen.", activity: "Write one sentence connecting glucose to plant growth.", checkpoint: "Which useful gas is released?", answer: "Oxygen is released." },
  ],
};

What does this code do?

  • The mockLesson object gives the interface a predictable result for every submission.
  • Each item in steps contains the explanation, activity, checkpoint, and answer needed for one lesson card.
  • The fixed source filename and page numbers let you test how source information looks before document retrieval exists.
  • Select the existing starter component beneath mockLesson.
  • Replace that component with this interactive page:
export default function Home() {
  // Track the form and mock result inside the browser.
  const [file, setFile] = useState(null); const [topic, setTopic] = useState("");
  const [lesson, setLesson] = useState(null); const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  async function handleSubmit(event) {
    event.preventDefault(); setError("");
    if (!file || !topic.trim()) { setError("Choose a PDF and enter a topic first."); return; }
    setLoading(true); setLesson(null);
    // Simulate generation before displaying the fixed lesson.
    await new Promise((resolve) => setTimeout(resolve, 600));
    setLesson(mockLesson); setLoading(false);
  }

  return (
    <main>
      <section className="hero"><p className="eyebrow">Source-to-Project</p><h1>Turn a textbook into an interactive lesson</h1><p className="heroCopy">Upload one permitted PDF, choose a topic inside it, and generate a short project-style learning experience.</p></section>
      <section className="panel"><form onSubmit={handleSubmit}><label>Source PDF<input type="file" accept=".pdf,application/pdf" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label><label>Topic to learn<input type="text" value={topic} onChange={(event) => setTopic(event.target.value)} placeholder="For example: photosynthesis" /></label><button type="submit" disabled={loading}>{loading ? "Building mock lesson..." : "Generate lesson"}</button></form><p className="notice">Use public-domain, self-authored, non-sensitive, or otherwise permitted material. The prototype accepts PDFs up to 10 MB.</p>{error && <p className="error" role="alert">{error}</p>}</section>
      {!lesson && !loading && <section className="emptyState"><h2>Your lesson will appear here</h2><p>It will include a goal, three guided activities, and checkpoints based on a fixed sample.</p></section>}
      {lesson && <section className="lesson" aria-live="polite"><div className="lessonHeader"><div><p className="eyebrow">Mock lesson</p><h2>{lesson.title}</h2><p>{lesson.overview}</p></div><div className="sourceBadge"><strong>{lesson.source.fileName}</strong><span>Pages {lesson.source.pages.join(", ")}</span></div></div><div className="objective"><strong>Objective</strong><p>{lesson.objective}</p></div><div className="stepList">{lesson.steps.map((step, index) => <article className="lessonCard" key={`${step.title}-${index}`}><span className="stepNumber">Step {index + 1}</span><h3>{step.title}</h3><p>{step.explanation}</p><div className="activity"><strong>Try it</strong><p>{step.activity}</p></div><div className="checkpoint"><strong>Checkpoint</strong><p>{step.checkpoint}</p><p className="answer">{step.answer}</p></div></article>)}</div></section>}
    </main>
  );
}

How does the mock flow work?

  • The five state values hold the selected file, topic, mock lesson, loading status, and current error.
  • The submit handler stops empty submissions before showing the fixed lesson.
  • The lesson renderer creates one card for every item in mockLesson.steps.
  • The source badge reads fixed metadata from the mock object.
  • Save app/page.js.
  • Return to the browser tab running at http://localhost:3000.
  • Choose your permitted PDF in the Source PDF field.
  • Enter a topic from that document in the Topic to learn field.
  • Click Generate lesson.

You should see the fixed lesson titled How Plants Make Food with three cards. Each card shows an explanation, activity, checkpoint, and answer.

You now have the complete learner flow working in the browser. The form accepts a source choice and turns a submission into a visible lesson experience.

Lesson not appearing?

Check that the development server from the previous step is still running in the VS Code terminal. Confirm that app/page.js starts with "use client"; before the import.

Check the browser error area if you submitted without both inputs. The form requires a file and a non-empty topic.

Help me debug the mock React lesson page.

✔️ Awesome, I've got everything!

Great. Save app/page.js before moving on to the visual styling.

ⓧ I'd like to double check the full code

Compare your complete app/page.js with this mock implementation.

"use client";

import { useState } from "react";

// Keep one fixed lesson so the interface can be tested before retrieval exists.
const mockLesson = {
  title: "How Plants Make Food",
  overview: "Explore how plants capture light and turn it into stored energy.",
  objective: "Explain the main stages of photosynthesis through three short activities.",
  source: { fileName: "Mock botany textbook.pdf", pages: [12, 14] },
  steps: [
    { title: "Find the ingredients", explanation: "Plants use light, water, and carbon dioxide to begin making food.", activity: "List where a plant gets each ingredient.", checkpoint: "Which ingredient enters through the leaves?", answer: "Carbon dioxide enters through openings in the leaves." },
    { title: "Follow the energy", explanation: "Chlorophyll captures light energy inside leaf cells.", activity: "Sketch a leaf and label where light is captured.", checkpoint: "What captures the light energy?", answer: "Chlorophyll captures the light energy." },
    { title: "Describe the result", explanation: "The captured energy helps the plant produce glucose and release oxygen.", activity: "Write one sentence connecting glucose to plant growth.", checkpoint: "Which useful gas is released?", answer: "Oxygen is released." },
  ],
};

export default function Home() {
  // Track the form and mock result inside the browser.
  const [file, setFile] = useState(null); const [topic, setTopic] = useState("");
  const [lesson, setLesson] = useState(null); const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  async function handleSubmit(event) {
    event.preventDefault(); setError("");
    if (!file || !topic.trim()) { setError("Choose a PDF and enter a topic first."); return; }
    setLoading(true); setLesson(null);
    // Simulate generation before displaying the fixed lesson.
    await new Promise((resolve) => setTimeout(resolve, 600));
    setLesson(mockLesson); setLoading(false);
  }

  return (
    <main>
      <section className="hero"><p className="eyebrow">Source-to-Project</p><h1>Turn a textbook into an interactive lesson</h1><p className="heroCopy">Upload one permitted PDF, choose a topic inside it, and generate a short project-style learning experience.</p></section>
      <section className="panel"><form onSubmit={handleSubmit}><label>Source PDF<input type="file" accept=".pdf,application/pdf" onChange={(event) => setFile(event.target.files?.[0] ?? null)} /></label><label>Topic to learn<input type="text" value={topic} onChange={(event) => setTopic(event.target.value)} placeholder="For example: photosynthesis" /></label><button type="submit" disabled={loading}>{loading ? "Building mock lesson..." : "Generate lesson"}</button></form><p className="notice">Use public-domain, self-authored, non-sensitive, or otherwise permitted material. The prototype accepts PDFs up to 10 MB.</p>{error && <p className="error" role="alert">{error}</p>}</section>
      {!lesson && !loading && <section className="emptyState"><h2>Your lesson will appear here</h2><p>It will include a goal, three guided activities, and checkpoints based on a fixed sample.</p></section>}
      {lesson && <section className="lesson" aria-live="polite"><div className="lessonHeader"><div><p className="eyebrow">Mock lesson</p><h2>{lesson.title}</h2><p>{lesson.overview}</p></div><div className="sourceBadge"><strong>{lesson.source.fileName}</strong><span>Pages {lesson.source.pages.join(", ")}</span></div></div><div className="objective"><strong>Objective</strong><p>{lesson.objective}</p></div><div className="stepList">{lesson.steps.map((step, index) => <article className="lessonCard" key={`${step.title}-${index}`}><span className="stepNumber">Step {index + 1}</span><h3>{step.title}</h3><p>{step.explanation}</p><div className="activity"><strong>Try it</strong><p>{step.activity}</p></div><div className="checkpoint"><strong>Checkpoint</strong><p>{step.checkpoint}</p><p className="answer">{step.answer}</p></div></article>)}</div></section>}
    </main>
  );
}
Style the lesson experience

The page structure now works. A shared visual system gives the form and lesson cards clear hierarchy across desktop and mobile screens.

  • Switch back to app/globals.css in VS Code.
  • Replace its minimal rules with this visual foundation:
/* Define the colors and typography shared across the page. */
:root { color-scheme: light; --ink: #172033; --muted: #657089; --paper: #ffffff; --canvas: #f4f7fb; --accent: #6558d3; --accent-dark: #4b3fc0; --accent-soft: #ece9ff; --success: #237a57; --success-soft: #e4f6ee; --danger: #a93434; --border: #dce2ec; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--canvas); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.65; }
main { width: min(980px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 16px; font-size: clamp(2.2rem, 7vw, 4.8rem); letter-spacing: -0.055em; line-height: 0.98; }
h2 { font-size: clamp(1.6rem, 4vw, 2.4rem); letter-spacing: -0.035em; }
h3 { margin-bottom: 10px; font-size: 1.25rem; }
.heroCopy, .lessonHeader p, .lessonCard > p { color: var(--muted); line-height: 1.7; }

What does this styling control?

  • The color variables keep borders, backgrounds, text, and feedback states consistent.
  • The responsive page width leaves comfortable space around the interface on wide and narrow screens.
  • The heading rules create a clear path from the product name to the generated lesson.
  • Save app/globals.css.
  • Return to the browser to view the updated page foundation.

You should see a light canvas with a large heading and purple Source-to-Project label. The page content should sit inside a centered column.

Still seeing the old typography?

Confirm that app/layout.js still imports ./globals.css. Check that you saved the stylesheet after replacing its contents.

Help me find why my global styles are not loading.

  • Add the form and feedback styles below the existing rules in app/globals.css by pasting this code:
/* Turn the input area and feedback messages into a focused control panel. */
.panel, .emptyState, .lessonCard, .objective { border: 1px solid var(--border); border-radius: 20px; background: var(--paper); box-shadow: 0 14px 40px rgba(39, 48, 71, 0.07); }
.panel { padding: 24px; }
form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 16px; align-items: end; }
label { display: grid; gap: 8px; font-size: 0.9rem; font-weight: 700; }
input[type="text"], input[type="file"] { width: 100%; min-height: 46px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: #fff; color: var(--ink); }
button { min-height: 46px; padding: 11px 18px; border: 0; border-radius: 12px; background: var(--accent); color: white; font-weight: 800; }
button:hover:not(:disabled) { background: var(--accent-dark); }
.notice { margin: 14px 0 0; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
.error { margin: 14px 0 0; padding: 12px 14px; border-radius: 10px; background: #fff0f0; color: var(--danger); }
.emptyState { margin-top: 24px; padding: 44px 24px; text-align: center; }
.emptyState p { margin-bottom: 0; color: var(--muted); }

How does the panel guide the learner?

  • The grid places the two inputs beside the primary action when space allows.
  • The shared panel surface groups the form into one clear starting point.
  • The notice and error styles separate safe-use guidance from input problems.
  • Save app/globals.css.
  • Refresh the browser to inspect the form panel.

You should see the PDF picker and topic field inside a white panel. The purple Generate lesson button should align with the inputs on a wide screen.

Form still arranged as plain text?

Check that the selectors begin with .panel and form. A missing closing brace earlier in the stylesheet can stop the later rules from loading.

Help me check the form CSS for a syntax problem.

  • Add the lesson card and mobile styles at the bottom of app/globals.css by pasting this code:
/* Organize the generated lesson into readable source and activity sections. */
.lesson { margin-top: 36px; }
.lessonHeader { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: start; }
.sourceBadge { display: grid; gap: 4px; max-width: 260px; padding: 12px 14px; border-radius: 12px; background: var(--accent-soft); color: var(--accent-dark); font-size: 0.82rem; }
.objective { margin: 20px 0; padding: 18px 20px; }
.objective p { margin: 6px 0 0; }
.progressRow { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: center; margin: 22px 0; font-size: 0.9rem; font-weight: 800; }
.progressTrack { height: 10px; overflow: hidden; border-radius: 999px; background: #dfe4ee; }
.progressFill { height: 100%; border-radius: inherit; background: var(--accent); transition: width 180ms ease; }
.stepList { display: grid; gap: 18px; }
.lessonCard { padding: 24px; transition: border-color 180ms ease, transform 180ms ease; }
.lessonCard.complete { border-color: #79c9a8; transform: translateY(-2px); }
.cardTop { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-bottom: 12px; }
.stepNumber { color: var(--accent); font-size: 0.78rem; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; }
.completeToggle { display: flex; grid-template-columns: none; flex-direction: row; align-items: center; gap: 8px; }
.activity, .checkpoint { margin-top: 16px; padding: 16px; border-radius: 14px; background: #f7f8fc; }
.activity p, .checkpoint p { margin: 7px 0 0; line-height: 1.6; }
.secondaryButton { min-height: 38px; margin-top: 12px; padding: 8px 12px; background: var(--ink); font-size: 0.86rem; }
.answer { padding: 12px; border-radius: 10px; background: var(--success-soft); color: var(--success); }
@media (max-width: 760px) { main { padding-top: 32px; } form, .lessonHeader { grid-template-columns: 1fr; } .sourceBadge { max-width: none; } }

How does the lesson layout adapt?

  • The source badge keeps the document name and page references separate from the lesson summary.
  • The card spacing groups each explanation with its activity and checkpoint.
  • The mobile rule stacks the form and lesson header when the screen becomes narrow.
  • Save app/globals.css.
  • Return to the browser to inspect the generated lesson.
  • Narrow the browser window to test the mobile layout.

You should see three separate lesson cards beneath the objective. On a narrow window, the form and source badge should move into a single column.

Cards not stacking on mobile?

Confirm that the media rule appears at the bottom of app/globals.css. Check that every earlier CSS block closes before the media rule begins.

Help me debug the responsive lesson layout.

✔️ Awesome, I've got everything!

Your form, source badge, lesson cards, and mobile layout now share one consistent visual system.

ⓧ I'd like to double check the full code

Compare your complete app/globals.css with this cumulative stylesheet.

/* Define the colors and typography shared across the page. */
:root { color-scheme: light; --ink: #172033; --muted: #657089; --paper: #ffffff; --canvas: #f4f7fb; --accent: #6558d3; --accent-dark: #4b3fc0; --accent-soft: #ece9ff; --success: #237a57; --success-soft: #e4f6ee; --danger: #a93434; --border: #dce2ec; }
* { box-sizing: border-box; }
body { margin: 0; background: var(--canvas); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
button, input { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: wait; opacity: 0.65; }
main { width: min(980px, calc(100% - 32px)); margin: 0 auto; padding: 56px 0 80px; }
.hero { max-width: 720px; margin-bottom: 28px; }
.eyebrow { margin: 0 0 8px; color: var(--accent); font-size: 0.78rem; font-weight: 800; letter-spacing: 0.14em; text-transform: uppercase; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: 16px; font-size: clamp(2.2rem, 7vw, 4.8rem); letter-spacing: -0.055em; line-height: 0.98; }
h2 { font-size: clamp(1.6rem, 4vw, 2.4rem); letter-spacing: -0.035em; }
h3 { margin-bottom: 10px; font-size: 1.25rem; }
.heroCopy, .lessonHeader p, .lessonCard > p { color: var(--muted); line-height: 1.7; }
/* Turn the input area and feedback messages into a focused control panel. */
.panel, .emptyState, .lessonCard, .objective { border: 1px solid var(--border); border-radius: 20px; background: var(--paper); box-shadow: 0 14px 40px rgba(39, 48, 71, 0.07); }
.panel { padding: 24px; }
form { display: grid; grid-template-columns: 1fr 1fr auto; gap: 16px; align-items: end; }
label { display: grid; gap: 8px; font-size: 0.9rem; font-weight: 700; }
input[type="text"], input[type="file"] { width: 100%; min-height: 46px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 12px; background: #fff; color: var(--ink); }
button { min-height: 46px; padding: 11px 18px; border: 0; border-radius: 12px; background: var(--accent); color: white; font-weight: 800; }
button:hover:not(:disabled) { background: var(--accent-dark); }
.notice { margin: 14px 0 0; color: var(--muted); font-size: 0.85rem; line-height: 1.5; }
.error { margin: 14px 0 0; padding: 12px 14px; border-radius: 10px; background: #fff0f0; color: var(--danger); }
.emptyState { margin-top: 24px; padding: 44px 24px; text-align: center; }
.emptyState p { margin-bottom: 0; color: var(--muted); }
/* Organize the generated lesson into readable source and activity sections. */
.lesson { margin-top: 36px; }
.lessonHeader { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: start; }
.sourceBadge { display: grid; gap: 4px; max-width: 260px; padding: 12px 14px; border-radius: 12px; background: var(--accent-soft); color: var(--accent-dark); font-size: 0.82rem; }
.objective { margin: 20px 0; padding: 18px 20px; }
.objective p { margin: 6px 0 0; }
.progressRow { display: grid; grid-template-columns: auto 1fr; gap: 14px; align-items: center; margin: 22px 0; font-size: 0.9rem; font-weight: 800; }
.progressTrack { height: 10px; overflow: hidden; border-radius: 999px; background: #dfe4ee; }
.progressFill { height: 100%; border-radius: inherit; background: var(--accent); transition: width 180ms ease; }
.stepList { display: grid; gap: 18px; }
.lessonCard { padding: 24px; transition: border-color 180ms ease, transform 180ms ease; }
.lessonCard.complete { border-color: #79c9a8; transform: translateY(-2px); }
.cardTop { display: flex; justify-content: space-between; gap: 16px; align-items: center; margin-bottom: 12px; }
.stepNumber { color: var(--accent); font-size: 0.78rem; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; }
.completeToggle { display: flex; grid-template-columns: none; flex-direction: row; align-items: center; gap: 8px; }
.activity, .checkpoint { margin-top: 16px; padding: 16px; border-radius: 14px; background: #f7f8fc; }
.activity p, .checkpoint p { margin: 7px 0 0; line-height: 1.6; }
.secondaryButton { min-height: 38px; margin-top: 12px; padding: 8px 12px; background: var(--ink); font-size: 0.86rem; }
.answer { padding: 12px; border-radius: 10px; background: var(--success-soft); color: var(--success); }
@media (max-width: 760px) { main { padding-top: 32px; } form, .lessonHeader { grid-template-columns: 1fr; } .sourceBadge { max-width: none; } }
Test the mock against another PDF

The interface now looks like a working lesson generator. A source comparison tests whether its lesson content truly depends on the selected document.

Before you submit again, do you think an unrelated PDF and topic produce a different lesson?

  • Click the Source PDF field.
  • Choose a second permitted PDF about an unrelated subject.
  • Replace the topic with one covered by the second PDF.
  • Click Generate lesson.

You should see How Plants Make Food again with the same three cards and mock source information. The chosen document and topic do not change the result.

Why did the lesson stay the same?

The submit handler always loads mockLesson. It never sends the selected PDF anywhere.

No server route, Gemini API key, or PDF retrieval exists in this version. The fixed result exposes the source-grounding gap that the next step closes.

Your lesson experience is visible and responsive. Next, you will replace the fixed sample with a lesson grounded in the uploaded PDF.

Ground Lessons in Uploaded PDFs

Your mock lesson proved that the interface works. However, every uploaded document still produces the same content because the PDF never reaches a server.

This step connects your Next.js app to the Gemini API through a server route. Gemini File Search provides the Retrieval Augmented Generation workflow that grounds each lesson in the uploaded PDF.

In this step, get ready to:
  • Store a Gemini API key in server-only environment files.
  • Create a route that generates structured lessons from uploaded PDFs.
  • Replace mock generation with source-grounded responses.
Create and protect your Gemini API key

The server needs an API key before it can send requests to Gemini. The key stays in .env.local so browser code cannot read it.

Credentials deserve an extra moment of care. You will copy the key once and keep it out of screenshots.

  • Open the Google AI Studio API Keys page in your browser.
  • Sign in with your Google account.
  • Click Create API key on the API Keys page.
  • Complete the project selection shown by Google AI Studio.
  • Copy the generated key using the copy control.

Google AI Studio creates the credential that authorizes requests from your local server. Keep the copied value private.

  • Return to the Explorer sidebar in VS Code.
  • Right-click the source-to-project folder.
  • Select New File.
  • Enter .env.local as the file name.
  • Add the environment variable by pasting this starter line:
GEMINI_API_KEY=your-api-key-here

What does this setting do?

Next.js loads GEMINI_API_KEY into process.env on the server. The absence of a NEXT_PUBLIC_ prefix keeps the value out of browser code.

  • Replace your-api-key-here with the key you copied.
  • Save .env.local.
  • Right-click the source-to-project folder again.
  • Select New File.
  • Enter .env.local.example as the file name.
  • Add the safe example value by pasting:
GEMINI_API_KEY=your-api-key-here

Why keep an example file?

The example file documents the required environment variable without containing a real credential. Your existing .gitignore ignores .env* while allowing .env.local.example to remain in the project.

  • Save .env.local.example.

Unsure whether the key is protected?

Confirm that the real key exists only in .env.local. Keep the placeholder in .env.local.example.

Check that your existing .gitignore still contains .env*.

help me check whether my Gemini API key is stored safely in this Next.js project

Build the grounded generation route

A route handler receives the browser form on the server. It validates the upload before Gemini processes the document.

The route uses structured JSON output to keep every response in the same lesson shape. Its cleanup block deletes the temporary File Search store after every generation attempt.

  • Right-click the app folder in the VS Code Explorer sidebar.
  • Select New Folder.
  • Enter api as the folder name.
  • Right-click the new api folder.
  • Select New Folder.
  • Enter generate as the folder name.
  • Right-click the new generate folder.
  • Select New File.
  • Enter route.js as the file name.
  • Add the complete server route to app/api/generate/route.js using the reference below.

✔️ Awesome, I've got everything!

Save app/api/generate/route.js once your route matches the complete reference.

ⓧ I'd like to double check the full code

import { GoogleGenAI } from "@google/genai";

export const runtime = "nodejs";

const MAX_FILE_SIZE = 10 * 1024 * 1024;

const lessonJsonSchema = {
  type: "object",
  additionalProperties: false,
  properties: {
    title: {
      type: "string",
      description: "A concise title for the lesson.",
    },
    objective: {
      type: "string",
      description: "One observable outcome for the learner.",
    },
    overview: {
      type: "string",
      description: "A short introduction grounded in the source.",
    },
    sourceCoverage: {
      type: "string",
      enum: ["supported", "not_found"],
    },
    steps: {
      type: "array",
      maxItems: 3,
      items: {
        type: "object",
        additionalProperties: false,
        properties: {
          title: { type: "string" },
          explanation: { type: "string" },
          activity: { type: "string" },
          checkpoint: { type: "string" },
          answer: { type: "string" },
        },
        required: [
          "title",
          "explanation",
          "activity",
          "checkpoint",
          "answer"
        ],
      },
    },
  },
  required: ["title", "objective", "overview", "sourceCoverage", "steps"],
};

function wait(milliseconds) {
  return new Promise((resolve) => setTimeout(resolve, milliseconds));
}

export async function POST(request) {
  const apiKey = process.env.GEMINI_API_KEY;

  if (!apiKey) {
    return Response.json(
      { error: "GEMINI_API_KEY is missing from .env.local." },
      { status: 500 },
    );
  }

  const formData = await request.formData();
  const file = formData.get("file");
  const topicValue = formData.get("topic");
  const topic = typeof topicValue === "string" ? topicValue.trim() : "";

  if (!(file instanceof File) || !topic) {
    return Response.json(
      { error: "A PDF and topic are required." },
      { status: 400 },
    );
  }

  const looksLikePdf =
    file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");

  if (!looksLikePdf) {
    return Response.json({ error: "Only PDF files are accepted." }, { status: 400 });
  }

  if (file.size > MAX_FILE_SIZE) {
    return Response.json(
      { error: "The PDF must be smaller than 10 MB." },
      { status: 400 },
    );
  }

  const ai = new GoogleGenAI({ apiKey });
  let storeName = null;

  try {
    const store = await ai.fileSearchStores.create({
      config: { displayName: `source-to-project-${Date.now()}` },
    });

    if (!store.name) {
      throw new Error("STORE_CREATION_FAILED");
    }

    storeName = store.name;

    let operation = await ai.fileSearchStores.uploadToFileSearchStore({
      fileSearchStoreName: storeName,
      file,
      config: {
        displayName: file.name,
        mimeType: "application/pdf",
      },
    });

    for (let attempt = 0; !operation.done && attempt < 30; attempt += 1) {
      await wait(2000);
      operation = await ai.operations.get({ operation });
    }

    if (!operation.done || operation.error) {
      throw new Error("INDEXING_FAILED");
    }

    const response = await ai.models.generateContent({
      model: "gemini-3.5-flash",
      contents: `Create an interactive lesson about "${topic}" using only the uploaded source.

If the source does not contain enough information about the topic, set sourceCoverage to "not_found" and return an empty steps array.

If the source supports the topic, set sourceCoverage to "supported" and return exactly three steps. Each step must include:
- a concise explanation grounded in the source
- a practical activity the learner can complete without specialist equipment
- one checkpoint question
- a short answer

Do not add facts that are not supported by the retrieved source.`,
      config: {
        tools: [
          {
            fileSearch: {
              fileSearchStoreNames: [storeName],
            },
          },
        ],
        responseMimeType: "application/json",
        responseJsonSchema: lessonJsonSchema,
        temperature: 0.2,
      },
    });

    if (!response.text) {
      throw new Error("EMPTY_RESPONSE");
    }

    const lesson = JSON.parse(response.text);

    if (lesson.sourceCoverage !== "supported") {
      throw new Error("TOPIC_NOT_FOUND");
    }

    if (!Array.isArray(lesson.steps) || lesson.steps.length !== 3) {
      throw new Error("INCOMPLETE_LESSON");
    }

    const groundingChunks =
      response.candidates?.[0]?.groundingMetadata?.groundingChunks ?? [];

    const pages = groundingChunks
      .map((chunk) => chunk.retrievedContext?.pageNumber)
      .filter((pageNumber) => Number.isInteger(pageNumber));

    const uniquePages = [...new Set(pages)].sort((a, b) => a - b);

    return Response.json({
      lesson,
      source: {
        fileName: file.name,
        pages: uniquePages,
      },
    });
  } catch (generationError) {
    console.error(generationError);

    const message =
      generationError instanceof Error &&
      generationError.message === "TOPIC_NOT_FOUND"
        ? "That topic was not found in the uploaded source. Try a topic covered by the PDF."
        : "Lesson generation failed. Check your API key, PDF, and topic, then try again.";

    return Response.json({ error: message }, { status: 500 });
  } finally {
    if (storeName) {
      try {
        await ai.fileSearchStores.delete({
          name: storeName,
          config: { force: true },
        });
      } catch (cleanupError) {
        console.error("Temporary File Search store cleanup failed.", cleanupError);
      }
    }
  }
}

How does the route ground a lesson?

  • The request checks for GEMINI_API_KEY before processing the upload.
  • The validation accepts a PDF with a topic up to the project's 10 MB limit.
  • The File Search store indexes the uploaded document before gemini-3.5-flash generates the lesson.
  • The schema requires a title plus an objective plus an overview plus exactly three lesson steps.
  • The response collects unique retrievedContext.pageNumber values when Gemini supplies them.
  • The finally block force-deletes the temporary store after success or failure.

Why use File Search?

File Search handles document indexing plus retrieval inside the Gemini workflow. This keeps the project focused on the learning experience without adding a separate vector database.

Connect the form to grounded generation

The browser now needs to package the selected PDF plus topic as multipart form data. The server response replaces the fixed lesson object that exposed the source-grounding gap.

The form also validates the file before upload. This gives the learner immediate feedback for an unsupported file or a PDF above 10 MB.

  • Return to app/page.js in VS Code.
  • Replace the mock Client Component with the grounded version in the complete reference below.

✔️ Awesome, I've got everything!

Save app/page.js once your Client Component matches the complete reference.

ⓧ I'd like to double check the full code

"use client";

import { useState } from "react";

const MAX_FILE_SIZE = 10 * 1024 * 1024;

export default function Home() {
  const [file, setFile] = useState(null);
  const [topic, setTopic] = useState("");
  const [lesson, setLesson] = useState(null);
  const [source, setSource] = useState(null);
  const [completed, setCompleted] = useState([]);
  const [revealed, setRevealed] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  async function handleSubmit(event) {
    event.preventDefault();
    setError("");

    if (!file || !topic.trim()) {
      setError("Choose a PDF and enter a topic first.");
      return;
    }

    const looksLikePdf =
      file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");

    if (!looksLikePdf) {
      setError("Please choose a PDF file.");
      return;
    }

    if (file.size > MAX_FILE_SIZE) {
      setError("Please choose a PDF smaller than 10 MB for this prototype.");
      return;
    }

    setLoading(true);

    try {
      const formData = new FormData();
      formData.append("file", file);
      formData.append("topic", topic.trim());

      const response = await fetch("/api/generate", {
        method: "POST",
        body: formData,
      });
      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "The lesson could not be generated.");
      }

      setLesson(data.lesson);
      setSource(data.source);
      setCompleted([]);
      setRevealed([]);
    } catch (requestError) {
      setError(
        requestError instanceof Error
          ? requestError.message
          : "The lesson could not be generated.",
      );
    } finally {
      setLoading(false);
    }
  }

  return (
    <main>
      <section className="hero">
        <p className="eyebrow">Source-to-Project</p>
        <h1>Turn a textbook into an interactive lesson</h1>
        <p className="heroCopy">
          Upload one permitted PDF, choose a topic inside it, and generate a
          short project-style learning experience grounded in that source.
        </p>
      </section>

      <section className="panel">
        <form onSubmit={handleSubmit}>
          <label>
            Source PDF
            <input
              type="file"
              accept=".pdf,application/pdf"
              onChange={(event) => setFile(event.target.files?.[0] ?? null)}
            />
          </label>

          <label>
            Topic to learn
            <input
              type="text"
              value={topic}
              onChange={(event) => setTopic(event.target.value)}
              placeholder="For example: photosynthesis"
            />
          </label>

          <button type="submit" disabled={loading}>
            {loading ? "Indexing PDF and building lesson..." : "Generate lesson"}
          </button>
        </form>

        <p className="notice">
          Use public-domain, self-authored, non-sensitive, or otherwise
          permitted material. The prototype accepts PDFs up to 10 MB.
        </p>
        {error && (
          <p className="error" role="alert">
            {error}
          </p>
        )}
      </section>

      {!lesson && !loading && (
        <section className="emptyState">
          <h2>Your lesson will appear here</h2>
          <p>
            It will include a goal, three guided activities, and checkpoints
            based on your source.
          </p>
        </section>
      )}

      {lesson && (
        <section className="lesson" aria-live="polite">
          <div className="lessonHeader">
            <div>
              <p className="eyebrow">Generated lesson</p>
              <h2>{lesson.title}</h2>
              <p>{lesson.overview}</p>
            </div>
            <div className="sourceBadge">
              <strong>{source?.fileName ?? "Uploaded source"}</strong>
              <span>
                {source?.pages?.length
                  ? `Pages ${source.pages.join(", ")}`
                  : "Page references were not returned"}
              </span>
            </div>
          </div>

          <div className="objective">
            <strong>Objective</strong>
            <p>{lesson.objective}</p>
          </div>

          <div className="stepList">
            {lesson.steps.map((step, index) => (
              <article className="lessonCard" key={`${step.title}-${index}`}>
                <div className="cardTop">
                  <span className="stepNumber">Step {index + 1}</span>
                </div>

                <h3>{step.title}</h3>
                <p>{step.explanation}</p>

                <div className="activity">
                  <strong>Try it</strong>
                  <p>{step.activity}</p>
                </div>

                <div className="checkpoint">
                  <strong>Checkpoint</strong>
                  <p>{step.checkpoint}</p>
                </div>
              </article>
            ))}
          </div>
        </section>
      )}
    </main>
  );
}

What changed in the client?

  • The browser validates the selected file plus the trimmed topic before making a request.
  • FormData carries the PDF plus topic to /api/generate as multipart form data.
  • The Fetch API sends the request with the POST method.
  • A successful response replaces the mock lesson plus mock source metadata.
  • The completion plus reveal arrays reset whenever a new lesson arrives.
  • Return to the running development server in the VS Code terminal.
  • Stop the server by pressing Control+C.
  • Restart the server so Next.js loads .env.local by running:
npm run dev

Why restart the server?

Next.js reads .env.local when the development server starts. Restarting makes GEMINI_API_KEY available to the route.

Before you generate the lesson, do you expect the result to match the PDF or the old mock content?

  • Return to http://localhost:3000 in your browser.
  • Choose the permitted PDF you selected for this project.
  • Enter a topic that the PDF covers.
  • Click Generate lesson.

This grounded request takes longer than the mock because the route uploads plus indexes the PDF. A quiet loading state is expected while that work finishes.

You should see a source-grounded title plus objective plus overview. You should also see exactly three lesson cards that relate to the uploaded document.

The source badge shows the uploaded filename. It also shows returned page numbers or the page-reference fallback.

Lesson generation failed?

If you see GEMINI_API_KEY is missing from .env.local. confirm that .env.local contains the real key. Restart the development server after saving it.

If the route cannot ground the topic, choose wording that appears clearly in the PDF. Confirm that the selected file is a PDF below 10 MB.

help me debug why my uploaded PDF is not producing a grounded lesson

Your app now turns an uploaded PDF into a structured lesson that depends on its source. Next, you will add answer reveals plus completion tracking plus browser-persisted progress.

Add Checkpoints and Saved Progress

Your source-grounded lesson now gives learners explanations and activities from their chosen PDF. The current experience still behaves like a page they only read.

This step uses React state to add answer reveals and completion controls. Browser storage then preserves the lesson and its progress across a refresh.

In this step, get ready to:
  • Confirm the lesson displays its source filename and available page references.
  • Add answer reveals and completion tracking to every lesson section.
  • Restore the generated lesson and its progress after a browser refresh.
Check source references and add answer reveals

The server already collects unique page numbers from Gemini File Search. Your source badge shows those pages when Gemini returns them.

Each checkpoint also includes a generated answer. A reveal control lets the learner attempt the question before seeing that answer.

  • Return to the app from earlier at http://localhost:3000.
  • Select your permitted PDF in the Source PDF field.
  • Enter a covered topic in the Topic to learn field.
  • Click Generate lesson.

You should see the uploaded filename in the source badge. You should also see returned page numbers or the fallback text Page references were not returned.

  • Switch back to app/page.js in VS Code.
  • Find the end of handleSubmit().
  • Add the answer-toggle function directly below it using this code:
  function toggleAnswer(index) {
    setRevealed((current) =>
      current.includes(index)
        ? current.filter((item) => item !== index)
        : [...current, index],
    );
  }

What does this function do?

  • The function receives the index of the lesson section whose answer was selected.
  • An existing index is removed from revealed to hide that answer.
  • A missing index is added to revealed to show that answer.
  • Find the callback inside lesson.steps.map().
  • Replace the callback opening and its article opening with this code:
            {lesson.steps.map((step, index) => {
              const isComplete = completed.includes(index);
              const isRevealed = revealed.includes(index);

              return (
                <article
                  className={`lessonCard ${isComplete ? "complete" : ""}`}
                  key={`${step.title}-${index}`}
                >

What do these section values track?

  • The map callback checks whether each section index appears in the relevant state array.
  • The isRevealed value controls whether the section answer is visible.
  • The isComplete value prepares the card for its completion control.
  • Find the checkpoint paragraph that renders step.checkpoint.
  • Add the reveal control directly below that paragraph using this code:
                    <button
                      className="secondaryButton"
                      type="button"
                      onClick={() => toggleAnswer(index)}
                    >
                      {isRevealed ? "Hide answer" : "Reveal answer"}
                    </button>
                    {isRevealed && <p className="answer">{step.answer}</p>}

How does the reveal control work?

  • The button passes the current section index to toggleAnswer().
  • The label changes between Reveal answer and Hide answer according to the section state.
  • The answer paragraph renders only while isRevealed is true.
  • Save app/page.js.
  • Return to the generated lesson in your browser.
  • Click Reveal answer on the first lesson card.

You should see the generated answer appear inside a green answer panel. The button should now read Hide answer.

  • Click Hide answer on the same lesson card.

You should see the answer disappear. The checkpoint now supports an attempt-first learning flow.

Answer not appearing?

Check that toggleAnswer(index) uses the same index passed into the lesson map callback. Also confirm the conditional paragraph reads from step.answer.

Help me fix an answer reveal that does not update in my React lesson card.

Track completed lesson sections

Completion turns each lesson card into a task the learner can finish. The completed section indexes also provide the numbers needed for a progress percentage.

  • In app/page.js, find the end of handleSubmit() again.
  • Add the completion-toggle function above toggleAnswer() using this code:
  function toggleComplete(index) {
    setCompleted((current) =>
      current.includes(index)
        ? current.filter((item) => item !== index)
        : [...current, index],
    );
  }

How is completion updated?

  • The function checks whether the selected section index already exists in completed.
  • Selecting an unfinished section adds its index.
  • Selecting a finished section removes its index.
  • Find the opening of each lessonCard article.
  • Add the card header directly inside the article using this code:
                  <div className="cardTop">
                    <span className="stepNumber">Step {index + 1}</span>
                    <label className="completeToggle">
                      <input
                        type="checkbox"
                        checked={isComplete}
                        onChange={() => toggleComplete(index)}
                      />
                      Complete
                    </label>
                  </div>

What does the completion control connect?

  • The checkbox reads its current state from isComplete.
  • Changing the checkbox passes the current section index to toggleComplete().
  • The existing card class applies its completed styling when the section index is saved.
  • Save app/page.js.
  • Return to the generated lesson in your browser.
  • Select the Complete checkbox on the first lesson card.

You should see the first lesson card gain its completed border and raised position. Clearing the checkbox should return the card to its unfinished style.

  • Find the end of toggleAnswer().
  • Add the progress calculations directly below that function using this code:
  const totalSteps = lesson?.steps?.length ?? 0;
  const progress = totalSteps
    ? Math.round((completed.length / totalSteps) * 100)
    : 0;

How is progress calculated?

  • The totalSteps value reads the number of sections in the generated lesson.
  • The progress value divides completed sections by the total.
  • Rounding converts the result into a whole percentage for the interface.
  • Find the end of the objective panel in the generated lesson.
  • Add the progress row directly below that panel using this code:
          <div className="progressRow">
            <span>{progress}% complete</span>
            <div
              className="progressTrack"
              role="progressbar"
              aria-valuemin="0"
              aria-valuemax="100"
              aria-valuenow={progress}
            >
              <div className="progressFill" style={{ width: `${progress}%` }} />
            </div>
          </div>

How does the progress bar update?

  • The percentage label displays the current progress value.
  • The inner bar uses that percentage as its width.
  • The progress attributes expose the same value to assistive technology.
  • Save app/page.js.
  • Return to the generated lesson in your browser.
  • Select the Complete checkbox on one lesson card.

You should see the progress label change to 33% complete. The filled section of the progress bar should expand at the same time.

Progress staying at zero?

Confirm the checkbox calls toggleComplete(index). Check that the percentage uses completed.length and totalSteps.

Help me debug a React progress percentage that stays at zero after a completion checkbox changes.

Save and restore the lesson session

Browser localStorage keeps string data for the current site across browser sessions. Your app can use it to restore the lesson without uploading the PDF again.

The saved session contains the generated lesson, source metadata, and completed indexes. It leaves out the uploaded PDF and the Gemini API key.

  • In app/page.js, replace the opening import, constants, and state declarations with this code:
"use client";

import { useEffect, useState } from "react";

const STORAGE_KEY = "source-to-project-session";
const MAX_FILE_SIZE = 10 * 1024 * 1024;

export default function Home() {
  const [file, setFile] = useState(null);
  const [topic, setTopic] = useState("");
  const [lesson, setLesson] = useState(null);
  const [source, setSource] = useState(null);
  const [completed, setCompleted] = useState([]);
  const [revealed, setRevealed] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [storageReady, setStorageReady] = useState(false);

What changed at the top of the file?

  • The useEffect import lets the component synchronize its state with browser storage.
  • The STORAGE_KEY constant gives the saved session one stable browser-storage name.
  • The storageReady state prevents the app from overwriting saved data before restoration finishes.
  • Add these effects directly below the state declarations:
  useEffect(() => {
    try {
      const saved = window.localStorage.getItem(STORAGE_KEY);
      if (saved) {
        const session = JSON.parse(saved);
        setLesson(session.lesson ?? null);
        setSource(session.source ?? null);
        setCompleted(Array.isArray(session.completed) ? session.completed : []);
      }
    } catch {
      window.localStorage.removeItem(STORAGE_KEY);
    } finally {
      setStorageReady(true);
    }
  }, []);

  useEffect(() => {
    if (!storageReady) return;

    if (lesson) {
      window.localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify({ lesson, source, completed }),
      );
    } else {
      window.localStorage.removeItem(STORAGE_KEY);
    }
  }, [completed, lesson, source, storageReady]);

How does session persistence work?

The first effect reads the saved JSON once when the page loads. Invalid saved data is removed so it cannot leave the interface stuck.

The second effect writes a new JSON string whenever the lesson, source, or completion state changes. The storage key is removed whenever no lesson exists.

  • The saved object contains lesson, source, and completed.
  • The file state and revealed state stay outside browser storage.
  • Save app/page.js.
  • Return to http://localhost:3000.
  • Generate a lesson from your permitted PDF if the lesson is no longer visible.
  • Reveal the answer on the first lesson card.
  • Mark one lesson card as Complete.

Before you refresh, do you expect the lesson, percentage, and revealed answer to return in the same state?

  • Refresh http://localhost:3000 in your browser.

You should see the generated lesson, source badge, completed card, and 33% complete return. The answer should be hidden because revealed answers are session-only state.

That persistence loop is working. Learners can now leave the page and return without losing their lesson progress.

Lesson disappearing after refresh?

Confirm the restoration effect calls setStorageReady(true) in its final branch. Also check that the saving effect returns while storageReady is false.

Help me debug why my React lesson disappears after a browser refresh.

Use these tabs to compare your completed app/page.js with the pre-secret-mission version.

✔️ Awesome, I've got everything!

Your file is saved. Your lesson now supports source references, answer reveals, completion tracking, and progress that survives a refresh.

ⓧ I'd like to double check the full code

"use client";

import { useEffect, useState } from "react";

const STORAGE_KEY = "source-to-project-session";
const MAX_FILE_SIZE = 10 * 1024 * 1024;

export default function Home() {
  const [file, setFile] = useState(null);
  const [topic, setTopic] = useState("");
  const [lesson, setLesson] = useState(null);
  const [source, setSource] = useState(null);
  const [completed, setCompleted] = useState([]);
  const [revealed, setRevealed] = useState([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");
  const [storageReady, setStorageReady] = useState(false);

  useEffect(() => {
    try {
      const saved = window.localStorage.getItem(STORAGE_KEY);
      if (saved) {
        const session = JSON.parse(saved);
        setLesson(session.lesson ?? null);
        setSource(session.source ?? null);
        setCompleted(Array.isArray(session.completed) ? session.completed : []);
      }
    } catch {
      window.localStorage.removeItem(STORAGE_KEY);
    } finally {
      setStorageReady(true);
    }
  }, []);

  useEffect(() => {
    if (!storageReady) return;

    if (lesson) {
      window.localStorage.setItem(
        STORAGE_KEY,
        JSON.stringify({ lesson, source, completed }),
      );
    } else {
      window.localStorage.removeItem(STORAGE_KEY);
    }
  }, [completed, lesson, source, storageReady]);

  async function handleSubmit(event) {
    event.preventDefault();
    setError("");

    if (!file || !topic.trim()) {
      setError("Choose a PDF and enter a topic first.");
      return;
    }

    const looksLikePdf =
      file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf");

    if (!looksLikePdf) {
      setError("Please choose a PDF file.");
      return;
    }

    if (file.size > MAX_FILE_SIZE) {
      setError("Please choose a PDF smaller than 10 MB for this prototype.");
      return;
    }

    setLoading(true);

    try {
      const formData = new FormData();
      formData.append("file", file);
      formData.append("topic", topic.trim());

      const response = await fetch("/api/generate", {
        method: "POST",
        body: formData,
      });
      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "The lesson could not be generated.");
      }

      setLesson(data.lesson);
      setSource(data.source);
      setCompleted([]);
      setRevealed([]);
    } catch (requestError) {
      setError(
        requestError instanceof Error
          ? requestError.message
          : "The lesson could not be generated.",
      );
    } finally {
      setLoading(false);
    }
  }

  function toggleComplete(index) {
    setCompleted((current) =>
      current.includes(index)
        ? current.filter((item) => item !== index)
        : [...current, index],
    );
  }

  function toggleAnswer(index) {
    setRevealed((current) =>
      current.includes(index)
        ? current.filter((item) => item !== index)
        : [...current, index],
    );
  }

  const totalSteps = lesson?.steps?.length ?? 0;
  const progress = totalSteps
    ? Math.round((completed.length / totalSteps) * 100)
    : 0;

  return (
    <main>
      <section className="hero">
        <p className="eyebrow">Source-to-Project</p>
        <h1>Turn a textbook into an interactive lesson</h1>
        <p className="heroCopy">
          Upload one permitted PDF, choose a topic inside it, and generate a
          short project-style learning experience grounded in that source.
        </p>
      </section>

      <section className="panel">
        <form onSubmit={handleSubmit}>
          <label>
            Source PDF
            <input
              type="file"
              accept=".pdf,application/pdf"
              onChange={(event) => setFile(event.target.files?.[0] ?? null)}
            />
          </label>

          <label>
            Topic to learn
            <input
              type="text"
              value={topic}
              onChange={(event) => setTopic(event.target.value)}
              placeholder="For example: photosynthesis"
            />
          </label>

          <button type="submit" disabled={loading}>
            {loading ? "Indexing PDF and building lesson..." : "Generate lesson"}
          </button>
        </form>

        <p className="notice">
          Use public-domain, self-authored, non-sensitive, or otherwise
          permitted material. The prototype accepts PDFs up to 10 MB.
        </p>
        {error && (
          <p className="error" role="alert">
            {error}
          </p>
        )}
      </section>

      {!lesson && !loading && (
        <section className="emptyState">
          <h2>Your lesson will appear here</h2>
          <p>
            It will include a goal, three guided activities, and checkpoints
            based on your source.
          </p>
        </section>
      )}

      {lesson && (
        <section className="lesson" aria-live="polite">
          <div className="lessonHeader">
            <div>
              <p className="eyebrow">Generated lesson</p>
              <h2>{lesson.title}</h2>
              <p>{lesson.overview}</p>
            </div>
            <div className="sourceBadge">
              <strong>{source?.fileName ?? "Uploaded source"}</strong>
              <span>
                {source?.pages?.length
                  ? `Pages ${source.pages.join(", ")}`
                  : "Page references were not returned"}
              </span>
            </div>
          </div>

          <div className="objective">
            <strong>Objective</strong>
            <p>{lesson.objective}</p>
          </div>

          <div className="progressRow">
            <span>{progress}% complete</span>
            <div
              className="progressTrack"
              role="progressbar"
              aria-valuemin="0"
              aria-valuemax="100"
              aria-valuenow={progress}
            >
              <div className="progressFill" style={{ width: `${progress}%` }} />
            </div>
          </div>

          <div className="stepList">
            {lesson.steps.map((step, index) => {
              const isComplete = completed.includes(index);
              const isRevealed = revealed.includes(index);

              return (
                <article
                  className={`lessonCard ${isComplete ? "complete" : ""}`}
                  key={`${step.title}-${index}`}
                >
                  <div className="cardTop">
                    <span className="stepNumber">Step {index + 1}</span>
                    <label className="completeToggle">
                      <input
                        type="checkbox"
                        checked={isComplete}
                        onChange={() => toggleComplete(index)}
                      />
                      Complete
                    </label>
                  </div>

                  <h3>{step.title}</h3>
                  <p>{step.explanation}</p>

                  <div className="activity">
                    <strong>Try it</strong>
                    <p>{step.activity}</p>
                  </div>

                  <div className="checkpoint">
                    <strong>Checkpoint</strong>
                    <p>{step.checkpoint}</p>
                    <button
                      className="secondaryButton"
                      type="button"
                      onClick={() => toggleAnswer(index)}
                    >
                      {isRevealed ? "Hide answer" : "Reveal answer"}
                    </button>
                    {isRevealed && <p className="answer">{step.answer}</p>}
                  </div>
                </article>
              );
            })}
          </div>
        </section>
      )}
    </main>
  );
}

How should you use this reference?

Compare each section with your saved file. Keep the identifiers and values exactly as shown so the state, controls, and persistence effects stay connected.

Secret mission

Add a Completion Celebration

Turn 100 percent progress into a clear celebration that rewards the learner. Add a start-over flow that clears the saved lesson and resets every input for a fresh PDF.

Clean Up Your Resources

Clean Up Your Resources

Your Source-to-Project app runs locally with no ongoing hosting cost. Three cleanup levels cover active work, a temporary pause, or permanent deletion.

Data and Temporary Stores

The Gemini API Free Tier is free of charge within its limits. Free Tier content may be used to improve Google products.

  • Continue using only public-domain, self-authored, non-sensitive, or otherwise permitted PDFs.

Every generation attempt that creates a temporary Gemini File Search store runs forced cleanup through the route's finally block. No manual File Search store cleanup is required after a completed request.

Resources you used:

  • The source-to-project folder on your Desktop. It contains the app code, node_modules, and the uncommitted .env.local file.
  • The Gemini API key created in Google AI Studio.
  • Any saved browser lesson data stored for http://localhost:3000 under the source-to-project-session key.

Keep everything running

No action is needed. Choose this option while you are actively testing lessons or extending the app.

  • Leave the existing VS Code terminal running while you continue testing the app.
  • Keep the source-to-project folder on your Desktop.
  • Keep the Gemini API key active while you plan to generate more lessons.
  • Keep any saved lesson progress in your browser.

Pause - I'll come back to this later

Stop the development server to free up memory. Your project files, API key, and browser progress stay available for later.

  • Switch back to the VS Code terminal from earlier.
  • Stop the development server by pressing Control+C.

You should see the terminal prompt return. This confirms the local server has stopped.

  • Restart the local app later from the source-to-project folder by running:
npm run dev

What Does This Command Do?

The dev script starts the local Next.js development server. Your app becomes available again at http://localhost:3000.

Server Does Not Restart?

Make sure the terminal is inside the source-to-project folder. The command needs the project's package.json file.

Help me restart the Source-to-Project development server.

Delete - I don't want to use this again

Deleting this project is permanent. The steps below remove only the local app, its saved browser session, and its Gemini API key.

  • Return to http://localhost:3000 in the browser tab from earlier.
  • Click Start another lesson if the completed lesson remains visible.
  • Use your browser's site-data controls for http://localhost:3000 if the completion button is unavailable.

The empty lesson state confirms that the saved lesson and completion progress are gone.

  • Switch back to the VS Code terminal from earlier.
  • Stop the development server by pressing Control+C if it is still running.

Revoking the API key prevents this app from making future Gemini requests. You can create a new key if you rebuild the project later.

  • Switch back to Google AI Studio from earlier.
  • Return to the API Keys page.
  • Find the Gemini API key created for this project.
  • Use the revoke or delete action for that key.
  • Confirm the key removal.

The key should no longer appear as an active credential. The copy inside .env.local disappears when you delete the project folder.

Choose your computer platform to remove the local source-to-project folder.

macOS

  • Delete the source-to-project folder from your Desktop by running these commands:
cd ~/Desktop
rm -rf source-to-project
What Do These Commands Do?
  • The first command moves the terminal to your Desktop.
  • The second command permanently removes the source-to-project folder and everything inside it.
  • Check your Desktop after the commands finish.

You should no longer see the source-to-project folder.

Folder Still on Your Desktop?

Check that the folder name is exactly source-to-project. Confirm that the terminal moved to your Desktop before the delete command ran.

Help me safely remove the local project folder.

Windows

  • Use the Windows command shell in the VS Code terminal.
  • Delete the source-to-project folder from your Desktop by running these commands:
cd ~\Desktop
rmdir /s /q source-to-project
What Do These Commands Do?
  • The first command moves the terminal to your Desktop.
  • The second command permanently removes the source-to-project folder and everything inside it.
  • Check your Desktop after the commands finish.

You should no longer see the source-to-project folder.

Folder Still on Your Desktop?

Make sure the VS Code terminal is using the Windows command shell. Check that the folder name is exactly source-to-project.

Help me safely remove the local project folder.

Nice Work!

Nice Work!

That is a wrap! Your Source-to-Project app now turns a permitted PDF into an interactive lesson grounded in the uploaded document.

You've learned how to:

  • Build a responsive full-stack lesson generator with Next.js. Use React to render three interactive lesson sections from each uploaded PDF.
  • Protect the Gemini API key inside a server-side route. Ground each lesson with Gemini File Search. Shape every response as predictable JSON.
  • Display source details with any returned page references. Add checkpoint answer reveals. Preserve completion progress in localStorage after a browser refresh.
  • Secret Mission: Show a completion celebration when progress reaches 100 percent. Clear saved progress through a reliable start-over flow.

Ready to quiz yourself?