1995 Classifieds — Category grid, listing links, search/filter, post detail, create/edit/delete. Craigslist launched in 1995 as an email newsletter from Craig Newmark to friends listing local events in San Francisco. By 1996 it had a web interface and by 1999 it was a full classifieds empire — proving that a website doesn't need fancy graphics to change the world. Inspired by Craigslist circa 1999 and the enduring power of text-first web design.

01

HTML Structure

Complete

Why learn this?

Craigslist's 1995 prototype was an email newsletter — a list of text links sent to friends every week. When it moved to the web, the structure barely changed: a header with the "cl · classifieds" branding, category sections with headings, and unordered lists of links with prices and descriptions. This pattern — a categorized list of items with prices — is the foundation of every marketplace, directory, and classifieds site on the web. Building a Craigslist clone teaches semantic HTML structure with <section>, <h2>, and <ul>, the text-first content hierarchy that prioritizes information density over visual chrome, and the lesson that the web's most successful sites often have the simplest HTML.

Design decisions & tradeoffs

Semantic sections vs divs. Each category (housing, jobs, for sale, services, community) is a <section> with an <h2> heading and a <ul> of listing links. This is semantically correct — each section is a distinct content category. Screen readers navigate by heading level (h2) to jump between categories. The alternative — using <div> elements with class names — would be visually identical but would lose the semantic structure that helps assistive technology. The <ul> for listings is also correct — each listing is a list item, and the browser renders it with a default bullet that provides visual structure without CSS.

Text-only links with prices in the link text. Every listing is a plain <a href="#"> with the description and price embedded in the link text (e.g. "2br/1ba - Mission District - $3,200"). This is the Craigslist way — the link text contains all the information a user needs to decide whether to click. The alternative — having separate elements for title, price, and meta — would be more flexible for styling but would break the content-first simplicity. Embedding everything in the link text means the page is fully functional with CSS and JavaScript disabled.

Minimal header with pipe separators. The nav links (post | account | help) use pipe characters (&nbsp;|&nbsp;) as visual separators between links. This is a web 1.0 pattern — before CSS made flexible nav layouts practical, pipes were the standard way to separate navigation links. The alternative — CSS-based borders or gaps — is more flexible but adds complexity. Pipes in the HTML are zero-CSS, zero-JS, and perfectly communicate "these are separate navigation items."

No CSS, no images, no scripts. Step 1 has zero CSS beyond browser defaults. The page uses Times New Roman (the browser default serif), full-width layout, and default blue underlined links. This is deliberate — it shows what the raw content looks like before styling, and it proves that the content structure is complete on its own. Every subsequent step adds only what's needed, nothing more.

Browser compatibility

  • HTML5 semantic elements (<section>, <header>, <main>, <nav>): Supported in all modern browsers. For IE 8 and below, these elements render as inline by default and need display: block in CSS. The page works with the HTML5 Shiv polyfill for older IE.
  • <ul> and <li>: Supported since the beginning of HTML. Zero compatibility concerns for unordered lists.
  • <hr> thematic breaks: Supported universally. Used for visual separators between header regions. The default browser styling (color: gray, border-style: inset) varies slightly between browsers but is universally functional.
  • <h1><h2> heading hierarchy: Supported universally. The page uses a single <h1> for the site title and <h2> for each category — a clean, accessible heading outline.
  • &nbsp; non-breaking spaces: Supported universally. Used for nav pipe separators. The &nbsp; entity prevents line breaks between the pipe and adjacent text.

Accessibility details

  • Heading hierarchy. The page uses a single <h1> ("cl · classifieds") and multiple <h2> elements for each category section. This creates a clean document outline: Page → Category → Listings. Screen readers navigate by heading level to jump between housing, jobs, for sale, and other categories.
  • Link text clarity. Each listing link has descriptive text that includes both the item name and price (e.g., "2br/1ba - Mission District - $3,200"). Screen readers announce this full text, giving users enough context to decide whether to follow the link. Avoid bare links like "click here" or "details."
  • Section semantics. Each category is wrapped in a <section> element. Screen readers see these as distinct landmarks when they have accessible names. For production, add aria-labelledby referencing the <h2> id to give each section an explicit accessible name.
  • No skip link. The page has no skip-to-content link. For production, add <a href="#main" class="skip-link">Skip to content</a> as the first focusable element — it lets keyboard users bypass the header navigation.

Common pitfalls

  • Forgetting the <main> landmark. The listing content is wrapped in <main> — this is the primary content landmark. Without it, screen readers have no way to jump directly to the content. Always use exactly one <main> per page.
  • Plain <h2> without section semantics. Using bare <h2> elements outside of <section> containers is valid but provides less structural information to assistive technology. The <section> wrapper signals that the heading and its content form a self-contained unit.
  • Pipe characters without whitespace control. The pattern <a>post</a> | <a>account</a> can break across lines on narrow screens. Using &nbsp;|&nbsp; prevents the pipe from wrapping to the next line alone — it stays attached to the preceding or following space.

