WordDrop: Daily Topic Discovery PWA

Build an AI-powered PWA with animations, topic-locked chat, and daily learning.

Introduction

30 Second Summary

You know that feeling when you want to learn something new every day but never know where to start? Imagine an app on your phone that picks a fresh topic each morning and teaches it to you in a way that actually sticks.

In this project, you will build WordDrop, a sleek Progressive Web App that lives on your iPhone home screen and delivers a new AI-powered topic every day with a cinematic letter-reveal animation and interactive lessons.

What You'll Build

Open WordDrop on your phone, watch letters rapidly cycle through the alphabet before locking into today's topic, then dive into a rich visual lesson you can chat about with an AI that refuses to go off-topic.

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

  • A Progressive Web App installed on your iPhone that reveals a new topic daily with a dramatic letter-by-letter animation powered by Framer Motion.
  • A Gemini-powered lesson engine that generates structured lessons with analogies, real-world examples, and key takeaways on demand.
  • A topic-locked AI chat that answers questions about today's topic and politely refuses anything else, plus a Memory gallery of everything you have learned with tap-for-recap summaries.
  • Secret Mission: Add daily push notifications so your phone buzzes when a new topic is live.

Are there any prerequisites?

This project assumes intermediate JavaScript and React knowledge. You will also need Node.js installed, a Google account for your Gemini API key, and an iPhone running iOS 16.4 or later for PWA installation.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Set Up Your Project and API Keys

You have a clear picture of what WordDrop does. Now you need a working foundation to build on.

This step gets you a running Next.js app with Tailwind CSS for styling, Framer Motion for animations, and the Google Gemini SDK for AI features. You will also grab your API key and connect your project to Vercel for deployment later.

In this step, get ready to:
  • Scaffold a Next.js project with Tailwind CSS and install Framer Motion and the Google GenAI SDK.
  • Get a Gemini API key from Google AI Studio and save it in your environment file.
  • Create a Vercel account and link your project for future deployment.
Scaffold the Next.js project

Next.js provides a scaffolding tool that sets up your entire project structure in one command. It handles TypeScript, Tailwind CSS, and the App Router automatically.

  • Open your terminal in VS Code by pressing Cmd+` (macOS) or Ctrl+` (Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your WordDrop project by running this command:
npx create-next-app@latest worddrop

What will this command ask you?

The CLI will ask: "Would you like to use the recommended Next.js defaults?" Select Yes, use recommended defaults. This enables TypeScript, ESLint, Tailwind CSS, and the App Router in one go.

  • Select Yes, use recommended defaults when prompted.
  • Wait for the scaffolding to finish. It installs all dependencies automatically.
  • Move into the new project folder by running:
cd worddrop
  • Install Framer Motion and the Google Gemini SDK by running:
npm install framer-motion @google/genai

What are these packages?

`framer-motion` provides spring-based animations that integrate natively with React components. `@google/genai` is Google's official SDK for calling the Gemini API from Node.js.

  • Start the development server by running:
npm run dev

Before you check the browser, what do you expect to see at localhost:3000?

  • Open your browser and navigate to http://localhost:3000.

You should see the default Next.js starter page with the Next.js logo and some getting-started links. This confirms your project is scaffolded and running correctly.

Not seeing the starter page?

  • Check your terminal for errors. If you see a port conflict, stop whatever is using port 3000 and re-run npm run dev.
  • Make sure you ran cd worddrop before npm run dev. The command must be run from inside the project folder.
  • help me debug my Next.js dev server
Get your Gemini API key

WordDrop uses Google's Gemini AI to generate topics and lessons. You need an API key to authenticate your app's requests.

  • Open Google AI Studio in your browser:

https://aistudio.google.com/app/apikey

  • Sign in with your Google account.
  • Click Create API key.
  • If prompted, select the default project or create a new one.
  • Copy the generated API key and keep it somewhere safe. You will only see it once.

What does this cost?

The Gemini API free tier gives you 1,500 requests per day with no credit card required. WordDrop will stay well within this limit during development.

  • Back in VS Code, stop the dev server with Ctrl+C.
  • Create a .env.local file in the root of your worddrop folder by running:
touch .env.local
  • Open .env.local in VS Code and add your API key:
GEMINI_API_KEY=your_gemini_api_key_here
  • Replace your_gemini_api_key_here with the key you just copied from Google AI Studio.
  • Save .env.local.

Never commit API keys to Git

Next.js automatically includes .env.local in .gitignore, so your key stays private and never gets pushed to a repository.

  • Restart the dev server to pick up the environment variable:
npm run dev
  • Confirm the app still loads at http://localhost:3000. Your API key is now available to server-side code via process.env.GEMINI_API_KEY.

Seeing an error about the API key?

  • Make sure there are no spaces around the = sign in .env.local.
  • Confirm the file is named exactly .env.local (with the leading dot) and lives in the project root alongside package.json.
  • help me troubleshoot my .env.local setup
Create a Vercel account and link your project

Vercel hosts your finished app for free and gives it a public URL. Linking your project now means deployment will be a single command when you are ready.

  • Open https://vercel.com/signup in your browser.
  • Sign up with your GitHub, GitLab, or email. The Hobby plan is free.
  • Once your account is created, return to your terminal in VS Code.
  • Stop the dev server with Ctrl+C if it is still running.
  • Install the Vercel CLI as a project dependency by running:
npm i vercel
  • Log in to Vercel from the terminal by running:
npx vercel login

What happens when you run this?

The CLI opens a browser tab asking you to confirm the login. Click Continue in the browser, then return to your terminal. You will see a success message.

  • Link your project to Vercel by running:
npx vercel link

What will this command ask?

The CLI asks which team to use (select yours), then whether to link to an existing project. Since this is new, answer N to create a new project. Accept the default project name worddrop and the auto-detected Next.js settings.

  • When asked "Link to existing project?", enter N.
  • Accept the default name worddrop and default build settings by pressing Enter through the remaining prompts.

You should see a confirmation like "Linked to your-team/worddrop" in the terminal. A .vercel folder now exists in your project.

Vercel link not working?

  • Make sure you completed the npx vercel login step first and confirmed in the browser.
  • If you see a permission error, try running the install with sudo npm i vercel on macOS.
  • help me fix Vercel CLI issues
  • Start the dev server again to confirm everything still works:
npm run dev
  • Open http://localhost:3000 and confirm the Next.js starter page still loads.

✔️ I see the Next.js starter page

Your project is scaffolded, your API key is configured, and Vercel is linked. You are ready to build.

ⓧ Something went wrong

  • Run ls in your terminal. You should see package.json, .env.local, and a .vercel folder listed.
  • If .env.local is missing, re-create it with touch .env.local and add your key.
  • If .vercel is missing, run npx vercel link again.
  • If the dev server errors out, check that you ran npm install framer-motion @google/genai from inside the worddrop folder.

Your foundation is solid. You have a running Next.js app, an AI key ready to go, and Vercel waiting to deploy. Next up, you will build the onboarding modal and daily topic engine that gives WordDrop its brain.

Build the Onboarding Modal and Daily Topic Engine

Your project runs locally, but right now it shows the default Next.js starter page. Without topic generation logic and a way to capture user preferences, the app has nothing meaningful to display.

In this step, you will build the first thing users see and the brain behind it. A full-screen onboarding modal captures the user's learning preference, then a server-side API route calls Gemini to generate today's topic.

In this step, get ready to:
  • Build a dark-themed onboarding modal with two animated card options.
  • Create an API route that calls Gemini to generate a daily topic.
  • Wire localStorage to cache the user's preference and today's topic.
Build the onboarding modal component

The onboarding modal is the first screen a new user sees. It needs to feel polished and intentional, with smooth entrance animations courtesy of Framer Motion.

  • Create a new folder called components in the root of your worddrop project by running this command from your project directory:
mkdir components
  • Create a new file called OnboardingModal.tsx inside the components folder and add the following code:
"use client";
import { motion } from "framer-motion";

// Full-screen onboarding modal with animated card choices
export default function OnboardingModal({ onSelect }: { onSelect: (pref: string) => void }) {
  const options = [
    { id: "surprise", emoji: "\u{1F3B2}", title: "Surprise Me", desc: "Random fascinating topics daily", x: -40 },
    { id: "career", emoji: "\u{1F680}", title: "Career Boost", desc: "Topics tailored to grow your skills", x: 40 },
  ];

  return (
    <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="fixed inset-0 bg-gray-950 flex flex-col items-center justify-center p-6">
      <motion.h1 initial={{ y: -20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.2 }} className="text-4xl font-bold text-white mb-4">WordDrop</motion.h1>
      <motion.p initial={{ y: -10, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ delay: 0.4 }} className="text-gray-400 mb-12 text-center">
        Learn something new every day. How should we pick your topics?
      </motion.p>
      <div className="flex flex-col gap-4 w-full max-w-sm">
        {options.map((opt, i) => (
          <motion.button key={opt.id} initial={{ x: opt.x, opacity: 0 }} animate={{ x: 0, opacity: 1 }} transition={{ delay: 0.6 + i * 0.2 }} whileHover={{ scale: 1.03 }} whileTap={{ scale: 0.97 }} onClick={() => onSelect(opt.id)} className="bg-gray-800 border border-gray-700 rounded-2xl p-6 text-left">
            <span className="text-2xl mb-2 block">{opt.emoji}</span>
            <span className="text-white font-semibold text-lg block">{opt.title}</span>
            <span className="text-gray-400 text-sm">{opt.desc}</span>
          </motion.button>
        ))}
      </div>
    </motion.div>
  );
}

What does this code do?

The component renders a full-screen dark overlay with a title, subtitle, and two card buttons. Each element uses Framer Motion's initial and animate props to fade and slide in with staggered delays.

The options array defines each card's data. The x value makes "Surprise Me" slide in from the left and "Career Boost" from the right. When a user taps a card, onSelect fires with the chosen preference ID.

Now you need a page that renders this modal. The default app/page.tsx still has the Next.js starter content.

  • Replace the entire contents of app/page.tsx with the following code:
"use client";
import { useState, useEffect } from "react";
import OnboardingModal from "@/components/OnboardingModal";

export default function Home() {
  const [preference, setPreference] = useState<string | null>(null);
  const [topic, setTopic] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);
  const today = new Date().toISOString().split("T")[0];

  useEffect(() => {
    const savedPref = localStorage.getItem("worddrop_preference");
    const cachedTopic = localStorage.getItem(`worddrop_topic_${today}`);
    if (savedPref) setPreference(savedPref);
    if (savedPref && cachedTopic) { setTopic(cachedTopic); console.log("Today's topic:", cachedTopic); }
    setLoading(false);
  }, [today]);

  // Saves preference only for now - API wiring comes next
  async function handleSelect(pref: string) {
    localStorage.setItem("worddrop_preference", pref);
    setPreference(pref);
  }
  if (loading) return null;
  if (!preference) return <OnboardingModal onSelect={handleSelect} />;
  return (
    <div className="min-h-screen bg-gray-950 flex items-center justify-center">
      <h1 className="text-4xl font-bold text-white">{topic || "Loading..."}</h1>
    </div>
  );
}

