Build an AI-Powered Inspiration Board

Use Claude's web search tool to curate a sourced inspiration board.

Introduction

30 Second Summary

Think about someone whose career, mindset, or daily habits you admire. The articles, interviews, and ideas that connect to that person are scattered across dozens of tabs and bookmarks you will never revisit.

In this project, you will build a web app where you describe the person you want to become and Claude searches the real web to curate matching inspiration. The results appear as a Pinterest-style board of cards you can save and revisit over time.

What You'll Build

Imagine typing "I want to be a calm, strategic founder who builds companies that last" and watching a board of real articles, interviews, and philosophies appear, each linked back to its original source on the web.

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

  • Type an aspiration and watch Claude search the real web, returning inspiration cards sourced from blogs, interviews, company pages, and thought leaders.
  • Click through real source links on every card to read the original content, with color-coded category tags for articles, quotes, people, habits, and philosophies.
  • Save multiple boards to revisit your aspirational identities over time, switch between saved boards, and start new ones.
  • Secret Mission: Add a "More like this" button that triggers a deeper web search in any card's direction, appending fresh inspiration to your board.

Do I need an Anthropic API key?

Your Claude Max subscription does not include API access. You will create an API key at console.anthropic.com in Step 1, with usage for this project costing roughly $0.10 to $0.50.

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 the Project

Your inspiration board has two halves. A React frontend lets users type their aspiration and browse the resulting board. An Express backend keeps your Anthropic API key safe by proxying requests to Claude.

In this step, you'll scaffold the frontend with Vite, create the backend from scratch, and confirm both development servers start up correctly.

In this step, get ready to:
  • Scaffold a React + TypeScript frontend with Vite.
  • Create an Express backend with the Anthropic SDK installed.
  • Start both development servers and confirm they respond.
Scaffold the React frontend with Vite and create the Express backend

Vite is a fast build tool that scaffolds a complete React project in seconds. You'll use it to generate the frontend, then create the Express backend by hand.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the React frontend by running this command:
npm create vite@latest client -- --template react-ts

What will this command ask you?

Even with the template flag, Vite may ask follow-up questions. If it asks about a linter, select the default option. If it asks whether to install dependencies and start the dev server now, select No. You'll install and start manually in the next steps.

  • Install the frontend dependencies by running these commands:
cd client
npm install
  • Start the Vite development server to verify the scaffold worked:
npm run dev

Open http://localhost:5173 in your browser. You should see the Vite + React starter page with rotating logos and a counter button.

Don't see the starter page?

  • Make sure the terminal still shows the npm run dev process running. If it exited, re-run the command.
  • Check that port 5173 is not already in use by another application. If Vite picked a different port, the terminal output shows the actual URL.
  • Help me troubleshoot my Vite setup.
  • Stop the development server by pressing Ctrl+C in the terminal.
  • Navigate back to your Desktop by running:
cd ~/Desktop
  • Create the server directory and move into it by running these commands:
mkdir server
cd server
  • Confirm both directories exist by running:
ls ~/Desktop

You should see both client and server listed in the output.

  • Open your code editor. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type the name of your code editor (such as VS Code or Cursor) and press Enter to open it.
  • In your editor, click File in the top menu bar.
  • Click Open Folder.
  • Select the server folder on your Desktop, then click Open.
  • Create a new file called package.json inside the server folder. Add the following code:
{
  "name": "inspo-board-server",
  "type": "module",
  "scripts": {
    "dev": "tsx watch --env-file=.env index.ts"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "0.117.1",
    "cors": "2.8.5",
    "express": "5.1.0"
  },
  "devDependencies": {
    "@types/cors": "2.8.17",
    "@types/express": "5.0.2",
    "tsx": "4.19.4",
    "typescript": "5.8.3"
  }
}

What does this file configure?

  • "type": "module" enables ES module import/export syntax instead of the older require() pattern.
  • The dev script uses tsx to run TypeScript directly without a compile step. The watch flag auto-restarts the server when you save changes.
  • The --env-file=.env flag loads your .env file so your API key is available as an environment variable.
  • Dependencies include @anthropic-ai/sdk for calling Claude, express for the web server, and cors for allowing cross-origin requests from the frontend.
  • Save package.json.
  • Install the backend dependencies by running this command in the server directory:
npm install

You should see npm download and install the packages. A node_modules folder appears in your server directory.

Install failing?

  • Make sure you are inside the server directory, not client. Run pwd to check your current location.
  • Check that package.json was saved correctly. A missing comma or bracket causes JSON parse errors.
  • Help me debug my npm install error.
  • Create a new file called index.ts inside the server folder. Add the following code:
// Import the Express web framework and CORS middleware
import express from "express";
import cors from "cors";

// Create the Express app and enable cross-origin requests
const app = express();
app.use(cors());
app.use(express.json());

// Start the server on port 3001
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this server do?

This is the minimal Express server your frontend will talk to. cors() allows the React app on port 5173 to make requests to the server on port 3001.

express.json() parses incoming JSON request bodies so your endpoints can read them. You'll add the actual Claude API route in the next step.

  • Save index.ts.

✔️ Awesome, I've got everything!

Great. Make sure both package.json and index.ts are saved in your server folder.

ⓧ I'd like to double check the full code

Here is the complete server/package.json:

{
  "name": "inspo-board-server",
  "type": "module",
  "scripts": {
    "dev": "tsx watch --env-file=.env index.ts"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "0.117.1",
    "cors": "2.8.5",
    "express": "5.1.0"
  },
  "devDependencies": {
    "@types/cors": "2.8.17",
    "@types/express": "5.0.2",
    "tsx": "4.19.4",
    "typescript": "5.8.3"
  }
}

Here is the complete server/index.ts:

import express from "express";
import cors from "cors";

const app = express();
app.use(cors());
app.use(express.json());

const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Configure the API key and verify both servers

The backend needs your Anthropic API key to call Claude. You'll store it in a .env file that stays on your machine and never gets shared.

  • Create a new file called .env inside the server folder. Add the following line:
ANTHROPIC_API_KEY=your-key-here

Why a .env file?

Hardcoding an API key directly in your source code is a security risk. If you ever push your code to GitHub, the key is exposed. The .env file keeps secrets separate from your code.

The tsx runner loads the file automatically with the --env-file flag. Your API key becomes available as process.env.ANTHROPIC_API_KEY in your server code.

Before you start both servers, what do you think you'll see in the terminal when the Express server starts?

  • Start the Express backend. In your terminal, make sure you are in the server directory, then run:
npm run dev

You should see Server running on port 3001 in the terminal. The server is now listening for requests.

  • Open a second terminal window. Press Cmd+Space (macOS) or the Windows key (Windows), type Terminal, and press Enter.
  • In the new terminal, start the React frontend by running these commands:
