Build a Healthcare Provider Finder PWA
Build a mobile-first PWA that searches real US healthcare provider data.
Introduction
30 Second Summary
Finding a healthcare provider in the US is harder than it should be. You search online, get buried in ads, and still can't tell which providers are near you or what they specialize in.
In this project, you will build CareConnect, a mobile-first web app that searches real US healthcare provider data by keyword and location. You will turn it into an installable Progressive Web App that lives on your phone's home screen like a native app.
What You'll Build
Open CareConnect on your phone, type "dentist bethesda," and instantly see a list of real providers with names, specialties, addresses, and tap-to-call phone numbers.
By the end of this project, you'll have:
- A healthcare provider search powered by free government data from the National Library of Medicine, returning real provider names, specialties, and addresses.
- Mobile-first provider cards with tap-to-call phone numbers that adapt to any screen size, so the app looks great on a phone or a laptop.
- An installable Progressive Web App that launches from your home screen in its own window, plus a system architecture diagram built right into the app for your portfolio.
- Secret Mission: Add provider type filtering so users can narrow search results by specialty category.
Are there any prerequisites?
This project assumes you are comfortable with basic HTML, CSS, and JavaScript. You should also have VS Code and Node.js already installed on your machine.
Before We Start
Before you start building, take a moment to commit to what you are creating and why it matters.
In this project, you are building CareConnect, a search tool that helps Americans find healthcare providers near them using free government data from the National Library of Medicine.
Set Up Your Project
Before you can build CareConnect, you need a local development environment that lets you write code and instantly see changes in the browser.
In this step you will scaffold a new project using Vite, a modern build tool that gives you hot-reload and a local server with zero configuration. Then you will replace the starter files with the CareConnect HTML shell, base styles, and a system architecture diagram.
In this step, get ready to:
- Scaffold a Vite vanilla JavaScript project and confirm the dev server runs.
- Replace the starter files with the CareConnect HTML structure and mobile-first styles.
- Add a system architecture diagram that documents how the app works.
Scaffold the project with Vite
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open a terminal inside VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Move to your Desktop by running this command:
cd ~/Desktop
- Scaffold the CareConnect project by running this command:
npm create vite@latest careconnect -- --template vanilla
What does this command do?
This tells npm to download the create-vite scaffolding tool and generate a new project called careconnect using the vanilla template (plain HTML, CSS, and JavaScript with no framework).
Because you specified both the project name and template on the command line, Vite creates the project immediately with no interactive prompts.
- Confirm the folder was created by running:
ls
You should see careconnect listed in the output.
Don't see careconnect?
- Make sure you ran cd ~/Desktop first. If your terminal was in a different directory, the folder might be elsewhere.
- Check for typos in the scaffold command. The project name careconnect and the template vanilla must be spelled exactly.
- help me troubleshoot the Vite scaffold command
Now open the project folder in VS Code so you can see the files in the Explorer sidebar and keep the dev server running in a terminal at the same time.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop and select the careconnect folder, then click Open.
VS Code reloads with the careconnect project. You should see files like index.html, package.json, and a src folder in the Explorer sidebar.
- Open a new terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Install the project dependencies by running:
npm install
- Start the development server by running:
npm run dev
Before you open the browser, what do you think you will see at the URL Vite prints in the terminal?
- Open http://localhost:5173 in your browser.
You should see the default Vite starter page with a counter button and the Vite logo.
The dev server is running
The npm run dev command starts a local development server that watches your files. Every time you save a change, the browser refreshes automatically.
This terminal is now occupied by the dev server. Do not close it. You will edit files in VS Code's editor tabs for the rest of this step.
Browser shows nothing at localhost:5173?
- Check the terminal output for the exact URL. Vite sometimes uses a different port if 5173 is already in use.
- Make sure npm install completed without errors before you ran npm run dev.
- help me get the Vite dev server running
Clean up the starter files and create the project structure
Vite's starter template includes a counter demo you do not need. You will replace index.html with the CareConnect HTML shell, clear src/main.js, and write your own styles in src/style.css.
- In the VS Code Explorer sidebar, click src/main.js to open it.
- Select all the code with Cmd+A (macOS) or Ctrl+A (Windows), then press Delete to clear the file.
- Save the empty file with Cmd+S (macOS) or Ctrl+S (Windows).
- Click index.html in the Explorer sidebar to open it.
- Select all and delete the existing content.
- Replace it with the CareConnect HTML head and header by pasting this code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a6e5c" />
<link rel="manifest" href="/manifest.json" />
<link rel="stylesheet" href="/src/style.css" />
<title>CareConnect</title>
</head>
<body>
<header>
<h1>CareConnect</h1>
<p>Find healthcare providers near you</p>
</header>
<main>
<section id="search-section">
<form id="search-form">
<input
type="text"
id="search-input"
placeholder="e.g. dentist bethesda"
aria-label="Search for a healthcare provider"
required
/>
<button type="submit">Search</button>
</form>
</section>
What is this code setting up?
- The <head> includes a viewport meta tag for mobile responsiveness, a theme color for the browser's address bar, and links to the stylesheet and a future PWA manifest.
- The <header> displays the app name and tagline.
- The search form has an <input> for the search query and a submit button. The required attribute prevents empty searches.
- Directly below the code you just pasted (still inside index.html), add the results section, footer, and closing tags:
<section id="results-section">
<p id="results-count"></p>
<div id="loading" class="hidden">Searching...</div>
<div id="results-container"></div>
</section>
</main>
<footer>
<p>
Data from the
<a
href="https://clinicaltables.nlm.nih.gov/apidoc/npi_org/v3/doc.html"
target="_blank"
>NLM Clinical Tables API</a
>
(NPI records, CMS)
</p>
</footer>
<script type="module" src="/src/main.js"></script>
</body>
</html>
What are these sections for?
- The results-section contains a paragraph for the result count, a loading indicator that starts hidden, and an empty container where provider cards will appear.
- The footer credits the NLM Clinical Tables API as the data source and links to its documentation.
- The <script> tag loads src/main.js as a JavaScript module.
- Save index.html.
Check your browser. You should see the CareConnect heading, a search input with a button, and the data attribution footer at the bottom. No styling yet.
Page is blank or shows errors?
- Make sure you deleted all the original Vite starter HTML before pasting. Leftover tags can cause rendering issues.
- Check the browser console (F12) for any error messages.
- help me fix my index.html
Now add the styles. Each CSS chunk you paste will immediately change what you see in the browser after you save.
- Click src/style.css in the Explorer sidebar to open it.
- Select all and delete the existing Vite starter styles.
- Add the global reset, body layout, and header styles by pasting this code:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background-color: #f0f4f3;
color: #1a1a1a;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background-color: #0a6e5c;
color: white;
padding: 1.5rem 1rem;
text-align: center;
}
header h1 {
font-size: 1.5rem;
margin-bottom: 0.25rem;
}
header p {
font-size: 0.9rem;
opacity: 0.9;
}
What do these styles do?
- The universal selector * resets margins and padding so every browser starts from the same baseline.
- The body uses flexbox in a column layout so the footer sticks to the bottom even when there is little content.
- The header gets a teal background (#0a6e5c) that becomes the app's brand colour.
- Save src/style.css.
Your browser should now show a teal header bar with white text on a light grey background.
- Below the code you just pasted in src/style.css, add the main container and search form styles:
main {
flex: 1;
padding: 1rem;
max-width: 800px;
margin: 0 auto;
width: 100%;
}
#search-form {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
#search-input {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #ccc;
border-radius: 8px;
outline: none;
}
#search-input:focus {
border-color: #0a6e5c;
}
What do these styles do?
- The main element centers content at a maximum width of 800px, keeping the layout readable on large screens.
- The search form uses display: flex to place the input and button side by side.
- The input border turns teal on focus, giving the user a clear visual cue that the field is active.
- Save src/style.css.
The search input now has rounded corners and sits beside the button. The input border turns teal when you click into it.
- Continue adding the search button, results count, and loading indicator styles below:
#search-form button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
background-color: #0a6e5c;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
}
#search-form button:hover {
background-color: #085a4b;
}
#results-count {
font-size: 0.9rem;
color: #555;
margin-bottom: 0.75rem;
}
.hidden {
display: none;
}
#loading {
text-align: center;
padding: 2rem;
color: #0a6e5c;
font-size: 1.1rem;
}
What do these styles do?
- The search button matches the header's teal colour and darkens slightly on hover.
- The .hidden class uses display: none to hide the loading indicator until a search is in progress.
- Save src/style.css.
The Search button is now a solid teal rectangle that darkens when you hover over it.
- Add the provider card styles below. These prepare the layout for the search results you will build in Step 2:
.provider-card {
background: white;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.provider-name {
font-size: 1.1rem;
color: #0a6e5c;
margin-bottom: 0.25rem;
}
.provider-type {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
Why add card styles now?
These styles target the provider result cards you will create in the next step. Adding them now means results will look polished the moment they appear on screen.
- Add the remaining card detail styles, phone link, and footer below:
.provider-address {
font-size: 0.9rem;
color: #333;
line-height: 1.4;
}
.provider-phone {
display: inline-block;
margin-top: 0.5rem;
color: #0a6e5c;
text-decoration: none;
font-weight: 500;
}
.provider-phone:hover {
text-decoration: underline;
}
footer {
text-align: center;
padding: 1rem;
font-size: 0.8rem;
color: #888;
}
footer a {
color: #0a6e5c;
}
What do these styles do?
- The .provider-phone class styles phone numbers as clickable teal links that underline on hover.
- The footer sits at the bottom of the page with muted grey text and a teal link colour.
- Save src/style.css.
Check your browser. The footer text should now appear centered at the bottom of the page with the API link in teal.
Styles not showing up?
- Make sure you saved src/style.css. Unsaved changes will not trigger the hot-reload.
- Check that the <link> tag in index.html points to /src/style.css (not the old Vite stylesheet path).
- help me fix my CSS not loading
Create the system design diagram as an HTML/CSS component
A system architecture diagram shows how data flows through your app. This diagram is built with HTML and CSS so it lives inside the app itself.
Anyone who visits CareConnect can see how it works, and hiring managers can see that you think about architecture.
- In index.html, find the <main> tag. Place your cursor right after it (before the <section id="search-section"> line) and add the architecture section:
<section id="architecture">
<h2>System Architecture</h2>
<div class="diagram">
<div class="diagram-box user-box">User Device</div>
<div class="diagram-arrow">--></div>
<div class="diagram-box app-box">CareConnect PWA</div>
<div class="diagram-arrow">--></div>
<div class="diagram-box api-box">NLM Clinical Tables API</div>
<div class="diagram-arrow">--></div>
<div class="diagram-box data-box">CMS NPI Database</div>
</div>
<p class="diagram-caption">
The app runs entirely on the user's device. Search queries are sent to
the free NLM Clinical Tables API, which returns provider data from the
CMS National Provider Identifier registry.
</p>
</section>
What does this diagram show?
The diagram maps the data flow: a user's device runs the PWA, which sends search queries to the NLM Clinical Tables API. That API returns provider records from the CMS National Provider Identifier database.
Building the diagram in HTML and CSS keeps everything self-contained. No external diagramming tools needed.
- Save index.html.
The diagram text appears in the browser but it looks like a plain list. You need CSS to make it look like a real diagram.
- At the bottom of src/style.css, add the diagram container and layout styles:
#architecture {
background: white;
border-radius: 12px;
padding: 1.25rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
#architecture h2 {
font-size: 1rem;
margin-bottom: 0.75rem;
color: #333;
}
.diagram {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.diagram-box {
padding: 0.5rem 0.75rem;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
white-space: nowrap;
}
What do these styles do?
- The #architecture container gets a white card treatment matching the app's design language.
- The .diagram uses flexbox to arrange the boxes horizontally with arrows between them.
- The flex-wrap: wrap property lets the diagram stack vertically on narrow mobile screens.
- Save src/style.css.
The diagram should now appear as a white card with boxes arranged in a row. The boxes do not have colours yet.
- Add the colour-coded box styles and arrow formatting below:
.user-box {
background-color: #e8f5e9;
border: 2px solid #4caf50;
color: #2e7d32;
}
.app-box {
background-color: #e0f2f1;
border: 2px solid #0a6e5c;
color: #0a6e5c;
}
.api-box {
background-color: #e3f2fd;
border: 2px solid #1976d2;
color: #1565c0;
}
.data-box {
background-color: #fff3e0;
border: 2px solid #f57c00;
color: #e65100;
}
.diagram-arrow {
font-size: 1.25rem;
color: #999;
font-weight: bold;
}
Why different colours for each box?
Each colour represents a different layer of the system: green for the user, teal for the app, blue for the API, and orange for the database. This makes it easy to trace the data flow at a glance.
- Save src/style.css.
The diagram boxes are now colour-coded with arrows between them. Each box stands out clearly.
- Add the final styles for the diagram caption and the responsive media query:
.diagram-caption {
font-size: 0.8rem;
color: #666;
line-height: 1.4;
}
@media (min-width: 600px) {
#results-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.provider-card {
margin-bottom: 0;
}
.diagram {
flex-wrap: nowrap;
}
}
What does the media query do?
- On screens wider than 600px, provider results switch to a two-column grid layout.
- The diagram stops wrapping so all four boxes sit in a single horizontal row on larger screens.
- Save src/style.css.
What do you expect the finished page to look like now? Check your browser.
You should see a teal header, a colour-coded system architecture diagram with four boxes and arrows, a search form with a styled input and button, and a footer at the bottom.
Diagram boxes not showing colours?
- Make sure the class names in your HTML match the CSS exactly. For example, user-box in the HTML must match .user-box in the CSS. Class names are case-sensitive.
- Verify the architecture section was added inside <main> before the <section id="search-section"> tag.
- help me fix the system architecture diagram
✔️ Awesome, I've got everything!
Great. Make sure you have saved both index.html and src/style.css.
ⓧ I'd like to double check the full code
Since the files are long, confirm each section is present by scrolling through your code:
- index.html: DOCTYPE and head (charset, viewport, theme-color, manifest link, stylesheet link, title), then header (h1 and p), then architecture section (diagram boxes and caption), then search-section (form with input and button), then results-section (results-count, loading, results-container), then footer (API link), then script tag loading /src/main.js.
- src/main.js: The file should be completely empty.
- src/style.css: Global reset (*, body), then header styles, then main and search form styles (including button, focus, and hover), then results and loading styles (results-count, .hidden, #loading), then provider card styles (card, name, type, address, phone), then footer styles, then architecture styles (#architecture, .diagram, .diagram-box, each colour box, arrow, caption), then the @media query at the very end.
Your development environment is ready and the CareConnect shell is in place. Next up, you will connect the search form to a real government API and display live healthcare provider data on screen.
Build the Provider Search
In Step 1 you set up a Vite project with an HTML shell, a system architecture diagram, and base styles. The search form is sitting there looking good, but it does nothing when you click Search.
This step connects that form to a real government API so your app can search for actual US healthcare providers. By the end, you will type a search term, click Search, and see real provider names, specialties, and addresses appear on screen.
In this step, get ready to:
- Connect the search form to the NLM Clinical Tables API.
- Display real provider results with names, types, addresses, and phone numbers.
- Handle loading states, empty results, and network errors gracefully.
Set up the DOM references and API endpoint
Before your JavaScript can interact with the search form or display results, it needs references to the HTML elements on the page. It also needs to know where to send search requests.
What is the NLM Clinical Tables API?
The NLM Clinical Tables API is a free, no-authentication-required API from the National Library of Medicine. It returns provider data from the CMS National Provider Identifier registry, which covers healthcare providers across the United States.
- Open src/main.js in VS Code and paste the following code:
// Grab references to the HTML elements we need
const searchForm = document.getElementById("search-form");
const searchInput = document.getElementById("search-input");
const resultsContainer = document.getElementById("results-container");
const resultsCount = document.getElementById("results-count");
const loading = document.getElementById("loading");
// Base URL for the NLM Clinical Tables API (NPI organizations endpoint)
const API_BASE =
"https://clinicaltables.nlm.nih.gov/api/npi_org/v3/search";
What does this code do?
- Each getElementById call grabs a reference to an HTML element by its id attribute. These IDs match the ones already in your index.html.
- API_BASE stores the URL for the NPI organizations search endpoint. Every search request your app makes will start from this URL.
- Save src/main.js.
- Switch to your browser at http://localhost:5173 and open the browser console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). You should see no errors in the console.
Now you need the function that actually fires when the learner submits a search. This function will call the API, parse the response, and show a result count on screen.
- In src/main.js, add the following event listener below the API_BASE constant:
searchForm.addEventListener("submit", async (e) => {
e.preventDefault();
const terms = searchInput.value.trim();
if (!terms) return;
// Clear previous results and show loading indicator
resultsContainer.innerHTML = "";
resultsCount.textContent = "";
loading.classList.remove("hidden");
try {
// Build the API URL with search terms, max 25 results, and phone number field
const url = `${API_BASE}?terms=${encodeURIComponent(terms)}&maxList=25&ef=addr_practice.phone`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
const totalCount = data[0]; // Total matching providers
const displayFields = data[3]; // Array of provider display data
const extraFields = data[2]; // Extra fields like phone numbers
loading.classList.add("hidden");
if (totalCount === 0) {
resultsCount.textContent = "No results found. Try a different search.";
return;
}
resultsCount.textContent = `Showing ${displayFields.length} of ${totalCount} providers`;
} catch (error) {
loading.classList.add("hidden");
resultsCount.textContent =
"Something went wrong. Please check your connection and try again.";
console.error("Search error:", error);
}
});
What does this code do?
- e.preventDefault() stops the form from reloading the page when submitted.
- The fetch call sends the search terms to the NLM API with maxList=25 to cap results at 25. The ef=addr_practice.phone parameter requests each provider's phone number as an extra field.
- The API returns an array. Index 0 is the total count, index 3 holds the display fields (name, type, address), and index 2 holds extra fields like phone numbers.
- The catch block handles network failures by hiding the loading indicator and showing an error message.
- Save src/main.js.
- Switch to your browser at http://localhost:5173.
- Type dentist bethesda in the search box and click Search.
- You should see a text line like "Showing 25 of X providers" appear below the search box. No cards yet, just the count.
Not seeing the result count?
- Open the browser console and check for errors. A ReferenceError means a variable name is misspelled. Double-check that the element IDs in your JavaScript match the IDs in index.html exactly.
- If you see "Something went wrong", your network connection may be down. Try opening https://clinicaltables.nlm.nih.gov/api/npi_org/v3/search?terms=test directly in your browser to confirm the API is reachable.
- Make sure the API_BASE string is on two lines exactly as shown, with the URL inside double quotes.
Why isn't my search returning results from the NLM Clinical Tables API?
Display provider cards with details
The count proves data is flowing. Now you need to turn each provider in the response into a visible card showing their name, specialty, address, and phone number.
- In src/main.js, find this line inside the try block:
resultsCount.textContent = `Showing ${displayFields.length} of ${totalCount} providers`;
- Add the following card-rendering loop directly below that line, still inside the try block (above the } catch line):
displayFields.forEach((fields, index) => {
const card = document.createElement("div");
card.className = "provider-card";
// Extract provider details from the fields array
const name = fields[1] || "Unknown Provider";
const type = fields[2] || "Not specified";
const address = fields[3] || "Address not available";
// Check for a phone number in the extra fields
let phone = "";
if (
extraFields &&
extraFields["addr_practice.phone"] &&
extraFields["addr_practice.phone"][index]
) {
phone = extraFields["addr_practice.phone"][index];
}
// Build the card HTML with provider details
card.innerHTML = `
<h3 class="provider-name">${name}</h3>
<p class="provider-type">${type}</p>
<p class="provider-address">${address}</p>
${phone ? `<a class="provider-phone" href="tel:${phone}">${phone}</a>` : ""}
`;
resultsContainer.appendChild(card);
});
What does this code do?
- displayFields.forEach loops through each provider in the API response. Each fields array contains the provider's NPI code at index 0, name at index 1, type at index 2, and address at index 3.
- The || fallback values (like "Unknown Provider") ensure the card always shows something meaningful even if a field is missing from the data.
- Phone numbers come from extraFields["addr_practice.phone"], the extra field you requested with the ef parameter. They render as clickable tel: links so mobile users can tap to call.
- Save src/main.js.
- Switch to your browser and type dentist bethesda in the search box, then click Search.
- You should see provider cards appear with green names, uppercase specialties, addresses, and phone numbers below the result count.
Cards not showing up?
- If you see the result count but no cards, make sure the forEach loop is placed after the resultsCount.textContent line and before the } catch line. If it is outside the try block, the variables displayFields and extraFields will be undefined.
- Check the console for syntax errors. A missing backtick or curly brace in the template literal will break the entire function.
Why aren't my provider cards appearing after a search?
✔️ Awesome, I've got everything!
Great. Double-check you have saved src/main.js.
ⓧ I'd like to double check the full code
Your src/main.js file should contain three sections in this order. Scroll up and confirm each one is present:
- DOM references (five const lines for searchForm, searchInput, resultsContainer, resultsCount, and loading) followed by the API_BASE constant.
- Event listener (searchForm.addEventListener("submit", ...)) with the fetch call, response parsing, zero-results check, and the catch block.
- Card-rendering loop (displayFields.forEach(...)) inside the try block, after the resultsCount line and before the catch.
Verify the edge cases
Your search works for real queries. The code also includes handling for two edge cases: searches that return no results and network failures. Time to confirm those work too.
Before you try this, what do you think will happen if you search for complete nonsense like xyzabc99999?
- Type xyzabc99999 in the search box and click Search.
You should see "No results found. Try a different search." appear below the search box. That is your zero-results handling in action.
- Now search for dentist bethesda one more time to confirm real results still load correctly after the empty search.
You should see provider cards with real names, specialties, and addresses filling the screen again.
Real US healthcare data is flowing through your app. You can search for any type of provider in any city. Next up, you will style these results into clean mobile-friendly cards and make the layout responsive for different screen sizes.
Make It Mobile-Ready
Your app now pulls real healthcare provider data from the NLM Clinical Tables API and displays names, specialties, addresses, and phone numbers on screen. Searching works, error states work, and the data is real.
Most people searching for a healthcare provider are doing it on their phone. In this step, you will inspect the mobile-first styles your app already uses and verify them in Chrome DevTools' device simulator to make sure everything looks and feels right on a small screen.
In this step, get ready to:
- Review the mobile-first card styles that make provider results readable on any device.
- Test the responsive layout in Chrome DevTools device toolbar to confirm the app works on a phone screen.
Review your mobile-first card styles
Your src/style.css file already contains a set of card styles that control how provider results look. Before testing on a simulated phone, it helps to understand what those styles are doing for you.
- In VS Code, open src/style.css and scroll down to the .provider-card rule.
- Find this block of CSS:
.provider-card {
background: white;
border-radius: 12px;
padding: 1rem;
margin-bottom: 0.75rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
What do these card styles do for mobile?
Each property plays a role in making provider results easy to read on a small screen.
- padding: 1rem keeps text from touching the card edges, which is critical on narrow phone screens.
- border-radius: 12px gives each card rounded corners that feel native to mobile interfaces.
- box-shadow adds a subtle lift that visually separates each provider from the next.
- margin-bottom: 0.75rem adds breathing room between cards so they don't blur together.
Now look at the text styles that sit below the card rule. Each class gives a different visual weight to help the reader scan results quickly.
.provider-name {
font-size: 1.1rem;
color: #0a6e5c;
margin-bottom: 0.25rem;
}
.provider-type {
font-size: 0.85rem;
color: #666;
margin-bottom: 0.5rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.provider-address {
font-size: 0.9rem;
color: #333;
line-height: 1.4;
}
Why the visual hierarchy matters
The provider name is the largest and uses the app's brand color so it stands out first. The type is small and uppercase, acting like a label. The address is mid-sized in a neutral color. On a phone screen, this hierarchy lets a user glance at a card and instantly find what they need.
- Now scroll down to the .provider-phone rule:
.provider-phone {
display: inline-block;
margin-top: 0.5rem;
color: #0a6e5c;
text-decoration: none;
font-weight: 500;
}
.provider-phone:hover {
text-decoration: underline;
}
What makes this a tap-to-call link?
The styling here makes the phone number visually distinct (bold weight, brand color) so it looks tappable. The actual tap-to-call behaviour comes from the tel: link in the HTML that your JavaScript generates. When a mobile user taps the number, their phone's dialler opens automatically.
- Finally, scroll to the bottom of src/style.css and find the @media query:
@media (min-width: 600px) {
#results-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.provider-card {
margin-bottom: 0;
}
.diagram {
flex-wrap: nowrap;
}
}
What is mobile-first CSS?
Mobile-first means the default styles (everything outside the @media block) target small screens. The @media (min-width: 600px) block adds enhancements only for wider screens.
On a phone, provider cards stack in a single column. On a tablet or laptop (600px and wider), they snap into a two-column grid. This ensures the default experience is always readable, and wider screens get a bonus layout.
- Switch to your browser where the Vite dev server is running at http://localhost:5173.
- Type dentist bethesda into the search box and click Search.
You should see a list of provider cards with names in green, uppercase specialty labels, addresses, and phone numbers as clickable links. On your desktop browser, the cards display in a two-column grid.
Test the responsive layout with Chrome DevTools
Seeing the cards on your laptop is not the same as seeing them on a phone. Chrome DevTools has a device toolbar that simulates any mobile screen size so you can verify the experience without pulling out your phone.
- Open Chrome DevTools by pressing Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows).
- Toggle the device toolbar by pressing Cmd+Shift+M (macOS) or Ctrl+Shift+M (Windows).
- Click the device dropdown at the top of the viewport and select iPhone 14 Pro.
The page reloads in a narrow phone-sized frame. Before you test, what do you expect the card layout to look like at this width?
- Type dentist bethesda into the search box and tap Search.
The provider cards now stack in a single column. Each card has clear padding, a visible shadow separating it from the next, and the phone number stands out as a tappable link. The search input and button are large enough to tap comfortably with a thumb.
What should I see?
- Cards stacked in a single column with no text running off-screen.
- Provider names in green, specialty labels in uppercase grey text, and addresses below.
- Phone numbers displayed as green links. On a real phone, tapping one would open the dialler.
- The search input stretches to fill the available width, with the Search button next to it.
- Now click the device dropdown again and select iPad Air (or any device wider than 600px).
The cards snap into a two-column grid. The system architecture diagram also unwraps into a single horizontal row instead of wrapping. This is your @media (min-width: 600px) breakpoint in action.
Cards not changing layout when you switch devices?
- Make sure you saved src/style.css. Unsaved changes will not appear in the browser.
- Check that the @media block is at the very bottom of your CSS file, outside all other rules.
- Try refreshing the page after switching devices. Sometimes DevTools needs a fresh load to recalculate the layout.
Why isn't my responsive layout working in Chrome DevTools?
- Click the device toolbar toggle again (Cmd+Shift+M or Ctrl+Shift+M) to exit the device simulator and return to your normal browser view.
✔️ Awesome, I've got everything!
Great. Double check you have saved src/style.css and confirmed the responsive behaviour in DevTools.
ⓧ I'd like to double check the full code
Here is the complete src/style.css file. Scroll through and confirm each section matches yours. The file has these sections in order: reset, body, header, main, search form, results count, hidden/loading, provider card, provider text styles, provider phone, architecture diagram, footer, and the responsive media query at the bottom.
Your app is now verified as mobile-ready. The cards are clean, the search form is thumb-friendly, and the layout adapts to any screen. Next up, you will turn this web app into a Progressive Web App that users can install on their phone's home screen.
Turn It Into an Installable PWA
Your app looks great on mobile and searches real provider data. But users have to remember the URL and open their browser every time they want to use it.
A Progressive Web App solves this by making your web app installable on a phone's home screen. In this step, you add a web app manifest and a service worker so CareConnect launches in its own window, no browser chrome.
In this step, get ready to:
- Create a web app manifest that describes your app to the browser.
- Register a service worker that caches your app for faster loading.
- Verify installability in Chrome DevTools.
Create the web app manifest
A web app manifest is a JSON file that tells the browser your app's name, colors, icons, and how it should behave when installed. Chrome reads this file to decide whether the app qualifies as an installable PWA.
Your index.html already has a <link rel="manifest"> tag pointing to /manifest.json. Now you need to create the actual file it points to.
- Create a new file called manifest.json in the careconnect folder (the same level as index.html).
- Paste the following into manifest.json:
{
"name": "CareConnect - Healthcare Provider Finder",
"short_name": "CareConnect",
"start_url": "/",
"display": "standalone",
"background_color": "#f0f4f3",
"theme_color": "#0a6e5c",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
What do these fields do?
- name and short_name set the app's display name. The short name appears under the home screen icon.
- start_url tells the browser which page to load when the app launches from the home screen.
- display set to standalone removes the browser's address bar and tabs, making the app feel native.
- icons provides the home screen icon in two sizes (192px and 512px) that Chrome requires for installability.
- Save manifest.json.
The manifest references two icon files that do not exist yet. You need to create placeholder icons for Chrome to recognize your app as installable.
- Create an icons folder inside the public directory of your project by running this command in the terminal:
mkdir -p public/icons
Why the public folder?
Vite serves files from the public directory at the root URL path / during development. A file at public/icons/icon-192.png becomes accessible at /icons/icon-192.png, which matches the paths in your manifest.
- Create two square PNG images and save them in the public/icons folder:
- icon-192.png at 192x192 pixels.
- icon-512.png at 512x512 pixels.
Quick placeholder icons
You can use any image editor (Paint on Windows, Preview on macOS) to create a solid-color square and export it as a PNG at each size. A simple green square works perfectly as a placeholder.
Alternatively, search for "free PWA icon generator" online to create icons from text or a shape.
- Confirm both files exist by running:
ls public/icons
You should see icon-192.png and icon-512.png listed in the output.
Now verify Chrome can read the manifest. Open http://localhost:5173 in Chrome and open DevTools.
- Press Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows) to open Chrome DevTools.
- Click the Application tab in the top toolbar.
- Select Manifest in the left sidebar.
You should see your app's name, colors, and icons displayed in a human-readable format. If the icons loaded correctly, their previews appear next to the icon entries.
Manifest not showing up?
- Make sure manifest.json is in the careconnect root folder (next to index.html), not inside src/.
- Hard-refresh the page with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows) to clear any cached version.
- Check the DevTools Console tab for errors about the manifest file path.
Why is my manifest not loading?
Create the service worker
A service worker is a JavaScript file that runs in the background, separate from your web page. It intercepts network requests so your app can serve cached files instantly, even on slow or offline connections.
- Create a new file called sw.js in the careconnect root folder (same level as index.html and manifest.json).
- Paste the cache setup and lifecycle handlers into sw.js:
// Name for this cache version
const CACHE_NAME = "careconnect-v1";
// App shell files to store offline on install
const PRECACHE_URLS = ["/", "/src/main.js", "/src/style.css"];
// Cache the app shell when the service worker first installs
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
self.skipWaiting();
});
// Remove old caches when a new version activates
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((names) =>
Promise.all(
names.filter((n) => n !== CACHE_NAME).map((n) => caches.delete(n))
)
)
);
self.clients.claim();
});
What do install and activate do?
- The install event fires when the browser first downloads sw.js. It opens a cache called careconnect-v1 and stores your HTML, JS, and CSS files inside it.
- skipWaiting() tells the new service worker to activate immediately instead of waiting for all open tabs to close.
- The activate event fires after install. It deletes any old caches with a different name, keeping your storage clean when you release updates.
- clients.claim() lets the new service worker immediately take control of all open pages.
- Save sw.js and confirm it appears at the same level as index.html in VS Code's Explorer sidebar (not inside src/).
Why must sw.js be in the project root?
A service worker can only control pages at or below its own URL path. Placing sw.js in the root means it can intercept requests for the entire site. If you put it inside src/, it could only control files within src/.
Now add the fetch handler that serves cached files to the browser. This is the part that makes your app load fast on repeat visits.
- In sw.js, add the fetch event listener below the activate listener:
// Serve from cache first, fall back to network
self.addEventListener("fetch", (event) => {
event.respondWith(
caches.match(event.request).then((cached) => {
if (cached) return cached;
return fetch(event.request).then((response) => {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return response;
});
})
);
});
What does the fetch handler do?
Every time the browser makes a network request (for a page, a script, a stylesheet), the service worker intercepts it. It checks the cache first. If the file is cached, it returns the cached copy instantly. If not, it fetches from the network, stores a copy in the cache for next time, then returns the response.
- Save sw.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved sw.js with both chunks in place.
ⓧ I'd like to double check the full code
Your sw.js should have two sections in order:
- The cache constants, install listener, and activate listener from the first chunk.
- The fetch listener from the second chunk.
Scroll up and confirm both sections are present and in that order.
Register the service worker from your app
The service worker file exists, but the browser does not know about it yet. You need to tell your app to register it.
- Open src/main.js and scroll to the very bottom of the file, after the closing }); of the submit event listener.
- Add the service worker registration code below all existing code:
// Register the service worker so the browser can cache the app
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
const reg = await navigator.serviceWorker.register("/sw.js");
console.log("Service worker registered:", reg.scope);
} catch (err) {
console.error("Service worker registration failed:", err);
}
});
}
What does this registration code do?
- "serviceWorker" in navigator checks that the browser supports service workers before trying to register one.
- The registration runs on the load event so it does not slow down the initial page render.
- register("/sw.js") tells the browser where to find the service worker script. The / path means the worker controls the entire site.
- Save src/main.js.
- Switch to Chrome and hard-refresh the page with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
- In DevTools, click the Application tab, then select Service workers in the left sidebar.
You should see your service worker listed with a Status showing it is activated and running. A green circle appears next to the active worker.
Service worker not showing up?
- Confirm sw.js is in the project root (next to index.html), not inside src/.
- Check the Console tab for the message Service worker registered: followed by a scope URL. If you see an error instead, the path in register() may be wrong.
- Make sure your Vite dev server is still running at http://localhost:5173.
Help me debug my service worker registration
✔️ Awesome, I've got everything!
Great. Make sure you have saved src/main.js with the service worker registration at the bottom.
ⓧ I'd like to double check the full code
Your src/main.js should have these sections in order:
- DOM element references (searchForm, searchInput, resultsContainer, resultsCount, loading).
- The API_BASE constant.
- The submit event listener with fetch, results display, and error handling.
- The service worker registration block at the very end (if ("serviceWorker" in navigator)).
Scroll through your file and confirm all four sections are present in that order.
Test installability in Chrome DevTools
Both the manifest and service worker are in place. Before you wrap up, predict what Chrome will report about your app's installability.
- In DevTools, click Manifest in the left sidebar under Application.
Do you think Chrome will show any installability errors, or will everything pass? Take a guess before you look.
The Manifest pane shows your app name, start URL, display mode, and theme color. If your icons loaded correctly and all required fields are present, Chrome reports no installability errors.
- Now click Service workers in the left sidebar.
- Confirm the service worker shows a green circle next to Status, indicating it is activated and running.
What about the install button?
On localhost, Chrome recognizes PWA criteria even without HTTPS. You may see an install icon in the address bar after interacting with the page for about 30 seconds. You can click it to install CareConnect on your machine and test the standalone window experience.
CareConnect is now a full Progressive Web App. Users can install it on their home screen and launch it like a native app. Next up, you will take on the Secret Mission and add provider type filtering.
Secret mission
Add Provider Type Filtering
When users search a broad term like "bethesda", they get dentists, surgeons, therapists, and pharmacies all mixed together. Can you add a filter dropdown that lets users narrow results to just the type of provider they need?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your machine and uses a free public API. There are no ongoing costs or cloud resources to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The careconnect project folder on your Desktop (includes all HTML, CSS, JavaScript, manifest, and service worker files).
- The Vite dev server process running in your terminal.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep experimenting with CareConnect.
- Your careconnect folder stays on your Desktop with all your project files intact.
- The Vite dev server keeps running in your terminal. You can stop it anytime with Ctrl+C and restart it later with npm run dev.
- There are no ongoing costs. The NLM Clinical Tables API is free and requires no account.
Pause - I'll come back to this later
Stop the running dev server to free up your terminal. Your project files stay on disk so you can pick up where you left off.
- In the terminal where Vite is running, press Ctrl+C to stop the dev server.
- When you are ready to resume, open a terminal in VS Code, navigate to the careconnect folder, and run npm run dev to start the server again.
Delete - I don't want to use this again
Remove all project files from your machine. This cannot be undone.
- In the terminal where Vite is running, press Ctrl+C to stop the dev server.
- Delete the entire project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/careconnect
- On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\careconnect
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or checking your Desktop. You should no longer see a careconnect folder.
Nice Work!
Nice Work!
You just built CareConnect, a fully functional Progressive Web App that searches real US healthcare provider data and installs on a phone's home screen like a native app.
You learned how to:
- Built a mobile-first healthcare provider search app that connects to the free NLM Clinical Tables API and displays real provider names, specialties, addresses, and tap-to-call phone numbers.
- Converted the app into an installable PWA with a web app manifest and service worker, so it launches in its own window from the home screen with no browser chrome.
- Created a system architecture diagram built entirely in HTML and CSS, showing how data flows from the user's device through your app to the CMS NPI Database.
- Secret Mission: Added dynamic provider type filtering so users can narrow search results by specialty category.
Ready to quiz yourself?