What does this code do?

On mount, the useEffect checks localStorage for a saved preference and a cached topic for today's date. If neither exists, the component renders the onboarding modal.

The handleSelect function saves the user's choice to localStorage. Right now it only stores the preference. You will wire it to the topic API in a later substep.

  • Save both files.
  • Switch to your browser and refresh localhost:3000. You should see a dark full-screen modal with the "WordDrop" title and two animated cards: "Surprise Me" and "Career Boost".

Don't see the modal?

  • Make sure your dev server is still running. If it stopped, run npm run dev again.
  • Check that components/OnboardingModal.tsx is in the correct folder. The import path @/components/OnboardingModal expects it at the project root, not inside app/.
  • If you previously clicked a card, the preference is already saved. Open DevTools (F12), go to the **Application** tab, click **Local Storage**, and delete any worddrop_ entries. Then refresh.

Still stuck? Why isn't my OnboardingModal rendering on the page?

Create the topic generation API route

Next.js Route Handlers let you define server-side API endpoints directly inside the app directory. You will create a POST endpoint that receives the user's preference and today's date, then asks Gemini for a topic.

  • Create the API route folder structure by running:
mkdir -p app/api/topic
  • Create a new file called route.ts inside app/api/topic/ and add the following code:
import { GoogleGenAI } from "@google/genai";

// Initialize Gemini with the API key from environment variables
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

export async function POST(request: Request) {
  const { preference, date } = await request.json();

  // Describe what kind of topic to generate based on user preference
  const modeDescription = preference === "career"
    ? "a professional skill or career-relevant concept"
    : "a random fascinating topic from any field of knowledge";

  // Use the date as a seed so the same day always returns the same topic
  const prompt = `Today is ${date}. Generate exactly one interesting topic (a single word or short 2-3 word phrase) that is ${modeDescription}. The topic should be specific and intriguing. Reply with ONLY the topic text, nothing else.`;

  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    contents: prompt,
  });

  // Extract the topic text from Gemini's response
  const topic = response.text?.trim() || "Quantum Computing";

  return Response.json({ topic });
}

What does this code do?

The route handler exports a POST function that Next.js automatically maps to /api/topic. It reads preference and date from the request body.

The date acts as a deterministic seed. Asking Gemini with the same date and preference produces a consistent topic, preventing multiple topics on the same day. The prompt instructs Gemini to return only the topic text with no extra formatting.

  • Save app/api/topic/route.ts.
  • Test the API by opening your browser's DevTools console (F12) and running this fetch call:
fetch('/api/topic', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ preference: 'surprise', date: '2026-08-06' }) }).then(r => r.json()).then(console.log)

You should see an object like {topic: "Bioluminescence"} logged to the console. The exact topic will vary.

Seeing a 500 error?

  • Check that your .env.local file contains GEMINI_API_KEY with a valid key from Google AI Studio.
  • Make sure the file path is exactly app/api/topic/route.ts. A missing folder level means Next.js cannot find the route.
  • Restart your dev server after editing .env.local. Environment variable changes require a restart.

Still stuck? Help me debug my /api/topic route returning a 500 error.