cd ~/Desktop/client
npm run dev

Open http://localhost:5173 in your browser. You should see the Vite + React starter page again, just like before.

Server not starting?

  • If you see a "Cannot find module" error in the server terminal, make sure you ran npm install inside the server directory.
  • If .env is not loading, check that the file is named exactly .env (with the leading dot) and is inside the server folder.
  • If port 3001 is already in use, stop whatever process is on that port or change the PORT value in .env.
  • Help me troubleshoot my Express server startup.

Why two separate servers?

The Anthropic SDK is designed for server-side use. Running it in the browser would expose your API key to anyone who opens DevTools. The Express backend keeps the key hidden.

The React frontend sends requests to your Express server, which forwards them to Claude. This is a common pattern called API proxying.

Your development environment is ready. You have a React frontend and an Express backend running side by side. Next up, you'll build the API endpoint that sends aspirations to Claude and gets inspiration cards back.

Generate Your First Inspiration Board

Your servers are up and running. Now it's time to connect the backend to Claude so it can actually generate inspiration.

In this step, you'll build the core generation flow: you describe who you want to become, your server sends that aspiration to Claude, and Claude returns a structured set of inspiration cards. There's a catch, though. This first version uses only Claude's training knowledge. No web search. No live sources. Pay attention to what comes back.

In this step, get ready to:
  • Build an Express endpoint that sends aspirations to Claude and gets structured inspiration cards back.
  • Define the TypeScript data structures shared between your backend and frontend.
Build the Express endpoint that calls Claude

Your Express server needs three things to generate inspiration cards: an Anthropic client to talk to Claude, a system prompt that tells Claude what format to return, and an endpoint that wires them together.

  • In server/index.ts, add the Anthropic SDK import on the line below import cors from "cors";:
import Anthropic from "@anthropic-ai/sdk";
  • Below app.use(express.json());, add the Anthropic client initialization and system prompt by pasting this code:
// Initialize the Anthropic client with your API key
const client = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
});

// System prompt that tells Claude to return 8 structured inspiration cards
const SYSTEM_PROMPT_NO_SEARCH = `You are an inspiration curator. Given a description of the person someone wants to become, generate exactly 8 inspiration cards that would resonate with that aspirational identity.

Return ONLY a JSON array (no markdown, no code fences) where each object has:
- "type": one of "article", "quote", "person", "habit", "philosophy"
- "title": a compelling title (2-8 words)
- "insight": a 2-3 sentence insight or excerpt that captures why this is inspiring
- "sourceUrl": null
- "tags": array of 2-3 short tags relevant to the aspiration

Focus on diverse types. Include real people, actionable habits, and thought-provoking philosophies. Make it feel like a curated board, not a list.`;

What does this code do?

The client uses your API key from the .env file to authenticate every request to the Anthropic API.

The system prompt is the key instruction. It tells Claude to return exactly 8 cards in a specific JSON format, each with a type, title, insight, and tags. Notice that sourceUrl is hardcoded to null. Claude can only draw from its training data right now, so there are no real URLs to cite.

  • Save server/index.ts.
  • Check the terminal running your server. Because tsx watch auto-restarts on save, you should see Server running on port 3001 printed again.

Server crashing on save?

  • Make sure the import line says import Anthropic from "@anthropic-ai/sdk" with a capital A. The SDK exports a default class.
  • If you see an authentication error, double-check that your .env file in the server/ directory has your real API key assigned to ANTHROPIC_API_KEY.
  • Help me debug this
  • Below the system prompt in server/index.ts, add the TypeScript interface and two helper functions that will parse Claude's response:
// Type definition for each inspiration card
interface InspirationCard {
  type: "article" | "quote" | "person" | "habit" | "philosophy";
  title: string;
  insight: string;
  sourceUrl: string | null;
  tags: string[];
}

// Extract only the text blocks from Claude's response
function extractTextContent(response: Anthropic.Message): string {
  const textBlocks = response.content.filter(
    (block): block is Anthropic.TextBlock => block.type === "text"
  );
  return textBlocks.map((b) => b.text).join("");
}

// Find and parse the JSON array from Claude's text output
function parseCards(text: string): InspirationCard[] {
  const jsonMatch = text.match(/\[[\s\S]*\]/);
  if (!jsonMatch) {
    throw new Error("No JSON array found in response");
  }
  return JSON.parse(jsonMatch[0]);
}

What do these functions do?

Claude's API response can contain different block types (text, tool use, etc.). extractTextContent filters the response to only the text blocks and joins them into a single string.

parseCards then finds the JSON array inside that text using a regex and parses it into typed InspirationCard objects. This is necessary because Claude returns its answer as free text, not as a native JSON response.

  • Save server/index.ts and confirm the server restarts in your terminal with no errors.

Seeing a type error?

  • Check that Anthropic.Message and Anthropic.TextBlock are capitalized exactly as shown. These are types exported by the SDK.
  • Make sure the InspirationCard interface is placed before the two functions that reference it.
  • Help me fix this type error
  • Above the const PORT line at the bottom of server/index.ts, add the generate endpoint by pasting this code:
// Handle POST requests to generate inspiration cards
app.post("/api/generate", async (req, res) => {
  const { aspiration, useSearch = false } = req.body;
  // Validate that the request includes an aspiration string
  if (!aspiration || typeof aspiration !== "string") {
    res.status(400).json({ error: "aspiration is required" });
    return;
  }
  try {
    const systemPrompt = SYSTEM_PROMPT_NO_SEARCH;
    // Call Claude with the system prompt and user's aspiration
    const response = await client.messages.create({
      model: "claude-sonnet-4-5",
      max_tokens: 4096,
      system: systemPrompt,
      tools: [],
      messages: [
        { role: "user", content: `Here is the person I want to become: ${aspiration}` },
      ],
    });
    // Parse Claude's text response into typed card objects
    const text = extractTextContent(response);
    const cards = parseCards(text);
    res.json({ cards });
  } catch (error) {
    console.error("Generation error:", error);
    res.status(500).json({ error: "Failed to generate inspiration board" });
  }
});

What does this endpoint do?

The route extracts an aspiration string from the request body and validates it. It also destructures a useSearch flag (defaulting to false). That flag does nothing yet. You'll wire it up in a later step.

The tools: [] array is empty on purpose. Claude has zero access to the web in this version. It can only generate cards from what it already knows.

  • Save server/index.ts.
  • Check your server terminal. It should restart and print Server running on port 3001 one more time.

Time to test. Before you run the next command, think about this: Claude has no web search tools in this version. What do you expect the sourceUrl field to contain for each card?

  • Open a new terminal tab or window (your server needs to keep running in the existing one).
  • Test the endpoint by running this curl command:
curl -X POST http://localhost:3001/api/generate -H "Content-Type: application/json" -d '{"aspiration":"a calm strategic founder"}'

After a few seconds, you'll see a JSON object with a cards array containing 8 inspiration cards. Each card has a type, title, insight, tags, and sourceUrl. Every single sourceUrl is null. Claude made these up from memory. No real articles, no real links.

Getting an error from curl?

  • If you see Connection refused, make sure your server is still running in its terminal.
  • If you see a 500 error, check that your ANTHROPIC_API_KEY in server/.env is a valid key from console.anthropic.com.
  • If you see a 400 error saying aspiration is required, double-check the -d flag in your curl command matches the example exactly.
  • Help me debug this curl error

✔️ Awesome, I've got everything!

Great. Make sure you've saved server/index.ts and that your curl response shows 8 cards.

ⓧ I'd like to double check the full code

Your server/index.ts file is now too long to show in one block. Scroll through your file and confirm these sections appear in order:

  • Three imports at the top: express, cors, and Anthropic.
  • The app setup lines (cors(), express.json()).
  • The client initialization and SYSTEM_PROMPT_NO_SEARCH constant.
  • The InspirationCard interface, extractTextContent, and parseCards functions.
  • The app.post("/api/generate", ...) route handler.
  • The const PORT and app.listen lines at the very bottom.
Define the card data structure

Your server returns inspiration cards, but the React frontend doesn't know what shape that data takes yet. Defining a TypeScript interface gives your frontend a clear contract for the card data it will receive.

  • Create a new file called types.ts inside the client/src/ directory and paste the following code:
// Shape of each inspiration card returned by Claude
export interface InspirationCard {
  type: "article" | "quote" | "person" | "habit" | "philosophy";
  title: string;
  insight: string;
  sourceUrl: string | null;
  tags: string[];
}

// A saved board containing the aspiration and its generated cards
export interface Board {
  id: string;
  aspiration: string;
  cards: InspirationCard[];
  createdAt: number;
}

What do these interfaces define?

InspirationCard mirrors the JSON shape your server returns. Each card has a type (one of five categories), a title, an insight, an optional sourceUrl, and a tags array.

Board groups a set of cards with the aspiration that generated them. You'll use this in a later step when you add the ability to save and revisit multiple boards.

  • Save types.ts.
  • Confirm the file exists by running this in the terminal where you ran curl:
ls client/src/types.ts

You should see the file path printed back, confirming it was created.

✔️ Awesome, I've got everything!

Great. Make sure you've saved client/src/types.ts.

ⓧ I'd like to double check the full code

// Shape of each inspiration card returned by Claude
export interface InspirationCard {
  type: "article" | "quote" | "person" | "habit" | "philosophy";
  title: string;
  insight: string;
  sourceUrl: string | null;
  tags: string[];
}

// A saved board containing the aspiration and its generated cards
export interface Board {
  id: string;
  aspiration: string;
  cards: InspirationCard[];
  createdAt: number;
}

Your backend can now take any aspiration and return a structured set of inspiration cards. The content looks decent, but those null source URLs tell the real story. Next up, you'll build the visual board so you can actually see these cards.

Build the Masonry Board UI

Your backend is generating inspiration cards, but right now the only way to see them is a raw JSON response in the terminal. You need a visual board to bring those cards to life.

In this step, you will build a Pinterest-style masonry board with a form, styled cards, and a sidebar. You will type an aspiration, click Generate, and watch a grid of cards appear.

In this step, get ready to:
  • Build the input form that sends aspirations to Claude.
  • Create a responsive masonry grid to display inspiration cards.
  • Connect everything with state management and see your first board.
Build the input form and API call

The form is the entry point for the entire app. It captures a text aspiration and sends it to your Express backend.

  • Create the components and hooks directories inside client/src/ by running this command:
mkdir -p client/src/components client/src/hooks
  • Create the AspirationForm component by creating a new file at client/src/components/AspirationForm.tsx and pasting in this code:
import { useState } from "react";

interface AspirationFormProps {
  onSubmit: (aspiration: string) => void;
  disabled: boolean;
}

export function AspirationForm({ onSubmit, disabled }: AspirationFormProps) {
  const [text, setText] = useState("");

  function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (text.trim() && !disabled) {
      onSubmit(text.trim());
    }
  }

  return (
    <form className="aspiration-form" onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="I want to be a calm, strategic founder who builds companies that last..."
        disabled={disabled}
        className="aspiration-input"
      />
      <button type="submit" disabled={disabled || !text.trim()} className="generate-button">
        {disabled ? "Generating..." : "Generate Board"}
      </button>
    </form>
  );
}

What does this component do?

The form captures text input and calls onSubmit with the trimmed aspiration when the learner hits the button.

The disabled prop locks the form during generation, and the button label switches to "Generating..." while a request is in flight.

  • Confirm the file was created by running:
ls client/src/components

You should see AspirationForm.tsx in the output.

File not showing up?

Make sure you ran the mkdir -p command first. If the components directory does not exist, the file creation will fail.

Help me troubleshoot my missing file

Build the masonry grid layout

A masonry layout arranges variable-height cards in columns so they pack tightly without gaps. Think Pinterest.

Why JS columns instead of CSS column-count?

CSS column-count fills columns top-to-bottom, which breaks left-to-right reading order. Card 4 ends up at the top of column 2 instead of below card 3.

The JS approach distributes cards round-robin (card 0 to column 0, card 1 to column 1, and so on). This preserves reading order regardless of card heights and works in every browser.

  • Create the responsive column hook at client/src/hooks/useColumns.ts with this code:
import { useState, useEffect } from "react";

function getColumnCount(): number {
  const width = window.innerWidth;
  if (width < 640) return 1;
  if (width < 900) return 2;
  if (width < 1200) return 3;
  return 4;
}

