Build CampusNotice with AWS Amplify

Build and deploy a real-time campus announcement board with AWS Amplify.

Introduction

30 Second Summary

Important campus updates often arrive through scattered channels. A deadline can disappear in an inbox before the right student sees it.

In this project, you will build a searchable campus announcement board with React and AWS Amplify. The finished feed stays synchronized across browser tabs while visitors search its cloud-backed announcements.

What You'll Build

A sample notice published at your live CampusNotice URL appears in another browser tab immediately. It remains searchable after a refresh.

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

  • A public CampusNotice app where you can publish sample announcements with a title, organization, category, message, and expiry date.
  • A real-time announcement feed that synchronizes new notices across browser tabs and preserves them after a refresh.
  • Search and category filters that narrow the feed by keyword, Academic, Events, Deadlines, or Emergency.
  • Secret Mission: Add a control that hides announcements after their expiry date.

Are there any prerequisites?

You need basic JavaScript or TypeScript familiarity plus existing AWS and GitHub accounts. The guide starts in Visual Studio Code on Windows with checks for Node.js and Git.

Before We Start

Before any hands-on work begins, this is your moment to define what CampusNotice proves. You are building a small public prototype that centralizes campus announcements before a future version adds production authentication and reminders.

A clear project goal keeps every later decision focused on the visitor experience. Your statement should connect the announcement tools to the everyday problem of important campus information being scattered across different places.

Frame the finished workflow around what a visitor can do. Include publishing announcements, finding them through search and filters, receiving live updates, and seeing important information in one place.

Prepare the Windows Workspace

CampusNotice needs a dependable path from a local React starter to an AWS deployment. A verified toolchain keeps that path repeatable.

In this step, you'll verify Node.js, npm, and Git. You'll also deploy the official AWS Amplify starter from GitHub before running the same Vite app locally.

In this step, get ready to:
  • Verify compatible Node.js, npm, and Git versions.
  • Deploy the official Amplify starter from a GitHub repository.
  • Run a local copy of the deployed starter.
Install and verify the local tools

Node.js runs the Vite development server. npm installs the packages listed by the starter.

Git connects your local files to the repository that Amplify deploys. All three tools need to work from the same terminal.

  • Switch back to Visual Studio Code from earlier.
  • Open its integrated terminal panel.
  • Check Node.js and npm by running these commands:
node -v
npm -v

How to read the results

  • The first line shows your installed Node.js version.
  • The second line shows the npm version bundled with your installation.
  • This starter requires Node.js 20.20.0 or higher.
  • This starter requires npm 10.8.0 or higher.

How the tools fit together

Node.js provides the runtime that executes Vite. npm uses that runtime to install packages and start the development server.

✔️ Both versions meet the minimum

Your runtime is ready. Node.js and npm can run the current Amplify starter.

ⓧ I see an older version

Your existing installation needs an update before the starter can run reliably.

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js v24.21.0 LTS.
  • Close Visual Studio Code before running the installer.
  • Run the downloaded Windows installer.
  • Keep the default option on each installer screen.
  • Press the Windows key after the installation finishes.
  • Type Visual Studio Code and press Enter.
  • Repeat the version checks in the new terminal by running:
node -v
npm -v

What confirms the update?

You should now see Node.js v24.21.0 and an npm version of at least 10.8.0.

ⓧ A command is not available

Windows cannot find the current Node.js installation from your terminal. Installing the current LTS release adds Node.js and npm together.

  • Open the official Node.js download page.
  • Download the Windows installer for Node.js v24.21.0 LTS.
  • Close Visual Studio Code before running the installer.
  • Run the downloaded Windows installer.
  • Keep the default option on each installer screen.
  • Press the Windows key after the installation finishes.
  • Type Visual Studio Code and press Enter.
  • Verify the new installation in the terminal by running:
node -v
npm -v

What confirms the installation?

You should now see a version on both lines. Node.js v24.21.0 meets the starter requirement.

  • Check Git in the same terminal by running:
git version

What does this check do?

The command asks Git to print its version information. A version result confirms that the terminal can use Git.

✔️ I see Git version information

Your local Git installation is ready to clone the CampusNotice repository.

ⓧ The Git command fails

Git needs to be installed before you can copy the starter repository to your computer.

  • Approve the installation if Windows asks for permission.
  • Install Git for Windows by running this command:
winget install --id Git.Git -e --source winget

What does this command do?

The command asks Windows Package Manager to install the exact Git.Git package from the configured winget source.

  • Close Visual Studio Code after the installation completes.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter.
  • Verify Git in the new terminal by running:
git version

What confirms Git is ready?

You should now see Git version information. The reopened terminal can access the new installation.

Git still does not work?

Close every open Visual Studio Code window before reopening the application. This refreshes the terminal environment.

If the installation command fails, help me diagnose my Git installation.

Create and deploy the official starter

The official starter gives your repository a compatible React frontend and an Amplify backend. Connecting its main branch creates the deployment path you use throughout the project.

  • Open the AWS Amplify React quickstart.
  • Use the starter React template on the quickstart page to begin creating a GitHub repository.
  • Sign in to GitHub if the template asks you to authenticate.
  • Enter campusnotice as the repository name.
  • Create the repository from the template.

That first handoff is complete. Your campusnotice repository now contains the official starter on its main branch.

Check the cost boundary

Connecting this starter creates metered hosting and backend resources. AWS Free Tier allowances are not a hard spending cap.

Amplify Hosting and its backend services are billed separately outside applicable allowances. This project keeps AWS WAF disabled.

  • Confirm that your AWS account has applicable Free Tier allowances or credits.
  • Return to the AWS Amplify React quickstart.
  • Open AWS Amplify from the quickstart deployment flow.
  • Sign in to AWS if prompted.
  • Select GitHub as the repository provider.
  • Authorize Amplify to access GitHub if prompted.

Why connect the repository?

Amplify watches the selected branch for new commits. A push to main starts a build that can update the frontend and backend resources.

  • Choose the campusnotice repository.
  • Choose the main branch.
  • Keep the remaining deployment settings unchanged.
  • Leave AWS WAF disabled.
  • Choose Save and deploy.

The first deployment can take several minutes while Amplify builds the starter. A quiet build screen during that wait is normal.

  • Wait for the main deployment to succeed.
  • Open the deployed starter from the completed deployment.
  • Confirm the official React starter loads in your browser.

Your cloud starter is live. Amplify can now rebuild CampusNotice whenever you push changes to main.

Prepare and run the local copy

Your local copy needs to come from the same repository that Amplify watches. This keeps local changes aligned with the deployed main branch.

  • Return to the campusnotice repository on GitHub.
  • Copy the repository URL.
  • Store the copied URL here: <repository>.
  • Switch back to Visual Studio Code from earlier.
  • Move the terminal to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The command sets your Desktop as the location for the local project. You can find the cloned folder easily from Windows File Explorer.

  • Clone your GitHub repository by running this command:
git clone [[REPOSITORY_URL="<repository>"]]

What does this command do?

Git copies the repository history and starter files into a new campusnotice folder on your Desktop.

You should see Git report that it cloned the repository. The campusnotice folder is now available locally.

Unable to clone the repository?

Confirm that the copied URL belongs to your campusnotice repository. Sign in to GitHub through the browser if Git requests authentication.

If cloning still fails, help me diagnose the Git clone problem.

  • Enter the cloned folder and install its dependencies by running these commands:
cd campusnotice
npm install

What do these commands do?

  • The first command moves the terminal into the local campusnotice folder.
  • The second command installs the packages defined by package.json.

You should see npm finish installing the starter dependencies. Your terminal remains inside the campusnotice folder.

Dependency installation failed?

Confirm that your terminal is inside the cloned campusnotice folder. Check that the Node.js and npm versions still meet the starter minimums.

If npm still fails, help me diagnose the dependency installation.