Wire localStorage to cache the preference and topic

The API works, but clicking a card in the modal does not call it yet. You need to update handleSelect so it fetches a topic from your new endpoint and caches the result in localStorage.

  • In app/page.tsx, find the handleSelect function. It currently looks like this:
  // Saves preference only for now - API wiring comes next
  async function handleSelect(pref: string) {
    localStorage.setItem("worddrop_preference", pref);
    setPreference(pref);
  }
  • Replace that entire function with this updated version that calls the API and caches the topic:
  // Fetch today's topic from Gemini and cache it locally
  async function handleSelect(pref: string) {
    localStorage.setItem("worddrop_preference", pref);
    setPreference(pref);
    const res = await fetch("/api/topic", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ preference: pref, date: today }),
    });
    const data = await res.json();
    localStorage.setItem(`worddrop_topic_${today}`, data.topic);
    setTopic(data.topic);
    console.log("Today's topic:", data.topic);
  }

Why cache in localStorage?

Each API call uses one of your 1,500 free daily Gemini requests. By caching today's topic under the key worddrop_topic_2026-08-06 (using today's date), repeat visits skip the API call entirely. Tomorrow's date will miss the cache and trigger a fresh topic.

  • Save app/page.tsx.
  • Clear your localStorage so you can test the full flow. In DevTools console, run:
localStorage.clear()

Before you refresh, what do you expect to happen when you click one of the cards?

  • Refresh localhost:3000. The onboarding modal appears again.
  • Click either "Surprise Me" or "Career Boost".
  • Open the DevTools console. You should see Today's topic: <some topic> logged, and the page should display that topic word in large white text.

Topic not showing up?

  • If you see "Loading..." but no topic appears, check the DevTools **Network** tab for a failed /api/topic request. A 500 means the API route has an error.
  • Make sure the handleSelect function includes setTopic(data.topic). Without it, the state never updates.
  • Confirm you ran localStorage.clear() before refreshing. A stale preference skips the modal.

Still stuck? Help me figure out why my topic isn't appearing after I click a card.

✔️ Awesome, I've got everything!

Great. Double check you have saved both components/OnboardingModal.tsx and app/page.tsx.

ⓧ I'd like to double check the full code

Your project should now have three key files. Scroll up and confirm each code block is present in your project:

  • components/OnboardingModal.tsx with the full modal component (options array, Framer Motion animations, two card buttons).
  • app/api/topic/route.ts with the POST handler, Gemini initialization, prompt with date seed, and Response.json({ topic }) return.
  • app/page.tsx with the localStorage check in useEffect, the updated handleSelect that calls /api/topic and caches the result, and the conditional rendering (modal vs topic display).

Your app now has a brain and a first impression. Next up, you will turn that plain topic word into a cinematic letter-by-letter reveal animation that makes opening the app feel magical.

Create the Letter Reveal Animation

Your app can now pick a daily topic, but showing it as plain text feels flat. The letter reveal animation is what transforms WordDrop from a generic text display into something that feels alive.

In this step you will build a component that spells out today's topic one letter at a time, with each character slot rapidly cycling through the alphabet before snapping into place. The result is a dramatic, cinematic reveal that hooks users every time they open the app.

In this step, get ready to:
  • Build a LetterReveal component with cycling character slots and staggered lock-in.
  • Add a vibrant glow effect and scale pulse for visual polish.
  • Wire a Learn button that fades in after the animation completes.
Build the LetterReveal component

Each letter of today's topic gets its own slot on screen. The slot cycles through random uppercase letters at high speed, then freezes on the correct character after a staggered delay.

  • Create a new file called components/LetterReveal.tsx and add the component shell with its state setup:
'use client';

import { useState, useEffect } from 'react';
import { motion } from 'motion/react';

interface LetterRevealProps {
  topic: string;
  onComplete: () => void;
}

export default function LetterReveal({ topic, onComplete }: LetterRevealProps) {
  // Track the character displayed in each slot
  const [displayLetters, setDisplayLetters] = useState<string[]>(
    Array(topic.length).fill(' ')
  );
  // Track which slots have locked to their final letter
  const [lockedIndices, setLockedIndices] = useState<Set<number>>(new Set());
  // Track when every letter has locked in
  const [allLocked, setAllLocked] = useState(false);

What does this code do?

  • displayLetters is an array the same length as the topic, holding the character currently shown in each slot.
  • lockedIndices is a Set that grows as each slot finishes cycling and freezes on its real letter.
  • allLocked flips to true once every slot has locked, triggering the final scale pulse.
  • Below the state declarations, add the useEffect that drives the cycling and lock-in logic:
  useEffect(() => {
    const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
    const intervals: NodeJS.Timeout[] = [];
    const timeouts: NodeJS.Timeout[] = [];

    topic.split('').forEach((char, i) => {
      // Stagger when each slot begins cycling
      const startTimeout = setTimeout(() => {
        const interval = setInterval(() => {
          setDisplayLetters(prev => {
            const next = [...prev];
            next[i] = alphabet[Math.floor(Math.random() * 26)];
            return next;
          });
        }, 50);
        intervals.push(interval);

        // Lock to the correct letter after a calculated delay
        const lockTimeout = setTimeout(() => {
          clearInterval(interval);
          setDisplayLetters(prev => { const n = [...prev]; n[i] = char.toUpperCase(); return n; });
          setLockedIndices(prev => {
            const updated = new Set(prev);
            updated.add(i);
            if (updated.size === topic.length) {
              setAllLocked(true);
              onComplete();
            }
            return updated;
          });
        }, 1000 + i * 200);
        timeouts.push(lockTimeout);
      }, i * 100);
      timeouts.push(startTimeout);
    });

    return () => { intervals.forEach(clearInterval); timeouts.forEach(clearTimeout); };
  }, [topic, onComplete]);

What does this cycling logic do?

Each letter slot starts cycling 100ms after the previous one (i * 100). The setInterval swaps in a random character every 50ms, creating the rapid flickering effect.

After 1000 + i * 200 milliseconds, the interval clears and the slot locks to the real character. Once the Set contains every index, the animation is complete and the callback fires.

  • Complete the component by adding the JSX return below the useEffect:
  return (
    <motion.div
      className="flex gap-2 justify-center items-center min-h-screen bg-gray-950"
      animate={allLocked ? { scale: [1, 1.05, 1] } : {}}
      transition={{ duration: 0.4, type: 'spring' }}
    >
      {displayLetters.map((letter, index) => (
        <motion.span
          key={index}
          className={`text-4xl font-bold font-mono tracking-wider ${
            lockedIndices.has(index) ? 'text-cyan-400' : 'text-gray-500'
          }`}
          style={
            lockedIndices.has(index)
              ? { filter: 'drop-shadow(0 0 8px rgb(34 211 238))' }
              : {}
          }
        >
          {letter}
        </motion.span>
      ))}
    </motion.div>
  );
}

What does this render do?

The outer motion.div applies a scale pulse (1 to 1.05 and back) with a spring transition once allLocked becomes true.

Each motion.span switches from gray to cyan text when it locks. The drop-shadow CSS filter adds a vibrant glow around each locked letter.

  • Save components/LetterReveal.tsx.