export function useColumns(): number {
  const [columns, setColumns] = useState(getColumnCount);

  useEffect(() => {
    function handleResize() {
      setColumns(getColumnCount());
    }
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return columns;
}

What does this hook do?

It returns a column count (1, 2, 3, or 4) based on the current window width. Every time the browser resizes, it recalculates and triggers a re-render so the grid adapts.

  • Create the card component at client/src/components/InspirationCard.tsx. Start with the imports, color map, and interface:
import type { InspirationCard as CardType } from "../types";

// Map each card type to a distinct accent color
const TYPE_COLORS: Record<CardType["type"], string> = {
  article: "#3b82f6",
  quote: "#8b5cf6",
  person: "#ec4899",
  habit: "#10b981",
  philosophy: "#f59e0b",
};

interface InspirationCardProps {
  card: CardType;
  onExpand?: (card: CardType) => void;
}
  • Continue adding the component function directly below in the same file:
export function InspirationCard({ card, onExpand }: InspirationCardProps) {
  return (
    <div className="card" style={{ borderTopColor: TYPE_COLORS[card.type] }}>
      <span className="card-type" style={{ color: TYPE_COLORS[card.type] }}>
        {card.type}
      </span>
      <h3 className="card-title">{card.title}</h3>
      <p className="card-insight">{card.insight}</p>
      {card.sourceUrl && (
        <a
          href={card.sourceUrl}
          target="_blank"
          rel="noopener noreferrer"
          className="card-source"
        >
          Read source
        </a>
      )}
      <div className="card-tags">
        {card.tags.map((tag) => (
          <span key={tag} className="card-tag">
            {tag}
          </span>
        ))}
      </div>
      {onExpand && (
        <button className="expand-button" onClick={() => onExpand(card)}>
          More like this
        </button>
      )}
    </div>
  );
}

What does this component do?

Each card renders a colored top border based on its type (article, quote, person, habit, or philosophy) using the TYPE_COLORS map.

The sourceUrl link and onExpand button are both optional. Right now neither will appear because your cards have no source URLs and the parent will not pass onExpand yet.

  • Create the masonry board component at client/src/components/MasonryBoard.tsx with this code:
import { useMemo } from "react";
import { InspirationCard } from "./InspirationCard";
import type { InspirationCard as CardType } from "../types";
import { useColumns } from "../hooks/useColumns";

interface MasonryBoardProps {
  cards: CardType[];
  onExpand?: (card: CardType) => void;
}

export function MasonryBoard({ cards, onExpand }: MasonryBoardProps) {
  const columnCount = useColumns();

  // Distribute cards round-robin across columns
  const columns = useMemo(() => {
    const cols: CardType[][] = Array.from({ length: columnCount }, () => []);
    cards.forEach((card, i) => {
      cols[i % columnCount].push(card);
    });
    return cols;
  }, [cards, columnCount]);

  return (
    <div className="masonry-board">
      {columns.map((column, colIndex) => (
        <div key={colIndex} className="masonry-column">
          {column.map((card, cardIndex) => (
            <InspirationCard
              key={`${colIndex}-${cardIndex}`}
              card={card}
              onExpand={onExpand}
            />
          ))}
        </div>
      ))}
    </div>
  );
}

How does round-robin distribution work?

The useMemo block creates an array of empty column arrays, then assigns each card to a column using i % columnCount. Card 0 goes to column 0, card 1 to column 1, card 2 to column 2, then card 3 wraps back to column 0.

This guarantees left-to-right reading order across every row, no matter how tall or short individual cards are.

  • Verify all your component and hook files exist by running:
ls client/src/components client/src/hooks

You should see AspirationForm.tsx, InspirationCard.tsx, and MasonryBoard.tsx under components, and useColumns.ts under hooks.

Missing a file?

Double-check the file names and paths. InspirationCard.tsx must be inside client/src/components/ and useColumns.ts must be inside client/src/hooks/.

Help me find my missing component file

Connect the components

All the pieces are built. Now you need a sidebar for switching between boards, a stylesheet for the dark theme, and an App.tsx that wires everything together.

  • Create the sidebar component at client/src/components/BoardSidebar.tsx. Start with the imports, interface, and the header section:
import type { Board } from "../types";

interface BoardSidebarProps {
  boards: Board[];
  activeBoardId: string | null;
  onSelect: (id: string) => void;
  onDelete: (id: string) => void;
  onNew: () => void;
}

export function BoardSidebar({
  boards,
  activeBoardId,
  onSelect,
  onDelete,
  onNew,
}: BoardSidebarProps) {
  return (
    <aside className="sidebar">
      <h2 className="sidebar-title">Your Boards</h2>
      <button className="new-board-button" onClick={onNew}>
        + New Board
      </button>
  • Continue adding the board list directly below in the same file:
      <ul className="board-list">
        {boards.map((board) => (
          <li
            key={board.id}
            className={`board-item ${board.id === activeBoardId ? "active" : ""}`}
          >
            <button
              className="board-item-button"
              onClick={() => onSelect(board.id)}
            >
              {board.aspiration.length > 40
                ? board.aspiration.slice(0, 40) + "..."
                : board.aspiration}
            </button>
            <button
              className="board-delete"
              onClick={(e) => {
                e.stopPropagation();
                onDelete(board.id);
              }}
            >
              x
            </button>
          </li>
        ))}
      </ul>
    </aside>
  );
}

What does the sidebar do?

It lists saved boards by their aspiration text (truncated to 40 characters). The active board gets a highlighted background.

Each board has a delete button that calls e.stopPropagation() to prevent the click from also selecting the board.

  • Verify the sidebar file was created by running:
ls client/src/components

You should now see four files: AspirationForm.tsx, BoardSidebar.tsx, InspirationCard.tsx, and MasonryBoard.tsx.

  • Create the stylesheet at client/src/App.css. This replaces the default Vite stylesheet with the project's dark theme. Start with the global reset and layout:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  background: #0f0f0f;
  color: #e5e5e5;
}

.app {
  display: flex;
  min-height: 100vh;
}

.main-content {
  flex: 1;
  padding: 48px 40px;
  max-width: 1200px;
}

.app-title {
  font-size: 32px;
  font-weight: 700;
  margin-bottom: 8px;
  color: #fff;
}

.app-subtitle {
  color: #888;
  margin-bottom: 32px;
  font-size: 16px;
}
  • Continue adding the form and button styles below:
.aspiration-form {
  display: flex;
  gap: 12px;
  margin-bottom: 32px;
}

.aspiration-input {
  flex: 1;
  padding: 14px 18px;
  background: #1a1a1a;
  border: 1px solid #333;
  border-radius: 12px;
  color: #e5e5e5;
  font-size: 15px;
}

.aspiration-input:focus {
  outline: none;
  border-color: #555;
}

.aspiration-input::placeholder {
  color: #555;
}

.generate-button {
  padding: 14px 28px;
  background: #3b82f6;
  border: none;
  border-radius: 12px;
  color: #fff;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}
  • Continue adding the button states and status styles:
.generate-button:hover:not(:disabled) {
  background: #2563eb;
}

.generate-button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.status-message {
  color: #3b82f6;
  margin-bottom: 24px;
  font-size: 14px;
}

.board-aspiration {
  font-size: 20px;
  font-weight: 400;
  color: #888;
  font-style: italic;
  margin-bottom: 32px;
}
  • Continue adding the masonry board and card base styles:
.masonry-board {
  display: flex;
  gap: 16px;
}

.masonry-column {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.card {
  background: #1a1a1a;
  border-radius: 12px;
  padding: 20px;
  border-top: 3px solid;
  transition: transform 0.2s;
}

.card:hover {
  transform: translateY(-2px);
}

.card-type {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-weight: 600;
}
  • Continue adding the card content and source link styles:
.card-title {
  font-size: 17px;
  font-weight: 600;
  color: #fff;
  margin: 8px 0;
}

.card-insight {
  font-size: 14px;
  line-height: 1.6;
  color: #aaa;
  margin-bottom: 12px;
}

.card-source {
  display: inline-block;
  font-size: 12px;
  color: #3b82f6;
  text-decoration: none;
  margin-bottom: 12px;
}

.card-source:hover {
  text-decoration: underline;
}

.card-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 12px;
}

.card-tag {
  font-size: 11px;
  padding: 3px 10px;
  background: #2a2a2a;
  border-radius: 12px;
  color: #888;
}
  • Continue adding the expand button and sidebar base styles:
.expand-button {
  width: 100%;
  padding: 8px;
  background: none;
  border: 1px solid #333;
  border-radius: 8px;
  color: #888;
  font-size: 12px;
  cursor: pointer;
}

.expand-button:hover {
  border-color: #555;
  color: #ccc;
}

.sidebar {
  width: 260px;
  background: #1a1a1a;
  padding: 24px 16px;
  border-right: 1px solid #2a2a2a;
  flex-shrink: 0;
  overflow-y: auto;
}

.sidebar-title {
  font-size: 14px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: #888;
  margin-bottom: 16px;
}
  • Continue adding the sidebar button and board list styles:
.new-board-button {
  width: 100%;
  padding: 10px;
  background: #2a2a2a;
  border: 1px dashed #444;
  color: #ccc;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  margin-bottom: 16px;
}

.new-board-button:hover {
  background: #333;
}

.board-list {
  list-style: none;
}

.board-item {
  display: flex;
  align-items: center;
  border-radius: 8px;
  margin-bottom: 4px;
}

.board-item.active {
  background: #2a2a2a;
}
  • Continue adding the board item button styles:
.board-item-button {
  flex: 1;
  padding: 10px 12px;
  background: none;
  border: none;
  color: #ccc;
  text-align: left;
  cursor: pointer;
  font-size: 13px;
  border-radius: 8px;
}

.board-item-button:hover {
  color: #fff;
}

.board-delete {
  padding: 4px 8px;
  background: none;
  border: none;
  color: #666;
  cursor: pointer;
  font-size: 12px;
  border-radius: 4px;
}

.board-delete:hover {
  color: #f87171;
  background: rgba(248, 113, 113, 0.1);
}
  • Finish App.css with the responsive media query:
@media (max-width: 768px) {
  .app {
    flex-direction: column;
  }

  .sidebar {
    width: 100%;
    border-right: none;
    border-bottom: 1px solid #2a2a2a;
    max-height: 200px;
  }

  .main-content {
    padding: 24px 16px;
  }

  .aspiration-form {
    flex-direction: column;
  }
}

What do these styles do?

The stylesheet creates a dark theme with a sidebar on the left and the main content area on the right. Cards get a colored top border, a hover lift effect, and tag chips with subtle backgrounds.

The media query collapses the sidebar above the content on screens narrower than 768px.

  • Save App.css.
  • Now replace the contents of client/src/App.tsx with the main app component. Start with imports, helper functions, and state declarations:
import { useState, useEffect } from "react";
import { AspirationForm } from "./components/AspirationForm";
import { MasonryBoard } from "./components/MasonryBoard";
import { BoardSidebar } from "./components/BoardSidebar";
import type { Board, InspirationCard } from "./types";
import "./App.css";

const API_BASE = "http://localhost:3001/api";

// Read saved boards from localStorage
function loadBoards(): Board[] {
  const stored = localStorage.getItem("inspo-boards");
  return stored ? JSON.parse(stored) : [];
}

// Write boards to localStorage
function saveBoards(boards: Board[]) {
  localStorage.setItem("inspo-boards", JSON.stringify(boards));
}

function App() {
  const [boards, setBoards] = useState<Board[]>(loadBoards);
  const [activeBoard, setActiveBoard] = useState<Board | null>(null);
  const [loading, setLoading] = useState(false);
  const [status, setStatus] = useState("");

  // Show the most recent board on load
  useEffect(() => {
    if (boards.length > 0 && !activeBoard) {
      setActiveBoard(boards[0]);
    }
  }, [boards, activeBoard]);
  • Continue adding the handleGenerate function directly below:
  async function handleGenerate(aspiration: string) {
    setLoading(true);
    setStatus("Searching the web for inspiration...");
    try {
      const response = await fetch(`${API_BASE}/generate`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ aspiration, useSearch: true }),
      });
      if (!response.ok) throw new Error("Failed to generate");
      const data = await response.json();
      const newBoard: Board = {
        id: crypto.randomUUID(),
        aspiration,
        cards: data.cards,
        createdAt: Date.now(),
      };
      const updatedBoards = [newBoard, ...boards];
      setBoards(updatedBoards);
      saveBoards(updatedBoards);
      setActiveBoard(newBoard);
    } catch (error) {
      console.error(error);
      setStatus("Something went wrong. Check your API key and try again.");
    } finally {
      setLoading(false);
      setStatus("");
    }
  }
  • Continue adding the board management handlers:
  function handleDeleteBoard(id: string) {
    const updatedBoards = boards.filter((b) => b.id !== id);
    setBoards(updatedBoards);
    saveBoards(updatedBoards);
    if (activeBoard?.id === id) {
      setActiveBoard(updatedBoards[0] || null);
    }
  }

  function handleNewBoard() {
    setActiveBoard(null);
  }
  • Finish App.tsx with the return statement and default export:
  return (
    <div className="app">
      <BoardSidebar
        boards={boards}
        activeBoardId={activeBoard?.id || null}
        onSelect={(id) => setActiveBoard(boards.find((b) => b.id === id)!)}
        onDelete={handleDeleteBoard}
        onNew={handleNewBoard}
      />
      <main className="main-content">
        <h1 className="app-title">Inspo Board</h1>
        <p className="app-subtitle">
          Describe the person you want to become. Claude will search the web to
          curate your inspiration.
        </p>
        <AspirationForm onSubmit={handleGenerate} disabled={loading} />
        {status && <p className="status-message">{status}</p>}
        {activeBoard && (
          <>
            <h2 className="board-aspiration">"{activeBoard.aspiration}"</h2>
            <MasonryBoard cards={activeBoard.cards} />
          </>
        )}
      </main>
    </div>
  );
}