Placing the backend configuration can be fiddly because browsers usually save it in your Downloads folder. The file belongs at the top level of campusnotice.

  • Return to the Amplify deployment from earlier.
  • Open Deployed backend resources.
  • Choose Download amplify_outputs.json file.
  • Locate the downloaded amplify_outputs.json file in Windows File Explorer.
  • Move amplify_outputs.json into the top level of the local campusnotice folder.

In the Visual Studio Code Explorer, you should see amplify_outputs.json beside package.json. This confirms the generated backend configuration is in the project root.

The Vite development server keeps control of the terminal while it runs. Leave that terminal open while you view the starter.

Before you run the server, do you expect the command to launch another cloud deployment or a local development page?

  • Start the local Vite development server by running:
npm run dev

What does this command do?

The command uses the development script from package.json to start Vite. The terminal prints the local address when the server is ready.

  • Open your browser.
  • Go to http://localhost:5173.

You should see the official Amplify React starter running from your local campusnotice folder. The terminal stays active while the page is available.

Local starter does not open?

Confirm that the terminal shows the local Vite address. Keep the development server running while you open the browser.

Check that amplify_outputs.json sits beside package.json in the campusnotice folder.

If the page still fails, help me debug the local Amplify starter.

✔️ The starter opens locally

Your local starter is connected to the deployed project. Keep the development server running for the next step.

ⓧ I'd like to double check the full code

You have not changed the starter source yet. Confirm these project artifacts match the end of this step.

  • amplify_outputs.json: Downloaded generated Amplify backend configuration for the deployed starter backend and saved at the project root.
  • package.json: Starter React/Vite/Amplify project package manifest with an npm run dev script.
  • src/App.tsx: Official Amplify React starter application source remains in place.
  • src/index.css: Starter stylesheet remains in place.
  • src/main.tsx: Starter React entry point remains in place.

Your cloud deployment and local workspace now run from the same starter. Next up, you'll replace that starter interface with the first visible CampusNotice announcement board.

Build the Local Announcement Board

Your Vite starter is running at http://localhost:5173. You now have a safe place to turn the generic template into CampusNotice.

The first publishing loop uses React state to hold announcements while the page is active. This gives you a working interface that you can test in the browser.

This checkpoint keeps the data path local. No announcement reaches AWS during this step.

In this step, get ready to:
  • Replace the starter interface with a local CampusNotice publishing form and announcement feed.
  • Apply the responsive two-column layout and category styling.
  • Test how temporary announcements behave across fresh browser sessions.
Replace the starter with a local board

The component starts with two sample announcements. Each form field updates a draft object before the submitted notice joins the visible feed.

  • Select src/App.tsx from the Explorer sidebar in Visual Studio Code.
  • Press Cmd+A on macOS or Ctrl+A on Windows to select the starter code.
  • Replace the selection by pasting this local board component:
import { useState } from "react";
import type { FormEvent } from "react";
type Announcement = { id: number; title: string; organization: string; category: string; content: string; expiresOn: string; createdAt: string };
const categories = ["Academic", "Events", "Deadlines", "Emergency"];
const emptyDraft = { title: "", organization: "", category: "Academic", content: "", expiresOn: "" };
const initialAnnouncements: Announcement[] = [{ id: 1, title: "Library hours extended", organization: "University Library", category: "Academic", content: "The main library will stay open until midnight during exam week.", expiresOn: "2026-12-18", createdAt: "2026-09-21" }, { id: 2, title: "Student club showcase", organization: "Student Activities", category: "Events", content: "Meet campus organizations in the student center this Friday.", expiresOn: "2026-11-06", createdAt: "2026-09-20" }];
function App() {
  // Keep temporary notices and form values in component state.
  const [announcements, setAnnouncements] = useState(initialAnnouncements);
  const [draft, setDraft] = useState(emptyDraft);
  const [status, setStatus] = useState("Local demo ready.");
  // Build a temporary record when the publishing form is submitted.
  function publishAnnouncement(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const announcement = { ...draft, id: Date.now(), createdAt: new Date().toISOString() };
    setAnnouncements([announcement, ...announcements]);
    setDraft(emptyDraft);
    setStatus("Announcement added.");
  }
  return (
    <main className="app-shell">
      <header className="hero"><div><p className="eyebrow">Campus information in one place</p><h1>CampusNotice</h1><p className="hero-copy">Find timely updates from campus offices, organizations, and student services.</p></div><div className="connection-status" aria-live="polite">{status}</div></header>
      <section className="workspace"><form className="publish-panel" onSubmit={publishAnnouncement}><div><p className="section-kicker">Prototype publisher</p><h2>Post an announcement</h2><p className="panel-note">Authentication is intentionally deferred. Use sample campus information only.</p></div><label>Title<input required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} placeholder="Library hours extended" /></label><label>Organization<input required value={draft.organization} onChange={(event) => setDraft({ ...draft, organization: event.target.value })} placeholder="University Library" /></label><label>Category<select value={draft.category} onChange={(event) => setDraft({ ...draft, category: event.target.value })}>{categories.map((item) => <option key={item}>{item}</option>)}</select></label><label>Message<textarea required rows={5} value={draft.content} onChange={(event) => setDraft({ ...draft, content: event.target.value })} placeholder="Add the details students need to know." /></label><label>Expires on<input required type="date" value={draft.expiresOn} onChange={(event) => setDraft({ ...draft, expiresOn: event.target.value })} /></label><button type="submit">Publish announcement</button></form>
      <section className="feed-panel"><div className="feed-heading"><div><p className="section-kicker">Local feed</p><h2>Campus announcements</h2></div><span className="result-count">{announcements.length} shown</span></div><div className="announcement-list" aria-live="polite">{announcements.length === 0 ? <div className="empty-state"><h3>No announcements yet</h3><p>Publish a notice to start the local feed.</p></div> : announcements.map((announcement) => <article className="announcement-card" key={announcement.id}><div className="card-meta"><span className={`badge badge-${announcement.category.toLowerCase()}`}>{announcement.category}</span><span>{new Date(announcement.createdAt).toLocaleDateString()}</span></div><h3>{announcement.title}</h3><p className="organization">{announcement.organization}</p><p>{announcement.content}</p><p className="expiry">Expires {announcement.expiresOn}</p></article>)}</div></section></section>
    </main>
  );
}
export default App;

What does this component do?

  • The initialAnnouncements array supplies two sample cards when CampusNotice loads.
  • The draft object mirrors the current values in the publishing form.
  • The publishAnnouncement() function builds one temporary announcement from the draft.
  • The setAnnouncements() call places the submitted notice at the top of the feed.
  • The status region confirms when the local submission finishes.
  • Save src/App.tsx.
  • Switch back to the browser tab from the previous step.

That is your first visible CampusNotice screen. The publishing form now sits beside a feed with two sample announcements.

Still see the starter screen?

  • Confirm that you replaced the contents of src/App.tsx.
  • Check the Visual Studio Code terminal for a TypeScript message that identifies a missing bracket or tag.
  • Confirm that the local development server is still running.
  • Help me debug my local CampusNotice React component.

✔️ Awesome, I've got everything!

Great. Confirm that src/App.tsx is saved before you continue.

ⓧ I'd like to double check the full code

Compare your complete src/App.tsx file with this local-only version.