The component is ready but it is not rendered anywhere yet. Next you will wire it into the home page.

Wire the LetterReveal into the home page

The home page currently renders the topic as plain text after onboarding completes. You need to replace that with the new LetterReveal component.

  • Open app/page.tsx and add the import for LetterReveal near the top of the file:
import LetterReveal from '@/components/LetterReveal';
  • Inside the component return, find where the topic text currently renders and replace it with the LetterReveal component, passing today's topic:
<LetterReveal topic={topic} onComplete={() => setAnimationDone(true)} />

What does onComplete do here?

When the last letter locks in, the callback sets a piece of state (animationDone) to true. You will use this in the next substep to control when the Learn button appears.

  • Add an animationDone state variable to your home page component:
const [animationDone, setAnimationDone] = useState(false);
  • Save app/page.tsx and check your browser at localhost:3000.

If you have already completed onboarding, clear localStorage to re-trigger it, or wait for the topic to load. You should see character slots rapidly flickering through random letters, then freezing one by one into the topic word with a cyan glow and a subtle scale pulse at the end.

Not seeing the animation?

  • Make sure the import path uses @/components/LetterReveal with a capital L.
  • Confirm you added 'use client' at the top of LetterReveal.tsx. Server components cannot use hooks like useState.
  • Check that topic is a non-empty string when LetterReveal renders. If it is empty, no slots will appear.

Why aren't my letters cycling or glowing?

Add the Learn button with a fade-in

The Learn button should only appear after the animation finishes, giving the reveal its full dramatic moment before offering the next action.

  • In app/page.tsx, add the following JSX below the LetterReveal component, inside the same container:
{animationDone && (
  <motion.a
    href="/lesson"
    className="mt-8 px-6 py-3 bg-cyan-500 text-gray-950 font-bold rounded-lg"
    initial={{ opacity: 0 }}
    animate={{ opacity: 1 }}
    transition={{ duration: 0.5 }}
  >
    Learn
  </motion.a>
)}

What does this code do?

The motion.a element only mounts once animationDone is true. When it appears, it fades in from zero opacity over 0.5 seconds using Framer Motion's initial and animate props.

The link points to /lesson which you will build in the next step.

  • Make sure you import motion from motion/react at the top of app/page.tsx if it is not already imported:
import { motion } from 'motion/react';
  • Save app/page.tsx and return to your browser.

What do you expect to see after the last letter locks in?

Watch the animation play out. Once every letter glows cyan and the container pulses, a bright Learn button smoothly fades in below the word. Clicking it navigates to /lesson (which will show a 404 for now, and that is expected).

Learn button not appearing?

  • Confirm that setAnimationDone(true) is called inside the onComplete callback you passed to LetterReveal.
  • Check for typos in the conditional render. The curly braces around {animationDone && (...)} must wrap the entire motion.a element.

Why doesn't my Learn button show up after the animation?

✔️ Awesome, I've got everything!

Great. Double check you have saved both files.

ⓧ I'd like to double check the full code

Scroll up and confirm you have three sections in components/LetterReveal.tsx in this order:

  • The component shell with state declarations (displayLetters, lockedIndices, allLocked).
  • The useEffect with cycling intervals and staggered lock-in logic.
  • The return block with motion.div (scale pulse) wrapping motion.span elements (glow on lock).

In app/page.tsx, confirm you have:

  • Imports for LetterReveal and motion from motion/react.
  • An animationDone state variable.
  • The <LetterReveal> component with the onComplete callback.
  • The conditional motion.a Learn button that fades in.

Your app now greets users with a cinematic letter-by-letter reveal that feels premium and polished. Next up, you will build the rich lesson page that the Learn button leads to.

Build the Rich Lesson Page

Your app has a dramatic reveal, but tapping "Learn" leads nowhere yet. The lesson is the core learning experience of WordDrop.

A wall of plain text will not keep users engaged. In this step, you will build a structured lesson page that asks Gemini to return rich content in a specific JSON shape, then renders it as polished, animated cards.

In this step, get ready to:
  • Create an API route that prompts Gemini to return a structured JSON lesson.
  • Build the lesson page UI with a gradient header and card-based sections using Framer Motion scroll-reveal.
  • Add a loading skeleton that displays while the lesson generates.
Create the lesson API route

The lesson page needs structured data to render discrete cards for an analogy, examples, takeaways, and a fun fact. You will write a system prompt that forces Gemini to return valid JSON in the exact shape your UI expects.

  • Create a new folder app/api/lesson inside your project.
  • Create a file called route.ts inside that folder and add the following code:
import { GoogleGenAI } from "@google/genai";
import { NextResponse } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

// System prompt that forces Gemini to return structured JSON
const systemPrompt = `You are an educational content creator. Return ONLY valid JSON with no markdown formatting. The JSON must have this exact structure:
{
  "analogy": "A clear, relatable analogy explaining the topic",
  "examples": [
    { "title": "Example title", "context": "2-3 sentence explanation" },
    { "title": "Example title", "context": "2-3 sentence explanation" },
    { "title": "Example title", "context": "2-3 sentence explanation" }
  ],
  "keyTakeaways": ["takeaway 1", "takeaway 2", "takeaway 3"],
  "didYouKnow": "An interesting fact about the topic"
}`;

export async function POST(request: Request) {
  const { topic } = await request.json();

  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    systemInstruction: systemPrompt,
    contents: `Create an educational lesson about: ${topic}`,
  });

  // Strip markdown fences if Gemini wraps the JSON
  const text = response.text || "";
  const cleaned = text.replace(/```json\n?|```\n?/g, "").trim();
  const lesson = JSON.parse(cleaned);

  return NextResponse.json(lesson);
}

What does this code do?

The systemInstruction field tells Gemini to act as an educational content creator and return ONLY valid JSON. This is system prompt engineering in action.