export default App;

What does App.tsx do?

The loadBoards and saveBoards helpers read and write boards to localStorage so your inspiration persists across page refreshes.

When you click Generate, handleGenerate POSTs your aspiration to the backend, wraps the returned cards into a new Board object, and saves it. The sidebar shows all saved boards and lets you switch between them.

  • Save App.tsx.

Before you test, what do you think will happen when you type an aspiration and click Generate? Will the cards have real source links?

  • Make sure both dev servers are still running (the React frontend on localhost:5173 and the Express backend on localhost:3001).
  • Open http://localhost:5173 in your browser.
  • Type an aspiration like a disciplined creator who ships weekly into the input field.
  • Click Generate Board.

A masonry grid of styled cards appears with titles, insights, and tag chips. The sidebar shows your board's aspiration text.

Look closely at the cards. You will notice there are no source links. The insights feel generic because Claude is generating them from training data alone, not from real web sources.

Board not rendering?

  • Check the browser console (F12 or Cmd+Option+I) for import errors. A typo in a file name or path will prevent the app from loading.
  • Make sure both servers are running. If the backend is down, the fetch will fail silently and no cards appear.
  • Confirm your ANTHROPIC_API_KEY is set correctly in server/.env. An invalid key returns a 500 error from the backend.

Help me debug why my board is not rendering

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files.