Key concepts

  • <section> — Content grouping. Each category is a semantic section with its own heading
  • <ul> — Unordered list for listings. Screen-reader friendly, browser-default bullets
  • <h1><h2> — Heading hierarchy. Single h1 for page title, h2 for category headings
  • &nbsp; — Non-breaking space. Prevents inline separators from wrapping alone
  • href="#" — Placeholder links. Use real URLs in production — placeholder links provide no navigation
  • Text-first — Content before chrome. The HTML is fully functional without CSS or JS

Next up

Step 2 adds the iconic Craigslist monospace styling: Courier New font family, constrained 780px width, uppercase category headers, blue link colors, and the spartan zero-frills aesthetic.

Header & Nav ▶ Run
<header>
  <h1>cl · classifieds</h1>
  <nav>
    <a href="#">post</a>&nbsp;|&nbsp;
    <a href="#">account</a>&nbsp;|&nbsp;
    <a href="#">help</a>
  </nav>
  <hr>
  <p>san francisco bay area
    &gt; all listings</p>
  <hr>
</header>
Category sections
<section>
  <h2>housing</h2>
  <ul>
    <li><a href="#">2br/1ba -
      Mission District -
      $3,200</a></li>
    <li><a href="#">roommate -
      Castro - $1,100</a></li>
    <li><a href="#">studio -
      SOMA - $2,400</a></li>
  </ul>
</section>

<section>
  <h2>jobs</h2>
  <ul>
    <li><a href="#">backend
      engineer - $150k</a></li>
    <li><a href="#">barista -
      $22/hr</a></li>
  </ul>
</section>
Footer
<hr>
<footer>
  <small>cl · classifieds
    &copy; 2026</small>
</footer>
📐 1995 content mindset: Craigslist started as a plain-text email — no images, no CSS, no JavaScript. The HTML structure in Step 1 mirrors that email: a header with the site name and nav, a list of categorized items, and a footer. Every listing is self-contained in one <a> tag — title, description, and price in one string. This "content is king" philosophy means the page is fully functional in lynx, curl, or any text-mode browser. When every byte counts, embed all the information in the link text.
02

CSS Styling

Complete

Why learn this?

Craigslist's visual design is a masterclass in intentional minimalism. The monospace font, the constrained content width, the blue-purple link color scheme, the uppercase category headers with thin bottom borders — every visual choice prioritizes readability and information density over decoration. This step teaches the CSS patterns that create a text-first brand identity: system font stacks with Courier New, max-width centered containers, link color states (:link, :visited, :hover), horizontal rule styling, and the "no rounded corners, no shadows, no gradients" philosophy that makes Craigslist instantly recognizable. These are the CSS fundamentals that every developer needs — layout, typography, color, and the discipline to stop adding visual flourishes.

Design decisions & tradeoffs

Monospace font stack vs system sans-serif. The page uses font-family: "Courier New", "Liberation Mono", monospace — a monospace stack that falls back to any system monospace font. Monospace is Craigslist's defining visual trait — it evokes the typewriter-era classifieds ads that Craig Newmark digitized. The alternative — system sans-serif (Helvetica, Arial) — would be more readable at small sizes but would lose the iconic Craigslist character. The tradeoff: monospace text takes more horizontal space per character, which is why Craigslist uses a narrower content width (780px) to compensate.

780px max-width vs fluid layout. The body is constrained to max-width: 780px; margin: 0 auto — a centered container that keeps line lengths readable on wide screens. At 14px monospace, 780px allows approximately 75-80 characters per line — near the optimal line length for readability (50-75 characters). The alternative — a fluid layout that expands to fill the viewport — would make lines too long on large screens, forcing users to scan horizontally. The 780px constraint is a deliberate readability decision that predates responsive design by a decade.