The cleaning step strips any accidental markdown code fences (```json) that Gemini might wrap around the response. Then JSON.parse converts the text into a structured object your UI can render as cards.

  • Save app/api/lesson/route.ts.
  • Test the route by running this in a new terminal:
curl -X POST http://localhost:3000/api/lesson -H "Content-Type: application/json" -d '{"topic": "photosynthesis"}'

You should see a JSON response with analogy, examples, keyTakeaways, and didYouKnow fields printed in your terminal.

Seeing a JSON parse error?

Make sure your .env.local file has a valid GEMINI_API_KEY and your dev server is still running. If Gemini returns extra text outside the JSON, the replace line handles common cases, but you may need to restart the dev server after editing .env.local.

Help me debug my lesson API route

✔️ Awesome, I've got everything!

Great. Make sure you have saved app/api/lesson/route.ts before moving on.

ⓧ I'd like to double check the full code

Here is the complete app/api/lesson/route.ts file:

import { GoogleGenAI } from "@google/genai";
import { NextResponse } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });

const systemPrompt = `You are an educational content creator. Return ONLY valid JSON with no markdown formatting. The JSON must have this exact structure:
{
  "analogy": "A clear, relatable analogy explaining the topic",
  "examples": [
    { "title": "Example title", "context": "2-3 sentence explanation" },
    { "title": "Example title", "context": "2-3 sentence explanation" },
    { "title": "Example title", "context": "2-3 sentence explanation" }
  ],
  "keyTakeaways": ["takeaway 1", "takeaway 2", "takeaway 3"],
  "didYouKnow": "An interesting fact about the topic"
}`;

export async function POST(request: Request) {
  const { topic } = await request.json();

  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    systemInstruction: systemPrompt,
    contents: `Create an educational lesson about: ${topic}`,
  });

  const text = response.text || "";
  const cleaned = text.replace(/```json\n?|```\n?/g, "").trim();
  const lesson = JSON.parse(cleaned);

  return NextResponse.json(lesson);
}
Build the lesson page with animated cards

Now that your API returns structured lesson data, you need a page to display it. Each section of the lesson will be its own card that fades in as the user scrolls down.

  • Create a new folder app/lesson inside your project.
  • Create a file called page.tsx inside that folder.
  • Add the data-fetching logic at the top of the file:
"use client";
import { useEffect, useState } from "react";
import { motion } from "framer-motion";

interface LessonData {
  analogy: string;
  examples: { title: string; context: string }[];
  keyTakeaways: string[];
  didYouKnow: string;
}

export default function LessonPage() {
  const [lesson, setLesson] = useState<LessonData | null>(null);
  const [topic, setTopic] = useState("");
  useEffect(() => {
    const cached = localStorage.getItem("worddrop_topic");
    if (cached) {
      const { topic } = JSON.parse(cached);
      setTopic(topic);
      // Call the lesson API with today's topic
      fetch("/api/lesson", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ topic }),
      })
        .then((res) => res.json())
        .then((data) => setLesson(data));
    }
  }, []);
  if (!lesson) return <p className="text-white text-center p-8">Loading...</p>;
  return <p className="text-white p-8">Lesson for: {topic}</p>;
}

What does this code do?

The component reads today's cached topic from localStorage and passes it to your /api/lesson route on mount. While waiting for the response, it shows a loading message.

The LessonData interface matches the JSON shape your API returns, so TypeScript can validate every field you render.

  • Save app/lesson/page.tsx and click the Learn button in your browser.

You should briefly see "Loading..." followed by "Lesson for: [your topic]" once the API responds.

Now replace the placeholder return (the last line return <p className="text-white p-8">Lesson for: {topic}</p>;) with the full card UI. Add the header, analogy card, and example cards:

  return (
    <div className="min-h-screen bg-gray-950 p-6 pb-20">
      {/* Gradient header with topic name */}
      <motion.h1 initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }}
        className="text-4xl font-bold text-center mb-8 bg-gradient-to-r from-purple-400 to-pink-400 bg-clip-text text-transparent">
        {topic}
      </motion.h1>
      {/* Analogy card - scroll reveal */}
      <motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true }} className="bg-gray-900 border border-gray-800 rounded-xl p-6 mb-6">
        <h2 className="text-lg font-semibold text-purple-300 mb-2">Think of it like...</h2>
        <p className="text-gray-300">{lesson.analogy}</p>
      </motion.div>
      {/* Real-world example cards */}
      {lesson.examples.map((ex, i) => (
        <motion.div key={i} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true }} transition={{ delay: i * 0.1 }}
          className="bg-gray-900 border border-gray-800 rounded-xl p-6 mb-4">
          <h3 className="font-semibold text-pink-300 mb-1">{ex.title}</h3>
          <p className="text-gray-300 text-sm">{ex.context}</p>
        </motion.div>
      ))}
  • Still inside the same return, add the key takeaways and did-you-know cards below the examples:
      {/* Key takeaways card */}
      <motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true }} className="bg-gray-900 border border-gray-800 rounded-xl p-6 my-6">
        <h2 className="text-lg font-semibold text-green-300 mb-3">Key Takeaways</h2>
        <ul className="space-y-2">
          {lesson.keyTakeaways.map((t, i) => (
            <li key={i} className="text-gray-300 flex gap-2">
              <span className="text-green-400">✓</span> {t}
            </li>
          ))}
        </ul>
      </motion.div>
      {/* Did-you-know fact card */}
      <motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }}
        viewport={{ once: true }} className="bg-gray-900 border border-purple-700 rounded-xl p-6">
        <h2 className="text-lg font-semibold text-yellow-300 mb-2">Did You Know?</h2>
        <p className="text-gray-300">{lesson.didYouKnow}</p>
      </motion.div>
    </div>
  );
}

How does the scroll-reveal work?

Each card uses Framer Motion's whileInView prop, which animates the element when it enters the viewport. Combined with viewport={{ once: true }}, the animation plays only the first time you scroll to it.

The initial={{ opacity: 0, y: 20 }} starts each card invisible and shifted down by 20 pixels. When it enters the viewport, it fades in and slides up to its natural position.

  • Save app/lesson/page.tsx and refresh the lesson page in your browser.

You should see a gradient topic title at the top, followed by styled cards for the analogy, three examples, key takeaways, and a did-you-know fact. Scroll down to watch each card animate into view.

Cards not appearing?

Check that the two code blocks above are combined into a single return (...) statement. The first block starts with return ( and the second block ends with ); and the closing } of the function.

If you see a blank white page, open the browser console and look for errors. A common cause is a missing comma or bracket when combining the two code sections.

Help me fix my lesson page cards

✔️ Awesome, I've got everything!

Great. Confirm app/lesson/page.tsx is saved and the cards render when you scroll.

ⓧ I'd like to double check the full code

Your app/lesson/page.tsx is built from three chunks. Scroll up and confirm you have all of the following in order:

  • The data-fetching chunk (imports, interface, state, useEffect, loading guard).
  • The first return chunk (opening return (, gradient motion.h1, analogy card, examples .map).
  • The second return chunk (key takeaways card, did-you-know card, closing </div>, );, }).
Add a loading skeleton

The "Loading..." text works, but it feels jarring compared to the polished animation you built. A skeleton UI shows placeholder shapes that match your final layout, keeping the experience smooth while Gemini generates content.

  • Create a new file components/LessonSkeleton.tsx and add the following:
"use client";

export default function LessonSkeleton() {
  return (
    <div className="min-h-screen bg-gray-950 p-6">
      {/* Header placeholder */}
      <div className="h-10 w-2/3 mx-auto bg-gray-800 rounded-lg animate-pulse mb-8" />
      {/* Analogy card placeholder */}
      <div className="h-28 bg-gray-800 rounded-xl animate-pulse mb-6" />
      {/* Example card placeholders */}
      <div className="space-y-4 mb-6">
        <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
        <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
        <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
      </div>
      {/* Takeaways placeholder */}
      <div className="h-28 bg-gray-800 rounded-xl animate-pulse mb-6" />
      {/* Did-you-know placeholder */}
      <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
    </div>
  );
}

What does this code do?

Each div block has Tailwind's animate-pulse class, which creates a soft pulsing opacity animation. The heights and widths match the approximate size of the real lesson cards so the layout does not shift when content loads in.

  • Save components/LessonSkeleton.tsx.
  • Switch back to app/lesson/page.tsx and find this line near the top:
import { motion } from "framer-motion";
  • Add the skeleton import directly below it:
import LessonSkeleton from "@/components/LessonSkeleton";
  • Now find the loading guard line:
  if (!lesson) return <p className="text-white text-center p-8">Loading...</p>;
  • Replace it with:
  if (!lesson) return <LessonSkeleton />;
  • Save app/lesson/page.tsx.
  • Clear your browser's localStorage for the site (DevTools > Application > Local Storage > right-click > Clear) and reload the app from the home page.
  • Select a mode in the onboarding modal, wait for the letter animation, then tap Learn.

Before the lesson content arrives, you should see dark pulsing placeholder blocks that match the card layout. After a moment, the real lesson cards fade in with scroll-reveal animations.

Skeleton not showing?

The skeleton only appears during the brief loading window while Gemini responds. If your internet is fast, you might barely see it. To test it visually, temporarily add a 3-second delay before your setLesson(data) call using setTimeout.

Help me verify my loading skeleton is working

✔️ Awesome, I've got everything!

Great. Double check you have saved both components/LessonSkeleton.tsx and app/lesson/page.tsx.

ⓧ I'd like to double check the full code

Here is the complete components/LessonSkeleton.tsx:

"use client";

export default function LessonSkeleton() {
  return (
    <div className="min-h-screen bg-gray-950 p-6">
      <div className="h-10 w-2/3 mx-auto bg-gray-800 rounded-lg animate-pulse mb-8" />
      <div className="h-28 bg-gray-800 rounded-xl animate-pulse mb-6" />
      <div className="space-y-4 mb-6">
        <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
        <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
        <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
      </div>
      <div className="h-28 bg-gray-800 rounded-xl animate-pulse mb-6" />
      <div className="h-20 bg-gray-800 rounded-xl animate-pulse" />
    </div>
  );
}

For app/lesson/page.tsx, scroll up and confirm these chunks are present in order:

  • Imports including LessonSkeleton from @/components/LessonSkeleton.
  • The LessonData interface and component function with state and useEffect.
  • Loading guard: if (!lesson) return <LessonSkeleton />;.
  • The full return JSX with gradient header, analogy card, example cards, takeaways card, and did-you-know card.

Before you move on, what do you think will happen if you reload the lesson page a second time for the same topic?

Tap Learn again and watch. Each time you visit, the skeleton flashes briefly while Gemini generates a fresh lesson. The cards then scroll-reveal into view with smooth fade-up animations.

Your lesson page now turns raw AI text into a structured, visually rich learning experience. Next up, you will add a topic-locked chat, a countdown timer to build anticipation, and a Memory gallery so nothing you learn is ever lost.

Add the Topic-Locked Chat, Countdown, and Memory

Your lesson page delivers rich, structured content powered by Gemini. But learning is not just reading. Asking questions, revisiting past topics, and building anticipation for what comes next are what turn a static app into a daily habit.

In this step, you will build a chat interface that only answers questions about today's topic, a live countdown timer, and a Memory tab for revisiting past topics. Then you will turn the whole app into an installable Progressive Web App and deploy it to Vercel.

In this step, get ready to:
  • Build a topic-locked AI chat that refuses off-topic questions.
  • Add a countdown timer and a Memory tab with tap-for-recap.
  • Make the app an installable PWA and deploy it live on Vercel.
Build the chat interface and add topic locking

The chat API route needs a system prompt that instructs Gemini to only discuss today's topic. To see why this matters, you will first build the route without the restriction and watch the AI happily answer anything you throw at it.

  • Create a new file app/api/chat/route.ts with the following code:
import { GoogleGenAI } from "@google/genai";
import { NextResponse } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

export async function POST(request: Request) {
  const { messages, topic } = await request.json();

  // Build conversation history as a single string
  const conversationText = messages
    .map((m: { role: string; text: string }) => `${m.role}: ${m.text}`)
    .join("\n");

  // Send to Gemini WITHOUT any topic restriction
  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    contents: conversationText,
  });

  return NextResponse.json({ reply: response.text });
}

What does this code do?

This route accepts a POST request with the conversation history and today's topic. It formats messages into a string and sends them to Gemini. Right now it has no restrictions on what the AI can discuss.

  • Save app/api/chat/route.ts.
  • Now create the chat page UI. Create a new file app/chat/page.tsx and add the component state and logic:
"use client";
import { useState, useRef, useEffect } from "react";

interface Message { role: "user" | "ai"; text: string; }

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [loading, setLoading] = useState(false);
  const endRef = useRef<HTMLDivElement>(null);

  // Read today's topic from localStorage
  const topic = typeof window !== "undefined"
    ? localStorage.getItem("worddrop_topic") || ""
    : "";

  // Auto-scroll to newest message
  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  async function sendMessage() {
    if (!input.trim()) return;
    const updated = [...messages, { role: "user" as const, text: input }];
    setMessages(updated);
    setInput("");
    setLoading(true);
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: updated, topic }),
    });
    const data = await res.json();
    setMessages([...updated, { role: "ai", text: data.reply }]);
    setLoading(false);
  }

What does this code do?

The component holds the conversation in state. When the user sends a message, it appends their text, calls /api/chat with the full history and today's topic, then appends the AI reply.

The endRef keeps the message list scrolled to the bottom as new messages arrive.

  • Continue in the same file. Add the return statement below the sendMessage function:
  return (
    <div className="min-h-screen bg-[#0a0a0a] flex flex-col">
      <div className="p-4 border-b border-white/10">
        <h1 className="text-white text-lg font-semibold">Chat: {topic}</h1>
      </div>
      <div className="flex-1 overflow-y-auto p-4 space-y-4">
        {messages.map((msg, i) => (
          <div key={i} className={`max-w-[80%] p-3 rounded-2xl ${
            msg.role === "user"
              ? "ml-auto bg-violet-600 text-white"
              : "mr-auto bg-white/10 text-gray-200"
          }`}>{msg.text}</div>
        ))}
        {loading && (
          <div className="mr-auto bg-white/10 text-gray-400 p-3 rounded-2xl">
            Thinking...
          </div>
        )}
        <div ref={endRef} />
      </div>
      <div className="p-4 border-t border-white/10 flex gap-2">
        <input type="text" value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && sendMessage()}
          placeholder={`Ask about ${topic}...`}
          className="flex-1 bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-white placeholder:text-gray-500 focus:outline-none focus:border-violet-500" />
        <button onClick={sendMessage}
          className="bg-violet-600 hover:bg-violet-500 text-white px-5 py-3 rounded-xl font-medium">
          Send
        </button>
      </div>
    </div>
  );
}

What does this code do?

The UI has three sections: a header showing today's topic, a scrollable message list with styled bubbles (violet for user, dark for AI), and a fixed input bar at the bottom with a send button.

  • Save app/chat/page.tsx.
  • Open your browser and navigate to http://localhost:3000/chat.

Before you test, think about this: if you type an off-topic question like "what is the weather today?", do you think the AI will refuse to answer or happily respond?

  • Type "what is the weather today?" and press Enter.

The AI answers your weather question without hesitation. It does not care about today's topic at all. This is the gap. A daily learning app that answers anything is just a generic chatbot.

  • Return to app/api/chat/route.ts in VS Code.
  • Find this line:
  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    contents: conversationText,
  });
  • Replace it with the following, which adds a systemInstruction that locks the AI to today's topic:
  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    contents: conversationText,
    systemInstruction: `You are a tutor for the topic "${topic}". Only answer questions directly related to "${topic}". If the user asks about anything else, politely decline and redirect them to ask about "${topic}".`,
  });

What is systemInstruction?

The systemInstruction field is Gemini's mechanism for developer-set instructions that the user cannot override. It tells the model who it is and what boundaries to enforce before any user message is processed.

By injecting today's topic into the instruction, the AI becomes a focused tutor that politely redirects any off-topic question.

  • Save app/api/chat/route.ts.
  • Return to http://localhost:3000/chat in your browser.
  • Type "what is the weather today?" again and press Enter.

This time the AI politely refuses and redirects you to ask about today's topic. That one systemInstruction field transformed a generic chatbot into a focused tutor.

AI still answering off-topic questions?

Make sure you saved the file after adding systemInstruction. Your dev server hot-reloads API routes on save. If you are still seeing old responses, try refreshing the page to start a fresh conversation.

help me debug why my chat API is not restricting topics

Add the countdown timer and Memory tab

A countdown builds anticipation for tomorrow's topic. The Memory tab makes past effort visible. Together they give users a reason to come back every day.

  • Create a new file components/CountdownTimer.tsx with the following code:
"use client";
import { useState, useEffect } from "react";

export default function CountdownTimer() {
  const [timeLeft, setTimeLeft] = useState("");

  useEffect(() => {
    function calculateTimeLeft() {
      const now = new Date();
      const midnight = new Date();
      midnight.setHours(24, 0, 0, 0);
      const diff = midnight.getTime() - now.getTime();

      // Format as HH:MM:SS
      const hours = Math.floor(diff / (1000 * 60 * 60));
      const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
      const seconds = Math.floor((diff % (1000 * 60)) / 1000);
      setTimeLeft(
        `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
      );
    }

    calculateTimeLeft();
    const interval = setInterval(calculateTimeLeft, 1000);
    return () => clearInterval(interval);
  }, []);

  return (
    <div className="text-center mt-8">
      <p className="text-gray-400 text-sm">Next topic in</p>
      <p className="text-white text-3xl font-mono font-bold mt-1">
        {timeLeft}
      </p>
    </div>
  );
}