ⓧ I'd like to double check the full code

Your project now has multiple files across client/src/. Scroll up and confirm each file matches what you pasted:

  • client/src/components/AspirationForm.tsx (the form with input and Generate button).
  • client/src/hooks/useColumns.ts (the responsive column hook).
  • client/src/components/InspirationCard.tsx (two code blocks: type config and component body).
  • client/src/components/MasonryBoard.tsx (the round-robin column distribution).
  • client/src/components/BoardSidebar.tsx (two code blocks: header and board list).
  • client/src/App.css (nine code blocks building the complete dark theme).
  • client/src/App.tsx (four code blocks: imports/state, handleGenerate, handlers, JSX return).

Make sure all blocks are present in each file, in order, with no duplicate or missing sections.

Your board looks great, but the content is all from Claude's training data. Next up, you will give Claude access to the real web so your cards get actual source URLs you can click and read.

Give Claude the Web

You have a beautiful masonry board that renders styled cards with titles, insights, and category tags. But look at the actual content on those cards.

There are no real sources. No clickable links. Claude is pulling everything from its training data, so the content feels generic and unverifiable. In this step, you give Claude the ability to search the real web, transforming your board from made-up inspiration into curated, sourced content you can actually click through and read.

In this step, get ready to:
  • Add the web_search server tool to the Claude API call.
  • Handle the pause_turn stop reason so Claude can finish multi-step searches.
  • See real source URLs appear on your inspiration cards.
Add a web-search-enabled system prompt

The current system prompt tells Claude to return null for every sourceUrl. You need a second prompt that instructs Claude to search the web and include real URLs in its response.

  • In server/index.ts, find the closing backtick of SYSTEM_PROMPT_NO_SEARCH.
  • Add the following new constant directly below it:
// Prompt that instructs Claude to search the web for real sources
const SYSTEM_PROMPT_WITH_SEARCH = `You are an inspiration curator. Given a description of the person someone wants to become, search the web to find real articles, interviews, quotes, people, and philosophies that match this aspirational identity.

Search for content from business publications, personal blogs, interviews, company pages, and thought leaders. Find real, specific, current sources.

After searching, return ONLY a JSON array (no markdown, no code fences) where each object has:
- "type": one of "article", "quote", "person", "habit", "philosophy"
- "title": a compelling title (2-8 words)
- "insight": a 2-3 sentence insight or excerpt that captures why this is inspiring for someone with this aspiration
- "sourceUrl": the real URL where this content was found (or null if from general knowledge)
- "tags": array of 2-3 short tags relevant to the aspiration

Generate exactly 8 cards. Prioritize real, sourced content over general knowledge. Make it feel like a curated inspiration board.`;

What changed in this prompt?

The original prompt told Claude to set sourceUrl to null. This new prompt tells Claude to search the web and fill in real URLs where it found the content.

The instruction to search is part of the prompt, but the actual search capability comes from the tool you add next.

Enable the web_search tool in the API call

Claude's web_search is a server tool. Unlike client tools where your code runs the tool and sends results back, Anthropic runs the search on their infrastructure. You just enable it in the request and read the results.

  • In server/index.ts, scroll down to the try block inside app.post("/api/generate", ...).
  • Find this code:
    const systemPrompt = SYSTEM_PROMPT_NO_SEARCH;

    const response = await client.messages.create({
      model: "claude-sonnet-4-5",
      max_tokens: 4096,
      system: systemPrompt,
      tools: [],
      messages: [
        {
          role: "user",
          content: `Here is the person I want to become: ${aspiration}`,
        },
      ],
    });

    const text = extractTextContent(response);
  • Replace everything from const systemPrompt down to (but not including) const text = extractTextContent(response); with the following code:
    // Build the tools array: include web_search when the request asks for it
    const tools: Anthropic.Messages.Tool[] = useSearch
      ? [{ type: "web_search_20250305", name: "web_search", max_uses: 10 }]
      : [];

    // Pick the matching system prompt
    const systemPrompt = useSearch
      ? SYSTEM_PROMPT_WITH_SEARCH
      : SYSTEM_PROMPT_NO_SEARCH;

    // Use let so we can reassign during the pause_turn loop later
    let response = await client.messages.create({
      model: "claude-sonnet-4-5",
      max_tokens: 4096,
      system: systemPrompt,
      tools,
      messages: [
        {
          role: "user",
          content: `Here is the person I want to become: ${aspiration}`,
        },
      ],
    });

What does this code do?

The route handler now checks the useSearch flag from the request body. When it is true, the handler passes the web_search_20250305 tool to Claude and uses the search-enabled prompt.

The max_uses: 10 parameter caps how many web searches Claude can make per request. Claude decides on its own what to search for and how many searches it needs to build your board.

What is a server tool?

With traditional "client" tools, Claude tells your code what tool to run, your code executes it, and you send the result back. With server tools like web_search, Anthropic handles the execution entirely. You never construct a tool_result block for server tools.

Claude autonomously decides what queries to search, reads the results, and decides whether to search again. All of this happens on Anthropic's infrastructure before the response reaches your server.

  • Save server/index.ts.
  • The server should auto-restart thanks to tsx watch. Test the endpoint by running this in a new terminal:
curl -X POST http://localhost:3001/api/generate -H "Content-Type: application/json" -d '{"aspiration":"a calm strategic founder","useSearch":true}'

This request takes longer than before because Claude is actively searching the web. After 15-30 seconds, you should see a JSON response with cards that include real sourceUrl values pointing to actual web pages.