import { useState } from "react";
import type { FormEvent } from "react";
type Announcement = { id: number; title: string; organization: string; category: string; content: string; expiresOn: string; createdAt: string };
const categories = ["Academic", "Events", "Deadlines", "Emergency"];
const emptyDraft = { title: "", organization: "", category: "Academic", content: "", expiresOn: "" };
const initialAnnouncements: Announcement[] = [{ id: 1, title: "Library hours extended", organization: "University Library", category: "Academic", content: "The main library will stay open until midnight during exam week.", expiresOn: "2026-12-18", createdAt: "2026-09-21" }, { id: 2, title: "Student club showcase", organization: "Student Activities", category: "Events", content: "Meet campus organizations in the student center this Friday.", expiresOn: "2026-11-06", createdAt: "2026-09-20" }];
function App() {
  // Keep temporary notices and form values in component state.
  const [announcements, setAnnouncements] = useState(initialAnnouncements);
  const [draft, setDraft] = useState(emptyDraft);
  const [status, setStatus] = useState("Local demo ready.");
  // Build a temporary record when the publishing form is submitted.
  function publishAnnouncement(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const announcement = { ...draft, id: Date.now(), createdAt: new Date().toISOString() };
    setAnnouncements([announcement, ...announcements]);
    setDraft(emptyDraft);
    setStatus("Announcement added.");
  }
  return (
    <main className="app-shell">
      <header className="hero"><div><p className="eyebrow">Campus information in one place</p><h1>CampusNotice</h1><p className="hero-copy">Find timely updates from campus offices, organizations, and student services.</p></div><div className="connection-status" aria-live="polite">{status}</div></header>
      <section className="workspace"><form className="publish-panel" onSubmit={publishAnnouncement}><div><p className="section-kicker">Prototype publisher</p><h2>Post an announcement</h2><p className="panel-note">Authentication is intentionally deferred. Use sample campus information only.</p></div><label>Title<input required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} placeholder="Library hours extended" /></label><label>Organization<input required value={draft.organization} onChange={(event) => setDraft({ ...draft, organization: event.target.value })} placeholder="University Library" /></label><label>Category<select value={draft.category} onChange={(event) => setDraft({ ...draft, category: event.target.value })}>{categories.map((item) => <option key={item}>{item}</option>)}</select></label><label>Message<textarea required rows={5} value={draft.content} onChange={(event) => setDraft({ ...draft, content: event.target.value })} placeholder="Add the details students need to know." /></label><label>Expires on<input required type="date" value={draft.expiresOn} onChange={(event) => setDraft({ ...draft, expiresOn: event.target.value })} /></label><button type="submit">Publish announcement</button></form>
      <section className="feed-panel"><div className="feed-heading"><div><p className="section-kicker">Local feed</p><h2>Campus announcements</h2></div><span className="result-count">{announcements.length} shown</span></div><div className="announcement-list" aria-live="polite">{announcements.length === 0 ? <div className="empty-state"><h3>No announcements yet</h3><p>Publish a notice to start the local feed.</p></div> : announcements.map((announcement) => <article className="announcement-card" key={announcement.id}><div className="card-meta"><span className={`badge badge-${announcement.category.toLowerCase()}`}>{announcement.category}</span><span>{new Date(announcement.createdAt).toLocaleDateString()}</span></div><h3>{announcement.title}</h3><p className="organization">{announcement.organization}</p><p>{announcement.content}</p><p className="expiry">Expires {announcement.expiresOn}</p></article>)}</div></section></section>
    </main>
  );
}
export default App;
Style the responsive workspace

The component now has the right structure. The stylesheet turns that structure into a readable publishing workspace with a responsive feed.

  • Select src/index.css from the Explorer sidebar.
  • Press Cmd+A on macOS or Ctrl+A on Windows to select the starter stylesheet.
  • Replace the selection by pasting these foundation styles:
:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #eef3f9; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select, textarea { font: inherit; border-radius: 10px; }
button { border: 0; padding: 0.85rem 1rem; color: white; background: #1d4ed8; cursor: pointer; font-weight: 700; }
button:hover { background: #1e40af; }
input, select, textarea { width: 100%; border: 1px solid #cbd5e1; padding: 0.75rem; color: #172033; background: white; }
input:focus, select:focus, textarea:focus { outline: 3px solid #bfdbfe; border-color: #2563eb; }
label { display: grid; gap: 0.4rem; color: #334155; font-size: 0.9rem; font-weight: 700; }
h1, h2, h3, p { margin-top: 0; }
.app-shell { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0 4rem; }
.hero { display: flex; justify-content: space-between; gap: 2rem; align-items: end; padding: 2rem; color: white; border-radius: 22px; background: linear-gradient(135deg, #172554, #1d4ed8); box-shadow: 0 18px 50px rgba(30, 64, 175, 0.22); }
.hero h1 { margin-bottom: 0.5rem; font-size: clamp(2.2rem, 6vw, 4rem); }
.hero-copy { max-width: 650px; margin-bottom: 0; color: #dbeafe; }
.eyebrow, .section-kicker { margin-bottom: 0.4rem; letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.74rem; font-weight: 800; }
.connection-status { flex: 0 0 auto; padding: 0.7rem 0.9rem; border: 1px solid rgba(255, 255, 255, 0.28); border-radius: 999px; background: rgba(255, 255, 255, 0.12); font-size: 0.85rem; }
.workspace { display: grid; grid-template-columns: minmax(280px, 0.78fr) minmax(0, 1.5fr); gap: 1.25rem; margin-top: 1.25rem; align-items: start; }
.publish-panel, .feed-panel { padding: 1.4rem; border: 1px solid #dbe3ee; border-radius: 18px; background: white; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.07); }
.publish-panel { display: grid; gap: 1rem; }

What do these styles change?

  • The root rules establish the typography and page colors for the entire interface.
  • The form controls share consistent spacing and focus indicators.
  • The .workspace grid places the publishing panel beside the announcement feed.
  • The panel rules separate each workspace area with a border and shadow.
  • Save src/index.css.
  • Switch back to the CampusNotice browser tab.

You should see a blue header above two white panels. The form fields now have consistent spacing and visible focus outlines.

  • Place your cursor after the final brace in src/index.css.
  • Add the feed details and responsive rule by pasting this code:
.panel-note, .organization, .expiry, .empty-state p { color: #64748b; }
.feed-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
.result-count { padding: 0.45rem 0.7rem; border-radius: 999px; color: #1e3a8a; background: #dbeafe; white-space: nowrap; font-size: 0.82rem; font-weight: 800; }
.filters { display: grid; grid-template-columns: 1fr 190px; gap: 0.8rem; margin: 1rem 0; }
.announcement-list { display: grid; gap: 0.9rem; }
.announcement-card { padding: 1.1rem; border: 1px solid #e2e8f0; border-left: 5px solid #2563eb; border-radius: 14px; background: white; }
.announcement-card h3 { margin-bottom: 0.35rem; }
.card-meta { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: 0.8rem; color: #64748b; font-size: 0.82rem; }
.badge { padding: 0.25rem 0.55rem; border-radius: 999px; color: #1e3a8a; background: #dbeafe; font-weight: 800; }
.badge-events { color: #6b21a8; background: #f3e8ff; }
.badge-deadlines { color: #9a3412; background: #ffedd5; }
.badge-emergency { color: #991b1b; background: #fee2e2; }
.organization { margin-bottom: 0.8rem; font-weight: 700; }
.expiry { margin-bottom: 0; font-size: 0.84rem; }
.empty-state { padding: 2.5rem 1rem; border: 2px dashed #cbd5e1; border-radius: 14px; text-align: center; }
@media (max-width: 820px) { .hero, .feed-heading { align-items: start; flex-direction: column; } .workspace, .filters { grid-template-columns: 1fr; } .connection-status { white-space: normal; } }

How does the feed stay readable?

  • The announcement list adds space between individual cards.
  • Each category badge receives a distinct color treatment.
  • The result count stays compact beside the feed heading.
  • The media query changes the workspace to one column on narrower screens.
  • Save src/index.css.
  • Switch back to the CampusNotice browser tab.
  • Narrow the browser window below 820px.

You should see the feed move below the publishing form. The header content should also stack vertically.

Does the page still look unstyled?

  • Confirm that you edited src/index.css inside the src folder.
  • Check that src/main.tsx still imports ./index.css.
  • Look for an unmatched brace in the stylesheet.
  • Help me debug my CampusNotice CSS layout.

✔️ Awesome, I've got everything!

Great. Confirm that src/index.css is saved before testing the form.

ⓧ I'd like to double check the full code

Compare your complete src/index.css file with this responsive stylesheet.

:root { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #172033; background: #eef3f9; font-synthesis: none; text-rendering: optimizeLegibility; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select, textarea { font: inherit; border-radius: 10px; }
button { border: 0; padding: 0.85rem 1rem; color: white; background: #1d4ed8; cursor: pointer; font-weight: 700; }
button:hover { background: #1e40af; }
input, select, textarea { width: 100%; border: 1px solid #cbd5e1; padding: 0.75rem; color: #172033; background: white; }
input:focus, select:focus, textarea:focus { outline: 3px solid #bfdbfe; border-color: #2563eb; }
label { display: grid; gap: 0.4rem; color: #334155; font-size: 0.9rem; font-weight: 700; }
h1, h2, h3, p { margin-top: 0; }
.app-shell { width: min(1180px, calc(100% - 2rem)); margin: 0 auto; padding: 2rem 0 4rem; }
.hero { display: flex; justify-content: space-between; gap: 2rem; align-items: end; padding: 2rem; color: white; border-radius: 22px; background: linear-gradient(135deg, #172554, #1d4ed8); box-shadow: 0 18px 50px rgba(30, 64, 175, 0.22); }
.hero h1 { margin-bottom: 0.5rem; font-size: clamp(2.2rem, 6vw, 4rem); }
.hero-copy { max-width: 650px; margin-bottom: 0; color: #dbeafe; }
.eyebrow, .section-kicker { margin-bottom: 0.4rem; letter-spacing: 0.12em; text-transform: uppercase; font-size: 0.74rem; font-weight: 800; }
.connection-status { flex: 0 0 auto; padding: 0.7rem 0.9rem; border: 1px solid rgba(255, 255, 255, 0.28); border-radius: 999px; background: rgba(255, 255, 255, 0.12); font-size: 0.85rem; }
.workspace { display: grid; grid-template-columns: minmax(280px, 0.78fr) minmax(0, 1.5fr); gap: 1.25rem; margin-top: 1.25rem; align-items: start; }
.publish-panel, .feed-panel { padding: 1.4rem; border: 1px solid #dbe3ee; border-radius: 18px; background: white; box-shadow: 0 10px 30px rgba(15, 23, 42, 0.07); }
.publish-panel { display: grid; gap: 1rem; }
.panel-note, .organization, .expiry, .empty-state p { color: #64748b; }
.feed-heading { display: flex; justify-content: space-between; gap: 1rem; align-items: start; }
.result-count { padding: 0.45rem 0.7rem; border-radius: 999px; color: #1e3a8a; background: #dbeafe; white-space: nowrap; font-size: 0.82rem; font-weight: 800; }
.filters { display: grid; grid-template-columns: 1fr 190px; gap: 0.8rem; margin: 1rem 0; }
.announcement-list { display: grid; gap: 0.9rem; }
.announcement-card { padding: 1.1rem; border: 1px solid #e2e8f0; border-left: 5px solid #2563eb; border-radius: 14px; background: white; }
.announcement-card h3 { margin-bottom: 0.35rem; }
.card-meta { display: flex; justify-content: space-between; gap: 1rem; margin-bottom: 0.8rem; color: #64748b; font-size: 0.82rem; }
.badge { padding: 0.25rem 0.55rem; border-radius: 999px; color: #1e3a8a; background: #dbeafe; font-weight: 800; }
.badge-events { color: #6b21a8; background: #f3e8ff; }
.badge-deadlines { color: #9a3412; background: #ffedd5; }
.badge-emergency { color: #991b1b; background: #fee2e2; }
.organization { margin-bottom: 0.8rem; font-weight: 700; }
.expiry { margin-bottom: 0; font-size: 0.84rem; }
.empty-state { padding: 2.5rem 1rem; border: 2px dashed #cbd5e1; border-radius: 14px; text-align: center; }
@media (max-width: 820px) { .hero, .feed-heading { align-items: start; flex-direction: column; } .workspace, .filters { grid-template-columns: 1fr; } .connection-status { white-space: normal; } }
Test the browser-memory limit

The interface now supports a complete local publishing interaction. A fresh-session test shows whether this storage approach can support a shared campus board.

  • Return to the Visual Studio Code terminal from the previous step.
  • Press Ctrl+C to stop the existing development server.
  • Restart the CampusNotice development server by running this command:
npm run dev

What does this command do?

The command starts the local development server defined by the starter project. It keeps the terminal occupied while CampusNotice is running.

You should see a local address for http://localhost:5173 in the terminal output.

Does the local page fail to load?

  • Confirm that the terminal is still inside the cloned campusnotice folder.
  • Check whether another development server still occupies the local port.
  • Review the first TypeScript message in the terminal because later messages may come from the same source.
  • Help me get the CampusNotice development server running.
  • Return to http://localhost:5173 in your browser.
  • Enter Exam review session in the Title field.
  • Enter Academic Success Center in the Organization field.
  • Select Academic from the Category menu.
  • Enter Bring your course notes for a guided review. in the Message field.
  • Enter 2026-12-31 in the Expires on field.
  • Click Publish announcement.

There it is. Your temporary announcement now appears at the top of the feed with an updated result count.

Before you refresh, predict whether the new notice will survive a reload or appear in another browser tab.

  • Open a second browser tab.
  • Load http://localhost:5173 in the second tab.

You should see the original sample cards. The exam review notice is absent from this fresh tab.

  • Return to the first CampusNotice tab.
  • Refresh the page.

The temporary exam review notice disappears after the refresh. The original sample announcements return.

Why did the announcement disappear?

The page stores submitted announcements in component memory. Every fresh browser session rebuilds the list from initialAnnouncements.

This deliberate shortfall proves that the interface needs shared persistent data before it can serve a campus community.

Your CampusNotice interface now publishes temporary announcements and adapts to smaller screens. Next up, you will give the board a shared data model that can preserve those notices.

Model Cloud Announcements

Your local CampusNotice board stores each submitted notice in React state. The refresh test showed that browser memory cannot preserve or share those notices.

In this step, you'll define the Announcement model. Amplify Data will deploy the shared API and table behind it.

In this step, get ready to:
  • Define the cloud model for campus announcements.
  • Remove the unused authentication backend.
  • Deploy the model through the connected cloud workflow.
Define the Announcement model

An Amplify Data model describes the fields that every cloud record must contain. The Announcement model mirrors the five fields already collected by your local form.

Prototype access boundary

This model uses a public API key for the prototype. API-key-authenticated visitors can create records while the key is valid.

Visitors can also read records. They can update records. They can delete records.

  • Keep every announcement fictional and free of personal information.
  • In Visual Studio Code, select amplify/data/resource.ts from the Explorer sidebar.
  • Replace the file with the announcement model by pasting this code:
import { a, defineData, type ClientSchema } from "@aws-amplify/backend";

const schema = a.schema({
  Announcement: a
    .model({
      title: a.string().required(),
      content: a.string().required(),
      organization: a.string().required(),
      category: a.string().required(),
      expiresOn: a.string().required(),
    })
    .authorization((allow) => [allow.publicApiKey()]),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "apiKey",
    apiKeyAuthorizationMode: { expiresInDays: 30 },
  },
});

What does this model do?

  • The Announcement model names the cloud record used by CampusNotice.
  • Each a.string().required() field requires a text value before Amplify accepts a record.
  • The allow.publicApiKey() rule applies public API-key authorization to the model.
  • The Schema type gives the frontend an exact TypeScript description of the model.
  • The authorizationModes configuration makes the API key valid for 30 days.
  • Save amplify/data/resource.ts.
  • Confirm the saved file has no red TypeScript underlines.

Seeing TypeScript underlines?

  • Check that every field uses a.string().required().
  • Confirm the closing brackets after allow.publicApiKey() match the reference code.
  • Compare the quote marks around apiKey with the reference code.
  • Help me find the TypeScript issue in my Amplify Data model.

✔️ Awesome, I've got everything!

Great. Double check that you saved amplify/data/resource.ts.

ⓧ I'd like to double check the full code

Compare your saved model with this complete file.

import { a, defineData, type ClientSchema } from "@aws-amplify/backend";

const schema = a.schema({
  Announcement: a
    .model({
      title: a.string().required(),
      content: a.string().required(),
      organization: a.string().required(),
      category: a.string().required(),
      expiresOn: a.string().required(),
    })
    .authorization((allow) => [allow.publicApiKey()]),
});

export type Schema = ClientSchema<typeof schema>;

export const data = defineData({
  schema,
  authorizationModes: {
    defaultAuthorizationMode: "apiKey",
    apiKeyAuthorizationMode: { expiresInDays: 30 },
  },
});
Remove the unused authentication backend

The backend entry point decides which resources Amplify deploys. Authentication sits outside this public prototype's scope.

  • Select amplify/backend.ts from the Explorer sidebar.
  • Replace the file with the data-only backend by pasting this code:
import { defineBackend } from "@aws-amplify/backend";
import { data } from "./data/resource";

defineBackend({ data });

What does this backend entry point do?

  • The first import brings in the backend composition function.
  • The second import brings in the data resource you just defined.
  • The defineBackend({ data }) call tells Amplify to deploy the data resource.
  • Save amplify/backend.ts.
  • Confirm the saved file has no red TypeScript underlines.

Still seeing an auth reference?

  • Confirm amplify/backend.ts contains only the two imports from the reference code.
  • Check that defineBackend receives only data.
  • Help me remove the unused authentication resource from my Amplify backend.

✔️ Awesome, I've got everything!

Great. Double check that you saved amplify/backend.ts.

ⓧ I'd like to double check the full code

Compare your saved backend entry point with this complete file.

import { defineBackend } from "@aws-amplify/backend";
import { data } from "./data/resource";

defineBackend({ data });
Deploy the model through Git

A push to the GitHub main branch gives AWS Amplify the backend changes. The connected CI/CD workflow then deploys them.

Use sample data only

This deployment creates a public prototype with broad API-key access. Keep all test announcements fictional.

  • Record the backend changes in Git by running this command in the Visual Studio Code terminal:
git commit -am "Model campus announcements"

What does this command do?

The command records the tracked backend file changes in a local commit. The commit message identifies the announcement-model milestone.

Before you push, which model do you expect Data manager to show?

  • Send the commit to the connected main branch by running this command:
git push origin main

What does this command do?

The command sends your local main commit to GitHub. Amplify detects that push and starts a branch deployment.

Amplify needs time to provision the API and table. A quiet build screen during those stages does not mean the deployment has stalled.

  • Return to the AWS Amplify deployment from earlier.
  • Select the main deployment.
  • Wait until the deployment shows success.
  • Select Data in the deployed app.
  • Select Data manager.

You should see Announcement listed in Data manager.

What did Amplify create?

  • An AWS AppSync API exposes generated operations for the Announcement model.
  • An Amazon DynamoDB table stores the five announcement fields.
  • Amplify adds id, createdAt, and updatedAt values to each record.
  • A public API key authorizes access for 30 days.

The cloud model is live. CampusNotice now has shared infrastructure ready for real-time records.

Don't see Announcement?

  • Confirm the main deployment shows success before checking Data manager.
  • Check that the pushed commit contains changes to both backend files.
  • Compare amplify/data/resource.ts with the complete model reference.
  • Help me diagnose why my Amplify Data manager does not show the Announcement model.

The deployed backend now has new connection details. Your local project needs a refreshed generated configuration before the next step can call the API.

  • Return to the Amplify deployment from earlier.
  • Open Deployed backend resources.
  • Choose Download amplify_outputs.json file.
  • Drag the downloaded amplify_outputs.json from Windows File Explorer into the top level of your campusnotice folder.
  • Confirm the overwrite when Windows asks.
  • Verify that amplify_outputs.json sits beside package.json in the campusnotice folder.

That is the persistence foundation in place. Next, you'll connect the local board to this shared feed.

Connect Real-Time Cloud Data

Your Announcement model is now available through AWS AppSync. Amazon DynamoDB is ready to store every record.

The React board still writes only to browser memory. This step connects it to the generated data client so every announcement can persist across sessions.

In this step, get ready to:
  • Configure Amplify before React renders the interface.
  • Subscribe the board to shared Announcement records.
  • Publish a persistent announcement to the cloud.
Configure the Amplify client

The root amplify_outputs.json file tells AWS Amplify how to reach your deployed API. Its public API key keeps this prototype open, so continue using sample campus information only.

  • In the Explorer sidebar in Visual Studio Code, select src/main.tsx.
  • Replace the contents of src/main.tsx with this code:
import React from "react";
import ReactDOM from "react-dom/client";
import { Amplify } from "aws-amplify";
import outputs from "../amplify_outputs.json";
import App from "./App.tsx";
import "./index.css";

Amplify.configure(outputs);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);

What does this code do?

  • The outputs import loads the connection details from the refreshed backend configuration.
  • Amplify.configure(outputs) configures the data connection before React creates the interface.
  • React.StrictMode keeps the starter's development checks around your App component.
  • Save src/main.tsx.
  • Start the local development server from the Visual Studio Code terminal by running:
npm run dev

What should I see?

The terminal should show the local development URL. A successful start confirms that the refreshed configuration loads without blocking the app.

  • Open a browser tab at http://localhost:5173.

You should see the CampusNotice interface from the previous step. The page should load without a development error overlay.

Does the board fail to load?

  • Confirm that amplify_outputs.json sits directly inside the campusnotice folder.
  • Check that the import uses ../amplify_outputs.json because main.tsx is inside src.
  • Compare the capitalization of Amplify and Amplify.configure(outputs).
  • Help me debug the Amplify configuration in src/main.tsx.

✔️ Awesome, I've got everything!

Your React entry point now configures the deployed backend before CampusNotice renders.

ⓧ I'd like to double check the full code

  • Compare your complete src/main.tsx file with this reference:
import React from "react";
import ReactDOM from "react-dom/client";
import { Amplify } from "aws-amplify";
import outputs from "../amplify_outputs.json";
import App from "./App.tsx";
import "./index.css";

Amplify.configure(outputs);

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
);
Stream shared announcements

The generated client understands the fields in your Announcement model through the exported Schema type. Its subscription keeps the board aligned with records created during the session.

  • In the Explorer sidebar, select src/App.tsx.
  • Replace the imports and shared constants at the top of src/App.tsx with this code:
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";

const client = generateClient<Schema>();
const categories = ["All", "Academic", "Events", "Deadlines", "Emergency"];
const emptyDraft = { title: "", organization: "", category: "Academic", content: "", expiresOn: "" };

How does the typed client help?

  • Schema describes the fields generated for an Announcement record.
  • generateClient<Schema>() gives the component typed access to the Announcement model.
  • categories supplies the form options already used by the local interface.
  • emptyDraft provides a clean form state after a successful publication.
  • Save src/App.tsx.

The browser should still show CampusNotice without an error overlay. This confirms that the generated Schema type matches the deployed model.

Seeing an import problem?

  • Confirm that the schema import points to ../amplify/data/resource from inside src/App.tsx.
  • Check that generateClient comes from aws-amplify/data.
  • Verify that the exported type is spelled Schema in amplify/data/resource.ts.
  • Help me fix the generated client imports in src/App.tsx.
  • Locate the state setup at the start of App().
  • Replace the temporary announcement state with this cloud-backed state and subscription:
  const [announcements, setAnnouncements] = useState<Array<Schema["Announcement"]["type"]>>([]);
  const [draft, setDraft] = useState(emptyDraft);
  const [status, setStatus] = useState("Connecting to the live campus feed...");

  useEffect(() => {
    const subscription = client.models.Announcement.observeQuery().subscribe({
      next: ({ items }) => {
        setAnnouncements([...items]);
        setStatus("Live campus feed connected.");
      },
    });
    return () => subscription.unsubscribe();
  }, []);

What does the subscription do?

  • The typed announcements state holds records returned by the deployed Announcement model.
  • observeQuery() loads the current records. It continues listening for changes after the first response.
  • setAnnouncements([...items]) gives React a fresh array whenever the shared records change.
  • subscription.unsubscribe() closes the listener when the component unmounts.
  • Save src/App.tsx.

The status area should change to Live campus feed connected. once the subscription receives its first response.

Does the feed stay on connecting?

  • Confirm that the latest amplify_outputs.json file came from the successful Announcement deployment.
  • Check that client.models.Announcement uses the same capitalization as the deployed model.
  • Keep the Visual Studio Code terminal running so the local app can stay connected.
  • Help me diagnose why observeQuery is not connecting to my Announcement model.
Publish and prove persistence

The subscription can read shared records, but the form still needs to create them through the same client. A successful create operation causes the subscription in every open tab to receive the new Announcement.

  • Locate publishAnnouncement in src/App.tsx.
  • Replace the temporary browser-memory function with this cloud create operation:
  async function publishAnnouncement(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("Publishing announcement...");
    const { errors } = await client.models.Announcement.create(draft);
    if (errors) {
      setStatus("The announcement could not be published. Check the browser console.");
      console.error(errors);
      return;
    }
    setDraft(emptyDraft);
    setStatus("Announcement published.");
  }

What happens during publication?

  • event.preventDefault() keeps the browser on the current page when the form submits.
  • client.models.Announcement.create(draft) sends the completed draft to the deployed Announcement API.
  • The error branch preserves the form values. It also records the returned details in the browser console.
  • setDraft(emptyDraft) clears the form only after the record is created successfully.
  • Save src/App.tsx.

✔️ Awesome, I've got everything!

CampusNotice now reads shared records through a subscription. The publishing form sends new records to the same cloud model.

ⓧ I'd like to double check the full code

  • Compare your complete src/App.tsx file with this cloud-connected version:
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";

const client = generateClient<Schema>();
const categories = ["All", "Academic", "Events", "Deadlines", "Emergency"];
const emptyDraft = { title: "", organization: "", category: "Academic", content: "", expiresOn: "" };

function App() {
  const [announcements, setAnnouncements] = useState<Array<Schema["Announcement"]["type"]>>([]);
  const [draft, setDraft] = useState(emptyDraft);
  const [status, setStatus] = useState("Connecting to the live campus feed...");

  useEffect(() => {
    const subscription = client.models.Announcement.observeQuery().subscribe({
      next: ({ items }) => {
        setAnnouncements([...items]);
        setStatus("Live campus feed connected.");
      },
    });
    return () => subscription.unsubscribe();
  }, []);

  async function publishAnnouncement(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("Publishing announcement...");
    const { errors } = await client.models.Announcement.create(draft);
    if (errors) {
      setStatus("The announcement could not be published. Check the browser console.");
      console.error(errors);
      return;
    }
    setDraft(emptyDraft);
    setStatus("Announcement published.");
  }

  return (
    <main className="app-shell">
      <header className="hero">
        <div>
          <p className="eyebrow">Campus information in one place</p>
          <h1>CampusNotice</h1>
          <p className="hero-copy">Find timely updates from campus offices, organizations, and student services.</p>
        </div>
        <div className="connection-status" aria-live="polite">{status}</div>
      </header>

      <section className="workspace">
        <form className="publish-panel" onSubmit={publishAnnouncement}>
          <div>
            <p className="section-kicker">Prototype publisher</p>
            <h2>Post an announcement</h2>
            <p className="panel-note">Authentication is intentionally deferred. Use sample campus information only.</p>
          </div>
          <label>Title<input required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} placeholder="Library hours extended" /></label>
          <label>Organization<input required value={draft.organization} onChange={(event) => setDraft({ ...draft, organization: event.target.value })} placeholder="University Library" /></label>
          <label>Category<select value={draft.category} onChange={(event) => setDraft({ ...draft, category: event.target.value })}>{categories.slice(1).map((item) => <option key={item}>{item}</option>)}</select></label>
          <label>Message<textarea required rows={5} value={draft.content} onChange={(event) => setDraft({ ...draft, content: event.target.value })} placeholder="Add the details students need to know." /></label>
          <label>Expires on<input required type="date" value={draft.expiresOn} onChange={(event) => setDraft({ ...draft, expiresOn: event.target.value })} /></label>
          <button type="submit">Publish announcement</button>
        </form>

        <section className="feed-panel">
          <div className="feed-heading">
            <div><p className="section-kicker">Live feed</p><h2>Campus announcements</h2></div>
          </div>
          <div className="announcement-list" aria-live="polite">
            {announcements.map((announcement) => (
              <article className="announcement-card" key={announcement.id}>
                <div className="card-meta"><span className={`badge badge-${announcement.category.toLowerCase()}`}>{announcement.category}</span><span>{new Date(announcement.createdAt).toLocaleDateString()}</span></div>
                <h3>{announcement.title}</h3>
                <p className="organization">{announcement.organization}</p>
                <p>{announcement.content}</p>
                <p className="expiry">Expires {announcement.expiresOn}</p>
              </article>
            ))}
          </div>
        </section>
      </section>
    </main>
  );
}

export default App;
  • Return to the CampusNotice browser tab from earlier.
  • Open a second browser tab at http://localhost:5173.
  • Enter Library hours extended in the Title field in the first tab.
  • Enter University Library in the Organization field.
  • Select Academic in the Category field.
  • Enter Add the details students need to know. in the Message field.
  • Choose a future date in the Expires on field.

Before you publish, make a quick prediction about whether the new record appears in both tabs without a refresh.

  • Click Publish announcement in the first tab.

You should see the new announcement card in both tabs without a manual refresh. This proves that each tab receives the same cloud-backed update.

  • Refresh the second browser tab.

The announcement should remain in the feed after the refresh. You have now proved that CampusNotice stores records beyond the current browser session.

Does the record appear in only one tab?

  • Confirm that both tabs use http://localhost:5173 from the same running development server.
  • Check the status area in each tab for Live campus feed connected..
  • Verify that the subscription calls setAnnouncements([...items]) inside its next handler.
  • Help me debug CampusNotice real-time updates across two browser tabs.

Your local board now publishes persistent records through the deployed API. Next up, you will make the growing feed easier to search.

Add Search and Deploy the MVP

Your React board now stores announcements in the cloud. It also delivers new records across browser tabs without a refresh.

A centralized feed becomes difficult to use as its list grows. This step adds client-side search so visitors can narrow the feed before you deploy the finished MVP.

In this step, get ready to:
  • Add keyword search plus category filtering.
  • Display matching result counts with a clear empty state.
  • Build the production bundle before deploying through GitHub.
Add search and category filtering

The app already keeps every cloud record in announcements. The new controls keep the original records intact while deriving a smaller list for the feed.

  • Return to src/App.tsx in Visual Studio Code.
  • Find this existing draft state declaration:
  const [draft, setDraft] = useState(emptyDraft);

Use the draft state as an anchor

This line sits with the other values managed by useState. The search values belong here because typing into either control updates the interface immediately.

  • Add the query state plus category state below the draft declaration so this section matches the following code:
  const [draft, setDraft] = useState(emptyDraft);
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState("All");

What do these values control?

  • The query value holds the current keyword from the search input.
  • The category value starts at All so the feed initially includes every category.

Search text can contain capital letters or extra spaces. Normalizing the query gives every record the same lowercase comparison format.

  • Find the end of publishAnnouncement in src/App.tsx.
  • Add the derived announcement list below that function by pasting this code:
  const normalizedQuery = query.trim().toLowerCase();
  const filteredAnnouncements = [...announcements]
    .filter((announcement) => {
      const matchesCategory = category === "All" || announcement.category === category;
      const searchableText = [announcement.title, announcement.content, announcement.organization]
        .join(" ")
        .toLowerCase();
      return matchesCategory && searchableText.includes(normalizedQuery);
    })
    .sort((first, second) => second.createdAt.localeCompare(first.createdAt));

How does the filter work?

  • The copied array protects the original announcements order from the later sort operation.
  • The category condition accepts every record when All is selected.
  • The searchable text combines each title with its message plus organization name.
  • The final sort compares createdAt values so the newest matching announcement appears first.
  • Save src/App.tsx.
  • Confirm the running development server processes the saved module without reporting a compile error.

Seeing a compile error?

  • Check that query plus category sit inside the App function.
  • Confirm filteredAnnouncements appears after the closing brace of publishAnnouncement.
  • Use this prompt if the compiler still rejects the filter: Help me compare my CampusNotice state and filteredAnnouncements code with the expected TypeScript structure.

The derived list is ready. The feed now needs controls that write to those state values.

  • Find the feed-heading section inside the existing feed-panel.
  • Replace that heading section with the result count plus filter controls shown below:
          <div className="feed-heading">
            <div><p className="section-kicker">Live feed</p><h2>Campus announcements</h2></div>
            <span className="result-count">{filteredAnnouncements.length} shown</span>
          </div>
          <div className="filters">
            <label>Search<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search notices" /></label>
            <label>Category<select value={category} onChange={(event) => setCategory(event.target.value)}>{categories.map((item) => <option key={item}>{item}</option>)}</select></label>
          </div>

What do the controls update?

  • The search input sends each typed value to setQuery.
  • The category selector builds its options from categories so its values match the stored records.
  • The count reads from filteredAnnouncements.length so it changes with both controls.
  • Save src/App.tsx.
  • Return to the local CampusNotice page in your browser.

You should see a search field plus a category selector above the feed. You should also see the current number of displayed announcements.

Do the controls look out of place?

  • Confirm the controls remain inside the feed-panel section.
  • Check that the existing src/index.css file still contains the .filters plus .result-count rules.
  • Use this prompt for help with the layout: Help me find why the CampusNotice search controls are not using the existing filters and result-count CSS styles.

A filter can legitimately produce zero results. A clear empty state tells visitors that the controls removed every card from the current view.

  • Find the existing announcement list inside src/App.tsx.
  • Replace that list with the filtered cards plus empty state shown below:
          <div className="announcement-list" aria-live="polite">
            {filteredAnnouncements.length === 0 ? (
              <div className="empty-state"><h3>No matching announcements</h3><p>Publish a notice or change the current search filters.</p></div>
            ) : filteredAnnouncements.map((announcement) => (
              <article className="announcement-card" key={announcement.id}>
                <div className="card-meta"><span className={`badge badge-${announcement.category.toLowerCase()}`}>{announcement.category}</span><span>{new Date(announcement.createdAt).toLocaleDateString()}</span></div>
                <h3>{announcement.title}</h3>
                <p className="organization">{announcement.organization}</p>
                <p>{announcement.content}</p>
                <p className="expiry">Expires {announcement.expiresOn}</p>
              </article>
            ))}
          </div>

How does the feed choose a result?

  • A zero-length result renders the existing empty-state design.
  • A nonzero result maps only filteredAnnouncements into cards.
  • The aria-live value allows assistive technology to announce changes to the feed.
  • Save src/App.tsx.
  • Return to the local CampusNotice page.

Before testing the controls, predict which cards should remain when a keyword matches one announcement in the selected category.

  • Enter a keyword from an existing announcement title in the Search field.
  • Select that announcement's category from the Category field.

You should see only matching cards. The result count should show the number of records that satisfy both conditions.

  • Replace the search keyword with zzzz.

You should see No matching announcements in place of the cards.

Are nonmatching cards still visible?

  • Confirm the card loop reads from filteredAnnouncements.
  • Check that the filter returns both matchesCategory plus the keyword comparison.
  • Use this prompt if one control seems to be ignored: Help me debug why my CampusNotice keyword and category filters are not combining correctly.

✔️ Awesome, I've got everything!

Your local feed now responds to keywords plus category selections. Double check that you saved src/App.tsx.

ⓧ I'd like to double check the full code

Compare your complete src/App.tsx file with this final pre-mission version.

import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";

const client = generateClient<Schema>();
const categories = ["All", "Academic", "Events", "Deadlines", "Emergency"];
const emptyDraft = { title: "", organization: "", category: "Academic", content: "", expiresOn: "" };

function App() {
  const [announcements, setAnnouncements] = useState<Array<Schema["Announcement"]["type"]>>([]);
  const [draft, setDraft] = useState(emptyDraft);
  const [query, setQuery] = useState("");
  const [category, setCategory] = useState("All");
  const [status, setStatus] = useState("Connecting to the live campus feed...");

  useEffect(() => {
    const subscription = client.models.Announcement.observeQuery().subscribe({
      next: ({ items }) => {
        setAnnouncements([...items]);
        setStatus("Live campus feed connected.");
      },
    });
    return () => subscription.unsubscribe();
  }, []);

  async function publishAnnouncement(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setStatus("Publishing announcement...");
    const { errors } = await client.models.Announcement.create(draft);
    if (errors) {
      setStatus("The announcement could not be published. Check the browser console.");
      console.error(errors);
      return;
    }
    setDraft(emptyDraft);
    setStatus("Announcement published.");
  }

  const normalizedQuery = query.trim().toLowerCase();
  const filteredAnnouncements = [...announcements]
    .filter((announcement) => {
      const matchesCategory = category === "All" || announcement.category === category;
      const searchableText = [announcement.title, announcement.content, announcement.organization]
        .join(" ")
        .toLowerCase();
      return matchesCategory && searchableText.includes(normalizedQuery);
    })
    .sort((first, second) => second.createdAt.localeCompare(first.createdAt));

  return (
    <main className="app-shell">
      <header className="hero">
        <div>
          <p className="eyebrow">Campus information in one place</p>
          <h1>CampusNotice</h1>
          <p className="hero-copy">Find timely updates from campus offices, organizations, and student services.</p>
        </div>
        <div className="connection-status" aria-live="polite">{status}</div>
      </header>

      <section className="workspace">
        <form className="publish-panel" onSubmit={publishAnnouncement}>
          <div>
            <p className="section-kicker">Prototype publisher</p>
            <h2>Post an announcement</h2>
            <p className="panel-note">Authentication is intentionally deferred. Use sample campus information only.</p>
          </div>
          <label>Title<input required value={draft.title} onChange={(event) => setDraft({ ...draft, title: event.target.value })} placeholder="Library hours extended" /></label>
          <label>Organization<input required value={draft.organization} onChange={(event) => setDraft({ ...draft, organization: event.target.value })} placeholder="University Library" /></label>
          <label>Category<select value={draft.category} onChange={(event) => setDraft({ ...draft, category: event.target.value })}>{categories.slice(1).map((item) => <option key={item}>{item}</option>)}</select></label>
          <label>Message<textarea required rows={5} value={draft.content} onChange={(event) => setDraft({ ...draft, content: event.target.value })} placeholder="Add the details students need to know." /></label>
          <label>Expires on<input required type="date" value={draft.expiresOn} onChange={(event) => setDraft({ ...draft, expiresOn: event.target.value })} /></label>
          <button type="submit">Publish announcement</button>
        </form>

        <section className="feed-panel">
          <div className="feed-heading">
            <div><p className="section-kicker">Live feed</p><h2>Campus announcements</h2></div>
            <span className="result-count">{filteredAnnouncements.length} shown</span>
          </div>
          <div className="filters">
            <label>Search<input type="search" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search notices" /></label>
            <label>Category<select value={category} onChange={(event) => setCategory(event.target.value)}>{categories.map((item) => <option key={item}>{item}</option>)}</select></label>
          </div>
          <div className="announcement-list" aria-live="polite">
            {filteredAnnouncements.length === 0 ? (
              <div className="empty-state"><h3>No matching announcements</h3><p>Publish a notice or change the current search filters.</p></div>
            ) : filteredAnnouncements.map((announcement) => (
              <article className="announcement-card" key={announcement.id}>
                <div className="card-meta"><span className={`badge badge-${announcement.category.toLowerCase()}`}>{announcement.category}</span><span>{new Date(announcement.createdAt).toLocaleDateString()}</span></div>
                <h3>{announcement.title}</h3>
                <p className="organization">{announcement.organization}</p>
                <p>{announcement.content}</p>
                <p className="expiry">Expires {announcement.expiresOn}</p>
              </article>
            ))}
          </div>
        </section>
      </section>
    </main>
  );
}

export default App;
Build and push the final version

A successful development preview does not guarantee that the production bundle compiles. Vite checks the finished source before Git sends it to GitHub.

  • Create a second terminal in Visual Studio Code.

Before running the build, predict whether the TypeScript checks will accept every new state value plus filter expression.

  • Build the production version by running this command in the second terminal:
npm run build

What does this build prove?

The build checks whether the final source can become production assets. A successful result confirms that the new search logic compiles with the existing typed data client.

You should see the production build finish successfully. The command should return control to the terminal without a TypeScript or Vite failure.

Did the production build fail?

  • Read the first reported file plus line number because later messages can be consequences of the same issue.
  • Check every use of filteredAnnouncements for identical spelling.
  • Use this prompt with the build output: Help me diagnose my CampusNotice production build failure without changing the Amplify data model.
  • Save every change in src/App.tsx.
  • Record the final tracked-file changes by running this command:
git commit -am "Add announcement search and filters"

What does this commit capture?

This commit records the final changes to the tracked CampusNotice source. Its message identifies search plus filtering as one deployable milestone.

You should see Git report a new commit containing the changes to src/App.tsx.

Could Git not create the commit?

  • Confirm the terminal is still inside the local campusnotice folder.
  • Check that src/App.tsx was saved before running the commit.
  • Use this prompt if Git reports an identity or repository problem: Help me resolve the Git error that prevents my CampusNotice search commit.
  • Send the final commit to the connected main branch by running this command:
git push origin main

How does the push start deployment?

The connected branch is the source for the hosted application. Pushing its newest commit starts an Amplify build with the completed CampusNotice interface.

You should see Git confirm that the main branch received the commit.

Did the push fail?

  • Confirm Visual Studio Code still has access to the GitHub account used for the campusnotice repository.
  • Check that the local branch is connected to origin.
  • Use this prompt with the terminal output: Help me fix the Git push failure for my CampusNotice main branch.
Verify the public MVP

The pushed commit now moves through AWS Amplify Hosting. The deployment can take a few minutes, so a quiet build screen is expected while the public version is generated.

  • Switch back to the CampusNotice Amplify app from earlier.
  • Select the deployment for the main branch.
  • Wait for the deployment to report success.
  • Open the public URL from the successful deployment.

You should see the CampusNotice publisher plus the live announcement feed at the hosted address.

Before testing the public app, predict which cards a matching keyword plus the Academic category should keep.

  • Duplicate the public CampusNotice page into a second browser tab.
  • Enter Library hours extended in the Title field of the first tab.
  • Enter University Library in the Organization field.
  • Select Academic from the publishing Category field.
  • Enter Add the details students need to know. in the Message field.
  • Choose a future date in the Expires on field.
  • Click Publish announcement.

You should see Announcement published. in the first tab. The new card should also appear in the second tab without a manual refresh.

  • Refresh the second browser tab.

The new announcement should remain visible after the reload. This confirms that the public app still reads persistent cloud records.

  • Enter Library in the feed's Search field.

The new card should remain visible because its title contains the keyword.

  • Replace the search term with University.

The same card should remain visible because its organization contains the keyword.

  • Replace the search term with details.

The card should remain visible because its message contains the keyword.

  • Replace the search term with Library.
  • Select Academic from the feed's Category field.

You should see only Academic cards that contain Library in the title, message, or organization. The count should match the cards still visible.

  • Replace the search term with zzzz.

You should see the No matching announcements empty state on the public site.

Does the public site show the old version?

  • Confirm the Amplify deployment for main completed after the final push.
  • Refresh the public page after the successful deployment finishes.
  • Use this prompt if the hosted app still lacks the controls: Help me compare my successful Amplify deployment with the latest CampusNotice GitHub commit.

Your CampusNotice MVP is now live with persistent records, real-time updates, keyword search, combined category filtering, result counts, and an empty state. The public URL now demonstrates the complete announcement workflow.

Secret mission

Hide Expired Announcements

CampusNotice keeps every announcement visible after its expiry date passes. Add a Hide expired control that removes old notices while preserving the existing keyword search and category filter.

Clean Up Your Resources

Clean Up Your Resources

Your CampusNotice environment is still live, so hosting and backend usage can continue after this project. Choose whether to keep it available, pause active development, or delete its cloud resources.

Cost warning

The $0 target applies only while your account is eligible for the AWS Free Tier. Your usage must also remain within its applicable allowances.

AWS Free Tier is not a spending cap. Amplify Hosting is metered separately from its backend services.

Choose Pause to stop active testing. Choose Delete to stop ongoing cloud usage from this project.

Resources you used:

  • The CampusNotice AWS Amplify app connected to the GitHub campusnotice repository on the main branch.
  • The public AWS Amplify Hosting production deployment.
  • The generated AWS AppSync API for announcement creation and real-time updates.
  • The Amazon DynamoDB table containing persistent Announcement records.
  • The public API key configured with a 30-day expiry.
  • The deployment infrastructure managed by Amplify.

Keep everything running

No action is needed. Choose this if you still use the public CampusNotice site or plan to extend it.

  • Keep the campusnotice repository connected to the Amplify app.
  • Keep the public CampusNotice URL available for demonstrations.
  • Monitor AWS Billing for usage from the hosting deployment and backend services.
  • Monitor your Free Tier usage while the environment remains deployed.

Pause - I'll come back to this later

Stop active development to reduce new builds and test traffic. The hosting deployment and backend resources remain online while paused.

  • Stop pushing commits to the main branch.
  • Avoid sending test traffic to the public CampusNotice URL.
  • Continue monitoring AWS Billing because the deployed resources still exist.
  • Resume development later by pushing a new commit to the main branch.

Delete - I don't want to use this again

Deleting the Amplify app is permanent. Your source code remains in the GitHub repository unless you remove that repository separately.

This removes the public hosting deployment. It also removes the managed AppSync API, DynamoDB table, API key, and deployment infrastructure.

  • Return to the AWS Amplify console from earlier.
  • Open the CampusNotice app.
  • Choose App settings.
  • Select General settings.
  • Choose Delete app.
  • Complete the confirmation shown by AWS Amplify.
  • Confirm that the CampusNotice app no longer appears in the Amplify console.

The public CampusNotice URL no longer serves the project. Keep the GitHub repository unless you no longer want the source code.

Nice Work!

Nice Work!

CampusNotice is live! Your announcement board now keeps campus notices synchronized across browser tabs.

A public AWS Amplify Hosting URL gives you a working demo that visitors can search and filter.

You've learned how to:

  • Build a responsive React and TypeScript interface that publishes campus announcements through a clear form.
  • Connect a typed Amplify Data client to AWS AppSync. Store persistent announcement records in Amazon DynamoDB.
  • Ship a keyword-searchable feed through Git-based CI/CD from GitHub to AWS Amplify Hosting.
  • Complete the Secret Mission by adding a Hide expired control that keeps past notices out of matching search results.

Ready to quiz yourself?