What does this code do?

The component calculates milliseconds until midnight, converts that to hours, minutes, and seconds, and updates every second via setInterval. The cleanup function prevents memory leaks when the component unmounts.

  • Save components/CountdownTimer.tsx.
  • Open app/page.tsx and add the import for CountdownTimer at the top of your imports:
import CountdownTimer from "@/components/CountdownTimer";
  • Inside the return statement of your home page, add the <CountdownTimer /> component below the Learn button.
  • Save app/page.tsx and check http://localhost:3000 in your browser.

You should see a countdown timer ticking below the Learn button, showing hours, minutes, and seconds until midnight.

Countdown not showing?

Make sure the import path uses @/components/CountdownTimer (capital C, capital T). Also confirm you placed the component inside the return JSX, not outside it.

help me fix my CountdownTimer import

  • Now build the recap API. Create a new file app/api/recap/route.ts with:
import { GoogleGenAI } from "@google/genai";
import { NextResponse } from "next/server";

const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });

export async function POST(request: Request) {
  const { topic } = await request.json();

  // Ask Gemini for a concise 3-sentence recap
  const response = await ai.models.generateContent({
    model: "gemini-3.6-flash",
    contents: `Give a 3-sentence recap of the topic "${topic}". Be concise and informative.`,
  });

  return NextResponse.json({ recap: response.text });
}