Getting a 500 error or timeout?

  • Check your server terminal for error details. If you see an authentication error, confirm your ANTHROPIC_API_KEY in server/.env is valid and has credits.
  • Make sure you changed const response to let response in the API call. You will need this to be reassignable for the next chunk.
  • If the response returns but cards have no sourceUrl values, confirm you included "useSearch":true in the curl request body.

Why is my web search API call failing?

Handle the pause_turn stop reason

When Claude uses server tools, Anthropic runs an internal loop of searches. If that loop hits its iteration limit before Claude finishes, the API returns a pause_turn stop reason instead of end_turn. Without handling this, some requests would return incomplete results.

  • In server/index.ts, find the closing }); of the client.messages.create() call you just updated.
  • Add the following code between that closing }); and the const text = extractTextContent(response); line:
    // Reconstruct the message history for multi-step conversations
    const messages: Anthropic.Messages.MessageParam[] = [
      {
        role: "user",
        content: `Here is the person I want to become: ${aspiration}`,
      },
    ];

    // Continue the conversation if Claude's search loop was paused
    while (response.stop_reason === "pause_turn") {
      messages.push({ role: "assistant", content: response.content });
      response = await client.messages.create({
        model: "claude-sonnet-4-5",
        max_tokens: 4096,
        system: systemPrompt,
        tools,
        messages,
      });
    }

What does this code do?

The messages array reconstructs the conversation history. The while loop checks if Claude's response has a pause_turn stop reason. If so, it appends Claude's partial response to the conversation and re-sends the request so Claude can continue searching where it left off.

This is the standard pattern for handling server tools. The loop repeats until Claude finishes with a different stop reason like end_turn.

  • Save server/index.ts.

✔️ Awesome, I've got everything!

Make sure you have saved server/index.ts. The server should auto-restart.

ⓧ I'd like to double check the full code

Your server/index.ts is now longer than 30 lines, so scroll through and confirm each section is present in this order:

  • Imports (express, cors, Anthropic) and Express setup at the top.
  • SYSTEM_PROMPT_NO_SEARCH constant (unchanged from before).
  • SYSTEM_PROMPT_WITH_SEARCH constant (the new prompt you just added).
  • InspirationCard interface, extractTextContent, and parseCards helper functions.
  • The updated POST /api/generate route with the conditional tools array, conditional systemPrompt, let response, and the pause_turn while loop.
  • The server listening on PORT at the bottom.

Server not restarting?

  • Check your server terminal for syntax errors. A missing comma or bracket in the new code will prevent the server from starting.
  • Make sure the while loop is placed after the }); closing the client.messages.create() call and before the const text = extractTextContent(response); line.

Help me fix my server/index.ts

See web-sourced cards in your browser

Your frontend already sends useSearch: true in every generate request, and the InspirationCard component already renders a clickable "Read source" link when sourceUrl is present. Now that the backend actually returns real URLs, the full experience comes alive.

Before you generate a new board, think about what you expect to see this time. Will the cards look the same as before, or different? What specifically should change?

  • Switch back to your browser at localhost:5173.
  • Type an aspiration like a disciplined creator who ships weekly and click Generate Board.

You will see the status message "Searching the web for inspiration..." while Claude works. Generation takes longer now because Claude is making multiple web searches behind the scenes.

When the board loads, the difference is dramatic. Cards now have blue "Read source" links beneath the insight text. Click one. It opens a real web page in a new tab. The titles reference specific articles, interviews, and people that Claude found on the web. Compare this to the generic, sourceless cards you generated in the previous step.

Why does this feel so different?

Without web search, Claude generates plausible-sounding content from its training data. The insights feel like paraphrases of common knowledge. With web search enabled, Claude finds real articles and interviews, quotes specific people, and links to actual sources.

This is the core pattern for AI-native apps. Instead of relying on what the model already knows, you give it tools to go find real, current information. The model decides when and what to search. Your code just enables the capability.

Your board now pulls real inspiration from across the web. Next up, you will add persistence so these boards survive a page refresh and you can build a collection of aspirational identities over time.

Save and Revisit Your Boards

In the last step, you gave Claude access to the real web. Your boards now contain sourced, clickable inspiration instead of generic training-data guesses.

There is one problem: refresh the page and everything disappears. Inspiration boards are only useful if you can come back to them. In this step, you will verify that your localStorage persistence works and add a handler for expanding boards with deeper, focused searches.

In this step, get ready to:
  • Verify that generated boards survive a page refresh.
  • Test the board switcher sidebar with multiple saved boards.
  • Add a handler that expands boards with deeper searches and persists the results.
Verify board persistence with localStorage

Your App.tsx already has two helper functions at the top of the file that handle persistence. Every time you generate a board, it is saved automatically.

How does localStorage persistence work?

Two small functions drive all the persistence in your app:

  • loadBoards() reads the inspo-boards key from localStorage and parses the JSON back into an array of boards. If nothing is stored yet, it returns an empty array.
  • saveBoards() takes the current boards array, serializes it to JSON, and writes it to the same key. Every function that changes the boards array calls saveBoards() immediately after updating state.
  • The useState<Board[]>(loadBoards) call in the component initializes the boards state directly from localStorage on first render. This is what makes boards reappear after a page refresh.

Before you test this, think about what you expect to happen. You have generated at least one board already. What will you see when you refresh the page?

  • Switch to the browser tab with your app at localhost:5173.
  • If you do not already have a board on screen, type an aspiration like "a disciplined creator who ships weekly" and click Generate Board.
  • Once your board appears, refresh the page with Cmd+R (macOS) or Ctrl+R (Windows).

Your board is still there. The aspiration title, all the cards, and their source links survived the refresh because handleGenerate called saveBoards() the moment it received the cards from Claude.

Board disappeared after refresh?

  • Open DevTools with Cmd+Option+I (macOS) or F12 (Windows). Go to the Application tab and look under Local Storage. You should see an inspo-boards entry. If it is missing, check that saveBoards(updatedBoards) is called inside handleGenerate.
  • Make sure you did not accidentally clear browser data or open a private/incognito window. localStorage is scoped per origin and browsing mode.

Still stuck? Ask for help.

Add the board expansion handler

Your sidebar already lets you switch between boards, delete them, and create new ones. The last piece of board management is letting the user go deeper on a card that resonates.

The handleExpand function will take a single card, send its context to the backend, and append the new cards to the active board. The expanded board gets saved to localStorage just like any other change.

  • In App.tsx, find the handleGenerate function. Add the following new function directly below it, before handleDeleteBoard:
  async function handleExpand(card: InspirationCard) {
    if (!activeBoard) return;

    setLoading(true);
    setStatus(`Finding more like "${card.title}"...`);

    try {
      // Collect existing source URLs to filter out duplicates
      const existingUrls = activeBoard.cards
        .map((c) => c.sourceUrl)
        .filter(Boolean);

      // Request deeper inspiration based on this card
      const response = await fetch(`${API_BASE}/expand`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          aspiration: activeBoard.aspiration,
          cardTitle: card.title,
          cardInsight: card.insight,
          existingUrls,
        }),
      });