Blue links, purple visited, no underline on hover. The link color scheme (#0000ee unvisited, #551a8b visited) is the unmodified browser default — Craigslist famously doesn't override it. The hover state uses a subtle yellow highlight (background: #ffffaa) instead of the more common underline or color change. This is surprisingly effective — the yellow highlight provides a clear hover signal without changing the link color (which would interfere with visited/unvisited state recognition). The visited state in purple tells users which listings they've already seen — a critical UX feature for a classifieds site where users scan hundreds of listings.

Uppercase section headers with bottom border. Category headings use text-transform: uppercase; border-bottom: 1px solid #888 — a compact, scannable header style. The uppercase is achieved via CSS, not by typing uppercase in the HTML — this keeps the HTML content lowercase and lets the presentation layer handle the capitalization. The bottom border creates a clear visual separator between the category header and its listings without taking up extra vertical space. The alternative — larger font sizes or padding-based separators — would push listings further down the page.

No rounded corners, no shadows, no gradients. Zero border-radius, zero box-shadow, zero gradient. Craigslist's flat design was unintentional — it launched in 1995 before CSS3 rounded corners existed — but it became a deliberate brand choice. The tradeoff: the page looks "ugly" by modern standards, but it loads instantly (no CSS frameworks, no fonts to download), works perfectly in text browsers, and focuses the user entirely on the content. Every modern developer should build at least one project that deliberately avoids decoration — it teaches what CSS is essential vs what's just ornamentation.

Browser compatibility

  • font-family with fallback stack: Supported universally. The "Courier New" font is available on Windows and macOS; Liberation Mono on Linux. The monospace fallback ensures every system renders in a monospace font.
  • max-width with auto margins: Supported universally. The centered container pattern works in IE 5.5+. No modern CSS features needed for this layout.
  • text-transform: uppercase: Supported universally. Transforms heading text to uppercase regardless of the source HTML casing.
  • :link, :visited, :hover pseudo-classes: Supported universally. The link color scheme uses the most basic CSS selectors available.
  • background: #ffffaa on hover: Supported universally. The yellow hover highlight is a simple background color change — no transitions, no transformations.
  • border-top on <hr>: Supported universally. The horizontal rules are restyled from their default 3D inset to a simpler 1px solid #888.

Accessibility details

  • Monospace font readability. Monospace fonts are generally less readable than proportional fonts for body text — each character takes the same width, making word shapes harder to recognize. The 14px font size and 1.5 line height provide adequate readability. For users with dyslexia, monospace can be challenging — consider adding a "readable font" toggle that switches to a sans-serif stack.
  • Link color contrast. Blue links (#0000ee) on white background have excellent contrast (~7.5:1). Purple visited links (#551a8b) on white background have ~5.5:1 — exceeding WCAG AA for normal text. The yellow hover highlight (#ffffaa) provides a clear visual state change that doesn't rely on color alone.
  • Uppercase text readability. Section headers in uppercase have ~10% lower readability than mixed case. However, headers are brief (5-10 characters), so the readability impact is minimal. Avoid uppercase for body text or longer headings.
  • No focus indicator styling. The page doesn't customize :focus or :focus-visible styles. Keyboard users see the browser-default focus ring (typically a blue outline) on links. This is functional but not brand-consistent. For production, add a custom focus style: a:focus-visible { outline: 2px solid #000; outline-offset: 2px; }.
  • Article/list screen reader flow. Screen readers announced the full link text (e.g., "2br/1ba - Mission District - $3,200"). The consistent format (description + location + price) helps users quickly scan and understand each listing. Ensure price is always included in the link text so screen reader users get the same information as sighted users.

Common pitfalls

  • Monospace + poor line length. Monospace text at 14px in a full-width container can hit 120+ characters per line — far beyond the readable 50-75 characters. The 780px max-width is essential. Without it, users physically cannot scan the listing text without moving their head or scrolling horizontally.
  • Uppercase in HTML instead of CSS. Writing <h2>HOUSING</h2> instead of <h2>housing</h2> with text-transform: uppercase. The CSS approach keeps the HTML semantic and allows easy changes to the presentation (e.g., switching to title case) without editing markup.
  • Global link underline removal. Never apply a { text-decoration: none; } globally on a text-first site. Underlined links are the web's original affordance — removing them makes links indistinguishable from plain text. Craigslist keeps browser-default link styling for this reason.
  • Hover-only interactive cues. The yellow hover highlight works for mouse users but provides no feedback for keyboard or touch users. Always pair hover effects with :focus-visible equivalents for keyboard accessibility.

Key concepts

  • font-family: "Courier New", monospace — Monospace font stack. Fallback order ensures every system renders monospace
  • max-width: 780px; margin: 0 auto — Centered container. Restricts line length without fixed widths
  • text-transform: uppercase — CSS-powered capitalization. Keeps HTML lowercase, presentation separate
  • a:link { color: #0000ee } — Unvisited link color. Browser default blue, intentionally unchanged
  • a:visited { color: #551a8b } — Visited link color. Purple signals "already seen" — critical for classifieds scanning
  • a:hover { background: #ffffaa } — Yellow hover highlight. Simple, effective, doesn't change link color
  • hr { border: none; border-top: 1px solid #888 } — Restyled hr. Replaces 3D inset with a flat, thin line

Next up

Step 3 adds JavaScript interactivity: real-time search filtering, category dropdown filter, collapsible section headers, a live listing count, and a logo-click reset.

Body & typography ▶ Run
body {
  font-family: "Courier New",
    "Liberation Mono", monospace;
  font-size: 14px;
  line-height: 1.5;
  color: #222;
  background: #fff;
  max-width: 780px;
  margin: 0 auto;
  padding: 20px;
}
header h1 {
  font-size: 24px;
  font-weight: normal;
  letter-spacing: -1px;
  color: #000;
}
Section headers & links
section h2 {
  font-size: 14px;
  font-weight: bold;
  text-transform: uppercase;
  border-bottom:
    1px solid #888;
  margin: 16px 0 8px;
  padding-bottom: 2px;
}
a:link {
  color: #0000ee;
}
a:visited {
  color: #551a8b;
}
a:hover {
  background: #ffffaa;
}
section ul {
  margin: 0;
  padding-left: 24px;
}
section li {
  font-size: 14px;
  line-height: 1.5;
}
Header & hr restyling
header nav {
  font-size: 13px;
  margin: 4px 0 12px;
}
header nav a {
  color: #0000ee;
  text-decoration: underline;
}
header hr, footer hr {
  border: none;
  border-top: 1px solid #888;
  margin: 8px 0;
}
header p {
  font-size: 13px;
  color: #555;
  margin-bottom: 4px;
}
🎨 Craigslist's design manifesto: Every visual choice on Craigslist serves a single goal: get the user to the content as fast as possible. The monospace font? Faster loading than downloading a web font. No rounded corners? Fewer CSS bytes to download. Blue default links? Users already know what they look like. Yellow hover highlight? The simplest possible interactive feedback. When building a text-first site, ask yourself: "Does this CSS help the user read listings faster?" If the answer is no, delete it. This discipline is harder than adding decorations — which is why so few sites match Craigslist's focus.
03

JavaScript

Complete

Why learn this?

Client-side search and filtering is the most common JavaScript pattern in web development — every e-commerce site, directory, marketplace, and documentation index uses it. The core mechanic — an input that filters visible items in real time — teaches event-driven programming with input events, data-driven rendering with a render() function that rebuilds the DOM from a filtered data array, array methods (filter(), map(), join()), and state management (tracking both search text and category filter simultaneously). For Craigslist specifically, search and filter are the primary way users navigate thousands of listings — without them, the site would be unusable. This step turns a static HTML page into a dynamic single-page application using nothing but vanilla JavaScript.

Design decisions & tradeoffs

Data-driven render() vs DOM manipulation. The page stores listings as a JavaScript array of objects (data[]) and rebuilds the entire listing HTML from scratch every time the filter changes. A render() function filters the array, groups by category, and generates HTML via map() and join(). The alternative — showing/hiding individual DOM elements with classList — would avoid rebuilding DOM but would be harder to maintain as the app grows. The data-driven approach is more code initially but is the foundation for Step 4 where full CRUD operations modify the same data array.

Dual filter: text search + category dropdown. The search supports two simultaneous filter dimensions: a free-text search input (filters by title and price) and a category dropdown (narrows to one category or "all"). Both filters are AND-combined — listings must match both the text query AND the selected category. This allows users to search "bike" within "for sale" — a common pattern on classifieds sites where users know both what they want and what category it's in. The filter function checks (item.title + " " + item.price).toLowerCase().includes(q) — simple, fast, and good enough for 20-100 listings.

Collapsible section headers vs always-visible listings. Each category heading is clickable — clicking it toggles display: none on the <ul> below. The collapse state is tracked per-section with a data attribute (data-collapsed). The toggle indicator shows [–] or [+] to signal state. The alternative — always showing all listings — would be simpler but less usable when a category has dozens of items. Collapsible sections let users focus on specific categories without scrolling past irrelevant ones.

Logo click as global reset. Clicking the "cl · classifieds" header resets the search input, category dropdown, and all collapsed sections to their default state. This is a user-friendly reset pattern — instead of manually clearing search, switching category to "all," and expanding all sections, the user clicks one element. The pattern is common in single-page apps (clicking the logo "goes home") but works here as a state reset instead of a navigation event.

Live count display. A <span class="count"> shows "N listings" that updates every time the filter changes. This gives users immediate feedback about filter results — if they search "bike" and the count drops to 2, they know the filter is working. Without the count, users might think the filter is broken or that items are missing. The count is part of the render output, so it updates automatically with every render() call.

Browser compatibility

  • Array.prototype.filter(): Supported since Chrome 1, Firefox 1.5, Safari 3, IE 9. Used for filtering the data array. Falls back to a for loop in IE 8-.
  • Array.prototype.map(): Supported since Chrome 1, Firefox 1.5, Safari 3, IE 9. Used for generating listing HTML strings. Falls back to a for loop in IE 8-.
  • String.prototype.includes(): Supported since Chrome 41, Firefox 40, Safari 9. Used for text search matching. Falls back to indexOf() !== -1 in IE 11 and below.
  • String.prototype.toLowerCase(): Supported universally. Normalizes search text and listing content for case-insensitive comparison.
  • Element.closest(): Supported since Chrome 41, Firefox 35, Safari 6. Used for collapse toggle event delegation. For IE support, use a while-loop parent traversal.
  • classList.contains()/.add()/.remove(): Supported since Chrome 8, Firefox 3.6, Safari 5.1, IE 10. Used for collapse state visibility toggling.

Accessibility details

  • Filter results not announced to screen readers. When the search input filters listings, the count display updates but has no aria-live="polite" attribute — screen readers won't announce the count change. Add aria-live="polite" to the count element so filter results are announced automatically.
  • Category dropdown label. The category filter uses a <label> element (<label>category</label>), which is semantically correct. However, without a for attribute matching the <select> id, the association is implicit (label wraps the select) rather than explicit. Both patterns work, but explicit for/id association is more robust.
  • Collapse toggle on headings. Section headings with click handlers are <h2> elements, not <button> elements. They are not keyboard accessible by default — they need role="button", tabindex="0", and keyboard event handlers for Enter/Space. For production, use <button> elements styled as headings, or add keyboard event listeners to the heading elements.
  • Hidden listings removed from accessibility tree. Collapsed sections use display: none on the <ul>, which removes items from the accessibility tree — correct behavior. Screen reader users navigating by heading won't encounter the hidden listings.

Common pitfalls

  • Case sensitivity in search. The search converts both input and content to lowercase via .toLowerCase(). Without this, searching "bike" wouldn't match "Bike" (capitalized). Always normalize case for text search comparisons.
  • XSS in listing title rendering. If listing titles contain HTML (<script> tags, etc.), using innerHTML to render them creates an XSS vulnerability. The current code uses innerHTML with template literals. For production, either sanitize titles with textContent or use a DOMPurify library. A simple escape function can prevent the most common attacks.
  • Rendering empty categories. When a category has no matching items after filtering, the code should skip rendering that section entirely. The current code does this by checking if (!items) return; before generating HTML for each category — but it's easy to forget this check and render empty sections with "0 listings."
  • Debounce for large datasets. The search filters on every input event — which fires on every keystroke. For 20 listings, the performance is imperceptible. But for 1000+ listings, add a debounce of 150-200ms to avoid redundant filtering while the user is still typing.

Key concepts

  • data[] → filter() → render() — Data-driven pipeline. Filter the array, then rebuild the DOM
  • .toLowerCase().includes() — Case-insensitive search. Always normalize both input and content
  • array.map().join("") — Convert array to HTML string. The standard pattern for generating listing markup
  • input event — Real-time search. Fires on every keystroke, unlike change which fires on blur
  • classList.toggle() — Collapse state. Show/hide sections without removing from DOM
  • Event delegation — Single listener on parent. Use e.target.closest() instead of binding per-element

Next up

Step 4 adds full CRUD operations with hash routing: create new listings, edit existing ones, delete with confirmation, and detail views with descriptions and contact info.

Search filter JS ▶ Run
const data = [
  { id: 1, cat: "housing",
    title: "2br/1ba - Mission",
    price: "$3,200" },
  { id: 2, cat: "housing",
    title: "roommate - Castro",
    price: "$1,100" },
  // ... 20 items total
];

function render() {
  const q = searchEl.value
    .trim().toLowerCase();
  const catSel = catEl.value;

  const filtered = data
    .filter(item => {
    if (catSel !== "all"
      && item.cat !== catSel)
      return false;
    if (q) {
      const text = (item.title
        + " " + item.price)
        .toLowerCase();
      if (!text.includes(q))
        return false;
    }
    return true;
  });

  // Group by category
  const groups = {};
  filtered.forEach(item => {
    if (!groups[item.cat])
      groups[item.cat] = [];
    groups[item.cat].push(item);
  });

  // Build HTML
  let html = "";
  catOrder.forEach(cat => {
    const items = groups[cat];
    if (!items) return;
    html += `<section>...`;
  });
  listingsEl.innerHTML = html;
}
Controls HTML
<div class="controls">
  <input type="text"
    id="search"
    placeholder="search"
    autofocus>
  <label>category
    <select id="catFilter">
      <option value="all">
        all</option>
      <option value="housing">
        housing</option>
      <option value="jobs">
        jobs</option>
      <option value="forsale">
        for sale</option>
    </select>
  </label>
  <span class="count"
    id="countDisplay">
    20 listings</span>
</div>
Collapse toggle
// Collapse toggle on
// section headings
container.addEventListener(
  "click", e => {
  const h2 = e.target
    .closest("section h2");
  if (!h2) return;
  const ul = h2
    .nextElementSibling;
  if (!ul || ul.tagName
    !== "UL") return;
  const collapsed =
    h2.dataset.collapsed
    === "true";
  h2.dataset.collapsed
    = !collapsed;
  ul.style.display =
    collapsed ? "" : "none";
  const toggle = h2
    .querySelector(".toggle");
  if (toggle)
    toggle.textContent =
      collapsed ? "[–]" : "[+]";
});

// Logo click = global reset
logo.addEventListener(
  "click", () => {
  searchEl.value = "";
  catEl.value = "all";
  render();
});
🔍 Vanilla JS data flow: The render pipeline is a single function: filter() → group() → map() → innerHTML. This is the same pattern used by React, Vue, and Svelte — just without the framework. The data array is the single source of truth — the DOM is always a reflection of the data, not the other way around. This unidirectional data flow makes debugging simple: if the UI is wrong, check what's in the data array. For 20 listings, innerHTML rebuilds the entire listing section in under 5ms — there's zero benefit to using a virtual DOM at this scale.
04

Search, Filter & CRUD

Complete

Why learn this?

Full Create, Read, Update, Delete (CRUD) functionality is the foundation of every data-driven web application — from classifieds and marketplaces to project management tools and CRM systems. This step combines everything built so far (HTML structure, CSS styling, JavaScript filtering) with the missing pieces: persistent data management, hash-based client-side routing, form handling for create and edit, and detail views. The hash router (#/, #/view/5, #/new, #/edit/5) enables browser back/button navigation without a server — the same pattern used by early SPAs like Backbone.js and still used by many modern single-page apps. Building CRUD from scratch in vanilla JavaScript teaches the architecture patterns that every framework (React, Vue, Angular) implements: state management, routing, form validation, and the separation of data layer from presentation layer.

Design decisions & tradeoffs

Hash-based routing vs pushState. The app uses window.location.hash for navigation — a hash change fires a hashchange event, which the router parses to determine which page to render. The router supports four routes: #/ (home), #/view/:id (detail), #/new (create form), #/edit/:id (edit form). The router parses the hash with a simple string split — no regex, no framework, no dependencies. The alternative — using the History API (pushState) — would give cleaner URLs without hashes but requires a server that can serve the same HTML for all routes (or a 404 fallback). Hash routing works everywhere, including file:// protocol, with zero server configuration.

SPA architecture with vanilla JS vs framework. The entire app is a single HTML file with embedded CSS and JS — no build step, no npm install, no framework. The architecture mirrors what you'd get with a small React app: a data layer (listings[] array), a router (navigate() + hashchange), page renderers (renderHome(), renderDetail(), renderNewForm(), renderEditForm()), and a toolbar with search + category filter that persists across navigation. The tradeoff: vanilla JS doesn't provide reactivity (the DOM doesn't automatically update when data changes), but for a single-user app with 20-50 listings, calling renderPage() after every mutation is trivial.

Form for create/edit vs inline editing. New and edit operations use a dedicated form view — a full-page layout with fields for title, category, price, description, and contact info. The form is rendered by renderNewForm() or renderEditForm() (with pre-filled values for edits). The alternative — inline editing on the detail page or a modal dialog — would keep the user on the same page but adds complexity (modal overlay, focus trapping, body scroll lock). A dedicated form page is simpler and more accessible.

Delete with confirmation vs instant delete. Deleting a listing shows a browser confirm() dialog before removing the item. This prevents accidental deletions — the most common user error in CRUD apps. The alternative — an undo feature that shows a temporary message ("Listing deleted. Undo?") with a 5-second timeout — would be more user-friendly but adds state complexity (tracking pending deletions, managing the undo timeout). The confirm() approach is simple and universally supported.

Data persistence: in-memory only vs localStorage. The current app stores data in a JavaScript array — data is lost on page refresh. For production, add localStorage persistence: save listings to localStorage.setItem("cl-listings", JSON.stringify(listings)) on every mutation, and load it on page boot. The tradeoff: localStorage adds ~20 lines of code but requires handling JSON parse errors and migration from seed data to stored data. The in-memory-only approach keeps the code focused on the CRUD architecture.

Browser compatibility

  • hashchange event: Supported since Chrome 5, Firefox 3.6, Safari 5, IE 8. The window.addEventListener("hashchange", handler) pattern works in all modern browsers. For IE 7, fall back to polling location.hash on a timer.
  • JSON.parse() and JSON.stringify(): Supported since Chrome 1, Firefox 3.5, Safari 4, IE 8. Used for data serialization. The localStorage persistence layer depends on these — without them, data can't be saved across sessions.
  • Object.assign(): Supported since Chrome 45, Firefox 34, Safari 9. Used for immutable update pattern in the edit flow. Falls back to a manual property copy loop (for...in) in IE 11 and below.
  • Array.find(): Supported since Chrome 45, Firefox 25, Safari 8. Used to find listings by id. Falls back to a for loop in IE 11 and below.
  • parseInt() with radix: Supported universally. Used to parse route parameters (parseInt(parts[1], 10)). Always include the radix parameter to prevent octal interpretation in older browsers.
  • form.reset(): Supported universally. Resets form fields to their default values. Works in every browser since HTML 4.

Accessibility details

  • SPA navigation without focus management. When the hash changes and a new page renders, focus stays on the element that triggered the navigation. For example, clicking "Edit" on a detail view leaves focus on the edit button, but the button disappears when the edit form renders. Focus must be explicitly moved to the form heading or first input with .focus(). Add tabindex="-1" to the form's <h2> to make it programmatically focusable.
  • Hash navigation and screen readers. The hashchange event alone doesn't announce page changes to screen readers. Add an aria-live="polite" region that announces "Showing detail for [title]" or "Create a new listing" whenever the route changes. This is the SPA equivalent of a page title update for multi-page apps.
  • Form validation error association. The create/edit form validates fields on submit. Error messages are <div> elements placed after each input but not linked via aria-describedby. For production, add aria-describedby="titleError" to the title input so screen readers announce the error when validation fails.
  • Delete confirmation accessibility. The confirm() dialog is a browser-native modal — it's accessible by default (keyboard operable, screen reader announces the message). However, custom confirmation modals often fail to trap focus or announce changes. If replacing confirm() with a custom dialog, ensure it implements focus trapping, role="alertdialog", and aria-modal="true".
  • Detail view focus on navigation. When navigating from the home listing to a detail view (e.g., clicking #/view/5), focus should move to the detail heading. Without this, keyboard users tabbing through links on the home page end up in the footer after clicking a listing — the detail content is below the current focus position, requiring back-tabbing to find it.
  • No-results state after CRUD. When the last listing in a category is deleted, the home page should show a clear message ("No listings found") that is announced by screen readers via role="status". Currently, deleting all listings results in a blank content area.

Common pitfalls

  • Hash route parsing without base case. The router splits location.hash on /. If the hash is empty ("") or just # (no route), parts[0] is an empty string. Always handle this case: if (!parts[0] || parts[0] === "") route = "home";. Without this guard, the router tries to render "" as a page name and silently fails.
  • Data mutation vs immutable update. The delete operation uses listings = listings.filter(l => l.id !== id) — an immutable operation that creates a new array. The edit operation uses Object.assign(existing, updates) — a mutation that modifies the existing object. Mixing immutable and mutable patterns can lead to subtle bugs. Choose one pattern and stick with it: immutable is safer (no side effects), mutable is faster (no object creation).
  • Form state persistence across navigation. If the user starts filling out a new listing form, then navigates away (back button, detail view), then returns to the new form, the partially filled data is lost. For production, save form state to a global draft object that persists across route changes, or warn the user before navigating away with beforeunload.
  • Double submission on create/edit. Users can double-click the submit button, creating duplicate listings. Disable the submit button on first click: submitBtn.disabled = true. Re-enable it only after the operation completes or fails.
  • ID collision after deletion. The nextId counter never decrements. If you delete listing id 5 and create a new one, it gets id 6 — not 5. This is fine for most apps but can lead to large ID gaps over time. For production, consider UUIDs or a counter that tracks the max used id.

Key concepts

  • hashchange event — Hash-based routing. Fires when location.hash changes, enabling SPA navigation with browser back button support
  • window.location.hash — URL fragment. Parse with split("/") for route matching — no regex needed
  • listings.filter(l => l.id !== id) — Delete operation. Filter out the item, re-render. Immutable pattern
  • listings.push(newItem) — Create operation. Add to array, navigate to new item's detail view
  • Object.assign(existing, updates) — Update operation. Merge new values into existing object in place
  • Unidirectional data flow — Data → render → DOM. Never read from DOM for state — always read from the data array
  • SPA without framework — Vanilla JS SPA architecture. Data layer + router + renderers. Everything you need, nothing you don't
Hash router ▶ Run
function navigate(hash) {
  let route = hash
    .replace(/^#\//, "")
    || "home";
  const parts = route.split("/");

  if (parts[0] === "view"
    && parts[1]) {
    currentRoute =
      { page: "detail",
        params: {
          id: parseInt(parts[1])
        } };
  } else if (parts[0]
    === "edit" && parts[1]) {
    currentRoute =
      { page: "edit",
        params: {
          id: parseInt(parts[1])
        } };
  } else if (parts[0]
    === "new") {
    currentRoute =
      { page: "new",
        params: {} };
  } else {
    currentRoute =
      { page: "home",
        params: {} };
  }
  renderPage();
}

window.addEventListener(
  "hashchange",
  () => navigate(location.hash)
);
CRUD operations
// CREATE
function createListing(data) {
  data.id = nextId++;
  listings.push(data);
  navigate("#/view/"
    + data.id);
}

// READ (via renderHome
//  and renderDetail)
function renderDetail(id) {
  const item = listings
    .find(l => l.id === id);
  if (!item) {
    navigate("#/");
    return;
  }
  // render detail view
  // with title, price, desc,
  // contact, edit/delete btns
}

// UPDATE
function saveEdit(id, updates) {
  const item = listings
    .find(l => l.id === id);
  if (item)
    Object.assign(item, updates);
  navigate("#/view/" + id);
}

// DELETE
function deleteListing(id) {
  if (!confirm(
    "Delete this listing?"))
    return;
  listings = listings
    .filter(l => l.id !== id);
  navigate("#/");
}
Data model
let listings = [
  {
    id: 1,
    cat: "housing",
    title:
      "2br/1ba - Mission",
    price: "$3,200",
    desc: "Sunny 2-bedroom
      in the Mission...",
    contact:
      "415-555-0101"
  }
  // ... more listings
];

const catLabels = {
  housing: "housing",
  jobs: "jobs",
  forsale: "for sale",
  services: "services",
  community: "community"
};

const catOrder = [
  "housing", "jobs",
  "forsale", "services",
  "community"
];
🗺️ Hash routing explained: #/view/5 is parsed by splitting on /: ["", "view", "5"]. The first meaningful part (parts[0]) determines the page type (view), and parts[1] becomes the parameter (the listing id). When the user clicks a link like <a href="#/view/5">, the browser updates location.hash and fires the hashchange event — the router catches it, parses the route, and calls the appropriate render function. The browser's back button triggers another hashchange — restoring the previous state. This is the same routing mechanism used by Backbone.js (2010) and still used by many vanilla JS SPAs today.
Model
qwen-3.7-max (via Nexum)
Total Tokens
~15.2K
Est. Cost
~$0.01
Steps
4 / 4
Output tokens measured from demo files × 0.28 tok/byte. Input estimated from session context. qwen-3.7-max (via Nexum) pricing. Updated as each step completes.

✅ Craigslist Clone Complete!

All 4 steps are built. The Craigslist Clone demonstrates: semantic HTML category sections, text-first listing layout, monospace typography with constrained content width, blue/purple link color scheme, real-time search filtering with category dropdown, collapsible section headers, full CRUD operations (create/read/update/delete), hash-based client-side routing, form handling for new and edit listings, and detail views with descriptions and contact info. Next project: a Hotmail Clone — teaching webmail interfaces, inbox layout, compose/send flow, and the fundamentals of browser-based email clients.

06
Hotmail CloneWebmail · Folder sidebar · Compose/send · localStorage persistence

Lessons Learned — Build Process

The AI challenges, design insights, and pipeline improvements from building Craigslist Clone with AI.

🎮
Why Craigslist's Design Endures

Craigslist has changed less than any other major web property in 30 years. The monospace font, blue links, and white background that launched in 1995 are essentially the same design in 2026. This is not laziness — it's a deliberate philosophy that content is the interface. Every design change would add friction for millions of users who scan hundreds of listings daily. The transferable principle: for utility-focused applications, stability is a feature. Users don't want a redesign — they want the listings to load fast and the search to work. The Craigslist design is a monument to the idea that the best interface is the one users don't have to think about.

⚠️
The Problem — AI Shortcomings

The AI generated inconsistent data model patterns across steps — step 3 used a flat data array with id, cat, title, and price, but step 4 added desc and contact fields without backfilling the seed data consistently. Some listings had desc set to an empty string while others had full descriptions. The hash router was initially too complex — using regex-based route matching when a simple string split() would have been cleaner and more readable. The form validation was inconsistent between new and edit modes — edit mode didn't validate required fields on save.

🛠️
The Fix — Pipeline Improvements

All seed data now shares a single data model with consistent field initialization — every listing has id, cat, title, price, desc, and contact. The hash router was simplified to use location.hash.replace(/^#\//, "").split("/") — no regex, no complex parsing, just array indexing. Form validation is now shared between create and edit modes via a single validateListing() function. The data consistency fix saved time on every subsequent step because the data model didn't change between steps 3 and 4.

Each app builds on the last. The bugs found in craigslist-clone were fixed before the next app was built — and every bug saves time on every future app. The monospace-spartan aesthetic is harder to get right than it looks — every line of CSS needs to justify its existence.