What does this code do?

This route takes a past topic and asks Gemini for a 3-sentence summary. The Memory tab will call this when the user taps a topic card, giving them a quick refresher without rereading the full lesson.

  • Save app/api/recap/route.ts.
  • Create the Memory page. Create a new file app/memory/page.tsx and add the component:
"use client";
import { useState, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";

export default function MemoryPage() {
  const [topics, setTopics] = useState<string[]>([]);
  const [recap, setRecap] = useState("");
  const [activeTopic, setActiveTopic] = useState("");
  const [loading, setLoading] = useState(false);

  // Load all past topics from localStorage
  useEffect(() => {
    const stored = localStorage.getItem("worddrop_topics");
    if (stored) setTopics(JSON.parse(stored));
  }, []);

  async function handleRecap(topic: string) {
    setActiveTopic(topic);
    setLoading(true);
    const res = await fetch("/api/recap", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ topic }),
    });
    const data = await res.json();
    setRecap(data.recap);
    setLoading(false);
  }
  • Continue in the same file. Add the return statement below handleRecap:
  return (
    <div className="min-h-screen bg-[#0a0a0a] p-6">
      <h1 className="text-white text-2xl font-bold mb-6">Memory</h1>
      <div className="grid grid-cols-2 gap-4">
        {topics.map((topic, i) => (
          <motion.button key={i} whileTap={{ scale: 0.95 }}
            onClick={() => handleRecap(topic)}
            className="bg-white/5 border border-white/10 rounded-xl p-4 text-left">
            <p className="text-white font-medium">{topic}</p>
          </motion.button>
        ))}
      </div>
      <AnimatePresence>
        {activeTopic && (
          <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="fixed inset-0 bg-black/80 flex items-center justify-center p-6 z-50"
            onClick={() => setActiveTopic("")}>
            <motion.div initial={{ scale: 0.9 }} animate={{ scale: 1 }}
              exit={{ scale: 0.9 }}
              className="bg-[#1a1a1a] rounded-2xl p-6 max-w-sm w-full"
              onClick={(e) => e.stopPropagation()}>
              <h2 className="text-white text-lg font-bold mb-3">{activeTopic}</h2>
              {loading
                ? <p className="text-gray-400">Loading recap...</p>
                : <p className="text-gray-300 leading-relaxed">{recap}</p>}
              <button onClick={() => setActiveTopic("")}
                className="mt-4 text-violet-400 font-medium">Close</button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

What does this code do?

The page reads worddrop_topics from localStorage and displays them as a two-column card grid. Tapping a card opens a modal and calls /api/recap to fetch a 3-sentence summary from Gemini. The modal uses Framer Motion for smooth enter/exit animations.

  • Save app/memory/page.tsx.
  • Navigate to http://localhost:3000/memory in your browser.

You should see today's topic displayed as a card. Tap it and a modal appears with a 3-sentence recap generated by Gemini.

No topics showing in Memory?

The Memory page reads from worddrop_topics in localStorage. If your app currently only saves a single topic under a different key, update your home page to also append each new topic to a worddrop_topics array. For example, after fetching a topic, add it with JSON.parse(localStorage.getItem('worddrop_topics') || '[]') then push and save.

Make it a PWA and deploy to Vercel

A Progressive Web App needs a web manifest (telling the OS how to display the app) and a service worker (enabling offline support). With these two files, your app becomes installable on an iPhone home screen.

  • Create a new file public/manifest.json with the following:
{
  "name": "WordDrop",
  "short_name": "WordDrop",
  "start_url": "/",
  "display": "standalone",
  "background_color": "#0a0a0a",
  "theme_color": "#7c3aed",
  "icons": [
    {
      "src": "/icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ]
}

What is a web manifest?

The manifest tells the browser and OS how to present your app when installed. The display: standalone setting removes all browser UI so your app feels native. The theme and background colors match WordDrop's dark design.

Icons are referenced but not generated in this step. You can use any 192x192 and 512x512 PNG placed in public/icons/. A simple solid violet square works perfectly.

  • Save public/manifest.json.
  • Create a new file public/sw.js with the service worker:
const CACHE_NAME = "worddrop-v1";
const urlsToCache = ["/", "/chat", "/memory"];

// Cache app shell on install
self.addEventListener("install", (event) => {
  event.waitUntil(
    caches.open(CACHE_NAME).then((cache) => cache.addAll(urlsToCache))
  );
});

// Serve from cache, fall back to network
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((response) => {
      return response || fetch(event.request);
    })
  );
});

What does the service worker do?

On install, it pre-caches your app's main routes so they load instantly on repeat visits. The fetch handler serves cached content first, falling back to the network. This gives your PWA basic offline support.

  • Save public/sw.js.
  • Now wire everything into the root layout. Create or update app/layout.tsx to include the manifest link and service worker registration:
import type { Metadata } from "next";
import "./globals.css";
import RegisterSW from "@/components/RegisterSW";

export const metadata: Metadata = {
  title: "WordDrop",
  description: "Learn a new topic every day",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <link rel="manifest" href="/manifest.json" />
        <meta name="theme-color" content="#7c3aed" />
      </head>
      <body className="bg-[#0a0a0a]">
        <RegisterSW />
        {children}
      </body>
    </html>
  );
}
  • Create the RegisterSW component at components/RegisterSW.tsx:
"use client";
import { useEffect } from "react";

export default function RegisterSW() {
  useEffect(() => {
    // Register service worker for PWA support
    if ("serviceWorker" in navigator) {
      navigator.serviceWorker.register("/sw.js");
    }
  }, []);

  return null;
}

Why a separate client component?

The root layout is a Server Component by default. The navigator object only exists in the browser, so service worker registration must happen in a Client Component. RegisterSW renders nothing visible but runs the registration on mount.

  • Save both app/layout.tsx and components/RegisterSW.tsx.
  • Create a public/icons/ folder and add two PNG images named icon-192.png (192x192) and icon-512.png (512x512). A solid violet square works.
  • Refresh http://localhost:3000 and open DevTools. In the Application tab, click Manifest. You should see WordDrop's name, colors, and icons listed.

Your app is now PWA-ready. Time to deploy it live so you can install it on your iPhone.

  • Install the Vercel CLI globally by running:
npm install -g vercel
  • Log in to your Vercel account by running:
vercel login
  • Follow the prompts to authenticate with your browser.
  • Deploy to production by running:
vercel --prod
  • The CLI will ask you to link the project. Accept the defaults.
  • When prompted for environment variables, add your GEMINI_API_KEY in the Vercel dashboard under your project's Settings > Environment Variables.
  • Once deployed, Vercel gives you a live URL. Copy it.

How to install on your iPhone

Open your Vercel URL in Safari on your iPhone. Tap the Share button (the square with an arrow), then tap Add to Home Screen. The app now launches in standalone mode with no browser UI, just like a native app.

Your iPhone must be running iOS 16.4 or later for PWA features to work from the home screen.

Before you verify, predict what will happen if you ask the deployed chat an off-topic question. Will it answer or refuse?

  • Open your deployed app on your phone and navigate to the chat.
  • Ask a question about today's topic. Confirm you get a helpful response.
  • Ask something off-topic like "what is the weather?" and confirm the AI politely refuses.
  • Navigate to Memory and confirm today's topic is listed as a card.
  • Return to the home screen and confirm the countdown timer is ticking toward midnight.

App not installable on iPhone?

The PWA must be served over HTTPS (Vercel handles this automatically) and opened in Safari. You cannot install PWAs from Chrome on iOS. Make sure you use the Share button in Safari and look for Add to Home Screen.

help me install my PWA on iPhone

Your app is live, installable, and intelligently locked to one topic per day. In the Secret Mission, you will push this further by adding daily push notifications that buzz your phone when a new topic drops.

Secret mission

Add Daily Push Notifications

A daily notification that says "Your new topic is live!" transforms WordDrop from something you might forget to open into a daily habit. This is what makes a PWA feel like a native app. Wire up VAPID keys, a service worker push handler, and a Vercel Cron Job to buzz your phone every morning.

Clean Up Your Resources

Clean Up Your Resources

This project runs on the Vercel Hobby plan (free) and the Gemini API free tier. No charges will accrue whether you leave it running or not. Decide whether to keep your resources active, pause them, or delete them entirely.

Resources you used:

  • Vercel Hobby project (free deployment, cron job, environment variables).
  • Google AI Studio API key (Gemini free tier, 1,500 requests/day).
  • Local project files (the worddrop folder on your Desktop).

Keep everything running

No action needed. Choose this if you want the app to stay live on your phone and keep delivering daily topics.

  • Your Vercel deployment stays live indefinitely on the free Hobby plan.
  • The Gemini API key remains active with its daily rate limits.
  • The daily cron job continues running at no cost.
  • Your local project files stay on your Desktop for future edits.

Pause - I'll come back to this later

Since both the Vercel Hobby plan and Gemini free tier cost nothing when idle, there is nothing to pause. If you want to stop the daily push notification while you are away, you can remove the cron configuration.

  • To stop daily notifications, delete the crons array from vercel.json and redeploy. The app stays live but stops sending push notifications.
  • Your local files, API key, and Vercel project all remain intact for when you return.

Delete - I don't want to use this again

Remove all project resources completely. Follow each step below.

  • Delete your Vercel project: open the Vercel dashboard, select your WordDrop project, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Enter the project name and click Continue to confirm. This removes all deployments, environment variables (including your VAPID keys), and the cron job.
  • Delete your Gemini API key: open the Google Cloud API Credentials page, find your API key, click the Actions icon next to it, and select Delete API key. In the Delete credential modal, click Delete to confirm.
  • Delete your local project files by running this command in your terminal:

macOS

rm -rf ~/Desktop/worddrop

Windows

rmdir /s /q %USERPROFILE%\Desktop\worddrop

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a polished daily learning app that lives on your iPhone home screen, powered by AI and wrapped in cinematic animations.

You've learned how to:

  • Build a full-stack Next.js Progressive Web App deployed live on Vercel and installed on your iPhone as a native-feeling app.
  • Integrate Google Gemini's free API to generate daily topics, structured lessons, and a topic-locked chat using system prompt engineering that makes the AI refuse off-topic questions.
  • Create a cinematic letter-reveal animation using Framer Motion that cycles through A-Z characters before locking into today's topic, making the app feel premium.
  • Add daily push notifications using Vercel Cron Jobs and the Web Push API so your phone buzzes each morning with a new topic (Secret Mission).

Ready to quiz yourself?