What does this code do?

  • The function takes the card the user clicked and sends it along with the original aspiration to a /api/expand endpoint on the backend.
  • It gathers all existing source URLs from the board so Claude can avoid returning duplicates.
  • Continue adding the rest of handleExpand directly below the fetch call you just pasted:
      if (!response.ok) throw new Error("Failed to expand");

      // Append new cards to the current board
      const data = await response.json();
      const updatedBoard: Board = {
        ...activeBoard,
        cards: [...activeBoard.cards, ...data.cards],
      };

      // Save the expanded board to localStorage
      const updatedBoards = boards.map((b) =>
        b.id === updatedBoard.id ? updatedBoard : b
      );
      setBoards(updatedBoards);
      saveBoards(updatedBoards);
      setActiveBoard(updatedBoard);
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
      setStatus("");
    }
  }

What does this code do?

  • It takes the new cards from the response and appends them to the active board using the spread operator.
  • It calls saveBoards() so the expanded board is persisted to localStorage immediately. Close the tab and come back later and the expanded cards are still there.
  • Save App.tsx.
  • Refresh the app in your browser. Your existing board should still be there, confirming you did not break persistence.

Now wire the handler to the board UI so each card can trigger an expansion.

  • In App.tsx, scroll down to the return statement and find this line:
            <MasonryBoard cards={activeBoard.cards} />
  • Add the onExpand prop so the line now looks like this:
            <MasonryBoard cards={activeBoard.cards} onExpand={handleExpand} />
  • Save App.tsx and refresh the browser.

You will notice a More like this button now appears at the bottom of every card. This button calls handleExpand, but the backend endpoint it needs does not exist yet. You will build that in the Secret Mission.

Not seeing the More like this button?

  • Make sure you passed onExpand={handleExpand} to the MasonryBoard component. Without this prop, the button is hidden.
  • Check the browser console for TypeScript errors. If handleExpand is not defined before the return statement, the app will not compile.

Help me debug the missing button.

Test the board switcher with multiple boards

The sidebar on the left is your BoardSidebar component. It lists every board you have saved, lets you switch between them, and lets you delete ones you no longer want.

  • Click the + New Board button in the sidebar.
  • Type a completely different aspiration, like "a patient teacher who makes hard things simple".
  • Click Generate Board and wait for Claude to search the web.

A new board appears in the main area, and the sidebar now shows two entries. The new board is highlighted as the active one.

  • Click on your first board's title in the sidebar.

The masonry grid swaps to show your original cards. The aspiration heading above the board updates too.

  • Hover over one of the board entries in the sidebar and click the x button to delete it.

The board disappears from the sidebar. If you deleted the active board, the app automatically switches to the remaining one.

Before you run the final check, predict what will happen when you refresh the page now. Will you see both boards? One? None?

  • Refresh the page with Cmd+R (macOS) or Ctrl+R (Windows).

The surviving board is still there. The deleted board is gone. Every state change you made was persisted to localStorage in real time.

Awesome, I've got everything!

Great. Make sure you have saved App.tsx.

I'd like to double check the full code

Your App.tsx is the only file that changed in this step. Scroll up and confirm it contains all of these sections in order:

  • Imports for useState, useEffect, AspirationForm, MasonryBoard, BoardSidebar, and types.
  • loadBoards() and saveBoards() helper functions at the top.
  • State hooks: boards, activeBoard, loading, status.
  • useEffect that sets the active board on first load.
  • handleGenerate function.
  • handleExpand function (the new one you just added).
  • handleDeleteBoard and handleNewBoard functions.
  • The return statement with BoardSidebar, AspirationForm, and MasonryBoard with onExpand={handleExpand}.

Your boards now survive refreshes, and you can build a whole collection of aspirational identities over time. Next up, you will take on the Secret Mission and give each card the power to spark a deeper search.

Secret mission

Add "More Like This"

A static board is good, but real inspiration is iterative. You see something that resonates and want to go deeper. "More like this" lets you click any card and have Claude search for more inspiration in that specific direction, appending new cards to your board.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no cloud resources or ongoing costs. The Anthropic API is pay-per-use and incurs no charges when you are not making calls.

Resources you used:

  • Local project files: the client and server directories on your Desktop.
  • Anthropic API key stored in server/.env.
  • Saved inspiration boards in your browser's localStorage.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Keep everything running

No action needed. Choose this if you are still actively building or want to use the app.

  • Your code lives in the client and server folders on your Desktop. Start the dev servers anytime to use the app.
  • Your saved boards are stored in your browser's localStorage and persist across sessions.
  • Your Anthropic API key in server/.env only incurs charges when you actively generate boards.

Pause - I'll come back to this later

Stop the dev servers to free up terminal sessions. Your files and boards stay exactly where they are.

  • Press Ctrl+C in both terminal windows to stop the client and server dev servers.
  • Your project files remain on your Desktop. Your boards remain in localStorage. No ongoing costs are incurred while the servers are stopped.
  • To resume, open two terminals, cd into client and server respectively, and run npm run dev in each.

Delete - I don't want to use this again

Remove all project files, clear saved boards, and optionally revoke your API key.

  • Press Ctrl+C in both terminal windows to stop the dev servers if they are still running.

Delete the project files by running these commands in a terminal.

macOS

cd ~/Desktop
rm -rf client server

Windows

cd ~\Desktop
rmdir /s /q client server
  • Clear your saved boards from localStorage. Open your browser's developer console and run:
localStorage.removeItem("inspo-boards")
  • Optionally, revoke your Anthropic API key. Go to console.anthropic.com, navigate to Settings then API Keys, and delete the key you created for this project.

Nice Work!

Nice Work!

You just built a full AI-native web application that turns aspirational identities into curated, web-sourced inspiration boards. That is a real, demo-able tool you can use and share.

What you learned:

  • Built an AI-powered inspiration board using Claude's web_search server tool, where Anthropic handles the search execution and Claude autonomously decides what to search for.
  • Experienced the dramatic difference between AI responses from training data versus live web search, understanding why tool use is the key pattern in AI-native apps.
  • Implemented a responsive masonry layout using JS column distribution for correct reading order, and added localStorage persistence so your boards survive page refreshes.
  • Secret Mission: Added iterative deepening with a "More like this" button, letting you click any card and have Claude search the web for deeper inspiration in that specific direction.

Ready to quiz yourself?