Lesson 3 of 8
Building the Search Interface
Estimated time: 2.5–3 hours
What You Will Learn
- Understand how Spring Boot serves static files and where to place your HTML, CSS, and JavaScript
- Build a complete search page with a form, input fields, and a state dropdown for filtering jobs
- Write CSS for job cards with a professional, responsive grid layout that stacks on mobile
- Use the Fetch API in JavaScript to call your Spring Boot backend and receive JSON data
- Dynamically create HTML elements from JavaScript to display search results as cards
- Handle loading states, empty results, and errors gracefully in the user interface
- Wire up a Favorite button on each card that sends a POST request to your API
- Add polish features like pagination hints, session storage, and a smooth user experience
In Lesson 24, you built a powerful Job Search API. You could open your browser, type a long URL like http://localhost:8080/api/jobs/search?query=java+developer&location=Lansing,MI, and get back a wall of JSON data. It worked. But let us be honest — nobody wants to use a tool that way. Imagine telling a friend, "Hey, check out my job search app! Just manually type this URL into your browser and read the raw JSON." They would look at you like you had lost your mind.
What people actually want is a search page. A text box where they type "software engineer." A dropdown where they pick their state. A button that says "Search." And then — beautiful, clickable job cards that show the title, company, salary, and a link to apply. That is what you are building today.
Here is the exciting part: you already know how to do most of this. Back in Lessons 1 and 2, you learned HTML — how to build forms with <input> fields, <select> dropdowns, and <button> elements. In Lessons 3 and 4, you learned CSS — how to style elements with colors, spacing, grids, and responsive layouts. In Lessons 5 and 6, you learned JavaScript — how to listen for events, manipulate the DOM, and work with data. Today you are going to bring all of those skills together and connect them to the real backend API you built in Lesson 24. This is the moment where the frontend meets the backend, and your application becomes something that actually feels like a real product.
/api/jobs/search endpoint working. Make sure you can start the server with ./mvnw spring-boot:run (or mvnw.cmd spring-boot:run on Windows) and see JSON results when you visit http://localhost:8080/api/jobs/search?query=developer in your browser.
1. Where Do Frontend Files Go?
Before we write a single line of HTML, we need to understand where to put our files. When you are building a separate HTML page (like a simple website), you just open an HTML file directly in your browser. But with Spring Boot, things work differently. Your Spring Boot server is already running at http://localhost:8080, and it is already handling API requests. The beautiful thing is that Spring Boot can also serve your HTML, CSS, and JavaScript files — no extra configuration needed.
Spring Boot automatically looks for static files (HTML, CSS, JS, images) inside a special folder in your project:
src/main/resources/static/
├── index.html <-- Search page (main page)
├── favorites.html <-- Saved favorites page
├── css/
│ └── style.css <-- All styling
└── js/
├── search.js <-- Search page logic
└── favorites.js <-- Favorites page logic
Anything you put inside src/main/resources/static/ becomes available at the root of your server. So if you create a file called index.html inside that folder, it will be served when you visit http://localhost:8080/. If you create css/style.css, it will be available at http://localhost:8080/css/style.css. Spring Boot handles all of this for you automatically — you do not need to write any Java code or configuration to make it work.
Why not just open the HTML file directly?
You could double-click your HTML file and open it in a browser. But if you do that, your JavaScript fetch() calls to /api/jobs/search will fail. That is because when you open a file directly, the browser uses the file:// protocol, and it has no idea where /api/jobs/search lives. When Spring Boot serves the file, the browser uses http://localhost:8080, so /api/jobs/search correctly resolves to http://localhost:8080/api/jobs/search. This is why both your frontend and backend need to be served from the same place.
Let us create the folder structure. Open your terminal, navigate to your Resumator project directory, and run these commands:
mkdir -p src/main/resources/static/css
mkdir -p src/main/resources/static/js
Now you have the directories ready. Let us start building the search page.
2. The Search Page (index.html)
The search page is the heart of Resumator. It needs three things: a header with navigation, a search form where users enter their criteria, and a results area where job cards will appear. Let us build the complete page.
Create a file called index.html inside src/main/resources/static/ and add the following HTML. This is a long file, but every part of it should look familiar from Lessons 1 and 2. Read through it carefully — we will break it down section by section after.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Resumator — Job Search</title>
<link rel="stylesheet" href="/css/style.css">
</head>
<body>
<!-- ===== HEADER ===== -->
<header class="site-header">
<div class="header-inner">
<h1 class="logo">Resumator</h1>
<nav class="main-nav">
<a href="/" class="nav-link active">Search</a>
<a href="/favorites.html" class="nav-link">Favorites</a>
</nav>
</div>
</header>
<!-- ===== MAIN CONTENT ===== -->
<main class="container">
<!-- Search Form -->
<section class="search-section">
<h2>Find Your Next Opportunity</h2>
<form id="search-form" class="search-form">
<div class="form-row">
<div class="form-group">
<label for="job-title">Job Title</label>
<input
type="text"
id="job-title"
name="query"
placeholder="e.g. Software Engineer"
required
>
</div>
<div class="form-group">
<label for="city">City</label>
<input
type="text"
id="city"
name="city"
placeholder="e.g. Lansing"
>
</div>
<div class="form-group">
<label for="state">State</label>
<select id="state" name="state">
<option value="">Any State</option>
<option value="AL">Alabama</option>
<option value="AK">Alaska</option>
<option value="AZ">Arizona</option>
<option value="AR">Arkansas</option>
<option value="CA">California</option>
<option value="CO">Colorado</option>
<option value="CT">Connecticut</option>
<option value="DE">Delaware</option>
<option value="FL">Florida</option>
<option value="GA">Georgia</option>
<option value="HI">Hawaii</option>
<option value="ID">Idaho</option>
<option value="IL">Illinois</option>
<option value="IN">Indiana</option>
<option value="IA">Iowa</option>
<option value="KS">Kansas</option>
<option value="KY">Kentucky</option>
<option value="LA">Louisiana</option>
<option value="ME">Maine</option>
<option value="MD">Maryland</option>
<option value="MA">Massachusetts</option>
<option value="MI">Michigan</option>
<option value="MN">Minnesota</option>
<option value="MS">Mississippi</option>
<option value="MO">Missouri</option>
<option value="MT">Montana</option>
<option value="NE">Nebraska</option>
<option value="NV">Nevada</option>
<option value="NH">New Hampshire</option>
<option value="NJ">New Jersey</option>
<option value="NM">New Mexico</option>
<option value="NY">New York</option>
<option value="NC">North Carolina</option>
<option value="ND">North Dakota</option>
<option value="OH">Ohio</option>
<option value="OK">Oklahoma</option>
<option value="OR">Oregon</option>
<option value="PA">Pennsylvania</option>
<option value="RI">Rhode Island</option>
<option value="SC">South Carolina</option>
<option value="SD">South Dakota</option>
<option value="TN">Tennessee</option>
<option value="TX">Texas</option>
<option value="UT">Utah</option>
<option value="VT">Vermont</option>
<option value="VA">Virginia</option>
<option value="WA">Washington</option>
<option value="WV">West Virginia</option>
<option value="WI">Wisconsin</option>
<option value="WY">Wyoming</option>
</select>
</div>
<div class="form-group form-group-btn">
<button type="submit" class="btn-search">Search</button>
</div>
</div>
</form>
</section>
<!-- Results Header (hidden until search) -->
<div id="results-header" class="results-header" style="display:none;">
<h3 id="results-title"></h3>
<p id="results-count"></p>
</div>
<!-- Loading Spinner (hidden by default) -->
<div id="loading" class="loading" style="display:none;">
<div class="spinner"></div>
<p>Searching for jobs...</p>
</div>
<!-- Results Container -->
<div id="results" class="results-grid">
<!-- Job cards will be inserted here by JavaScript -->
</div>
</main>
<footer class="site-footer">
<p>© 2025 Resumator — Built at Coders Farm</p>
</footer>
<script src="/js/search.js"></script>
</body>
</html>
Let us walk through the important pieces of this HTML.
The header gives your app an identity. The <h1> with class logo shows the app name, and the <nav> provides links between the Search page and the Favorites page. Notice the active class on the Search link — that is how we will highlight the current page in the navigation. This is a pattern you learned in Lesson 4 with CSS selectors: apply a class to one link and style it differently.
The search form has three inputs wrapped in a form-row div so we can lay them out side by side with CSS flexbox. The job title input uses required so the browser will prevent submission if it is empty — remember form validation from Lesson 2? The city input is optional, because some people want to search nationwide. The state dropdown uses a <select> element with all 50 U.S. states. The first <option> has an empty value and reads "Any State," which is the default. When nothing is selected, no state filter is applied to the API call.
The results area has three parts. The results-header div will show text like "Showing results for: Software Engineer in Michigan" after a search is performed. The loading div contains a spinner animation that appears while waiting for the API response. And the results div is an empty container where JavaScript will insert job cards dynamically. All three start hidden — they only appear when JavaScript toggles them.
The id Attribute is Your JavaScript Hook
Notice how every important element has an id: search-form, job-title, city, state, loading, results, results-header, results-title, results-count. These are the handles that JavaScript uses to find and manipulate elements. In Lesson 5, you learned document.getElementById() — that is exactly how your search JavaScript will grab each of these elements. Think of id values as name tags on every important piece of your page.
3. Professional Styling (style.css)
HTML without CSS is like a house without paint, furniture, or landscaping — structurally sound but not somewhere anyone wants to spend time. Let us make Resumator look like a real application. Create css/style.css inside your static folder and add the following styles.
This is a substantial CSS file. We are covering the entire app: layout, header, search form, job cards, responsive breakpoints, loading animation, and the favorites page styling. Do not feel like you need to memorize every line. Read through it to understand the patterns, and refer back to it when you need to tweak something.
/* ===== RESET & BASE ===== */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
Roboto, Oxygen, Ubuntu, sans-serif;
background: #f4f6f9;
color: #1a1a2e;
line-height: 1.6;
min-height: 100vh;
display: flex;
flex-direction: column;
}
a { color: #2563eb; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ===== HEADER ===== */
.site-header {
background: #1a1a2e;
color: #fff;
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.header-inner {
max-width: 1200px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
height: 64px;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.5px;
}
.main-nav { display: flex; gap: 1.5rem; }
.nav-link {
color: rgba(255,255,255,0.7);
font-weight: 500;
padding: 0.25rem 0;
border-bottom: 2px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.nav-link:hover,
.nav-link.active {
color: #fff;
border-bottom-color: #60a5fa;
text-decoration: none;
}
/* ===== CONTAINER ===== */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
flex: 1;
}
/* ===== SEARCH SECTION ===== */
.search-section {
background: #fff;
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.search-section h2 {
margin-bottom: 1.25rem;
font-size: 1.4rem;
color: #1a1a2e;
}
.form-row {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.form-group {
flex: 1;
min-width: 160px;
}
.form-group label {
display: block;
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.35rem;
color: #374151;
}
.form-group input,
.form-group select {
width: 100%;
padding: 0.65rem 0.85rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.95rem;
background: #f9fafb;
transition: border-color 0.2s, box-shadow 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.15);
}
.form-group-btn {
flex: 0 0 auto;
min-width: auto;
}
.btn-search {
background: #2563eb;
color: #fff;
border: none;
padding: 0.65rem 2rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-search:hover { background: #1d4ed8; }
/* ===== RESULTS HEADER ===== */
.results-header {
margin-bottom: 1rem;
}
.results-header h3 {
font-size: 1.1rem;
color: #374151;
}
.results-header p {
font-size: 0.9rem;
color: #6b7280;
}
/* ===== LOADING SPINNER ===== */
.loading {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
margin: 0 auto 1rem;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ===== JOB CARDS GRID ===== */
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1.25rem;
}
/* ===== INDIVIDUAL JOB CARD ===== */
.job-card {
background: #fff;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
transition: box-shadow 0.2s, transform 0.2s;
}
.job-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
transform: translateY(-2px);
}
.job-card-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.company-logo {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: contain;
background: #f3f4f6;
flex-shrink: 0;
}
.job-card-title {
font-size: 1.1rem;
font-weight: 700;
color: #1a1a2e;
margin-bottom: 0.15rem;
}
.job-card-company {
font-size: 0.9rem;
color: #6b7280;
}
.job-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.meta-tag {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8rem;
color: #4b5563;
background: #f3f4f6;
padding: 0.25rem 0.6rem;
border-radius: 6px;
}
.meta-tag.salary { background: #ecfdf5; color: #065f46; }
.job-card-description {
font-size: 0.9rem;
color: #4b5563;
line-height: 1.5;
margin-bottom: 1rem;
flex: 1;
/* Clamp to 3 lines */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.job-card-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.75rem;
border-top: 1px solid #f3f4f6;
}
.btn-apply {
display: inline-block;
background: #2563eb;
color: #fff;
padding: 0.45rem 1.25rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
transition: background 0.2s;
}
.btn-apply:hover {
background: #1d4ed8;
text-decoration: none;
}
.btn-favorite {
background: none;
border: 2px solid #d1d5db;
border-radius: 6px;
padding: 0.4rem 0.6rem;
cursor: pointer;
font-size: 1.1rem;
color: #9ca3af;
transition: color 0.2s, border-color 0.2s;
}
.btn-favorite:hover {
color: #f59e0b;
border-color: #f59e0b;
}
.btn-favorite.favorited {
color: #f59e0b;
border-color: #f59e0b;
}
/* ===== EMPTY / ERROR STATES ===== */
.no-results, .error-message {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
.no-results h3, .error-message h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #374151;
}
.error-message { color: #dc2626; }
/* ===== FOOTER ===== */
.site-footer {
text-align: center;
padding: 1.5rem;
color: #9ca3af;
font-size: 0.85rem;
margin-top: auto;
}
/* ===== RESPONSIVE: Stack on mobile ===== */
@media (max-width: 640px) {
.form-row {
flex-direction: column;
}
.form-group {
min-width: 100%;
}
.results-grid {
grid-template-columns: 1fr;
}
.header-inner {
flex-direction: column;
height: auto;
padding: 0.75rem 0;
gap: 0.5rem;
}
}
Let us highlight the most important CSS techniques at work here.
The card grid uses display: grid with grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)). This is one of the most useful CSS grid patterns in existence. It says: "Create as many columns as will fit, but make each one at least 340 pixels wide. If there is extra space, stretch them equally." On a wide screen you get three cards per row. On a tablet you get two. On a phone you get one. All from a single line of CSS — no media queries needed for the grid itself.
The card hover effect uses transform: translateY(-2px) and an enhanced box-shadow on hover. This creates a subtle "lift" effect that makes the card feel interactive. Users instinctively understand that this element is clickable or important. Small touches like this are what separate a student project from a professional application.
The description clamp uses -webkit-line-clamp: 3 to limit the job description to exactly three lines of text, cutting off anything longer with an ellipsis. Job descriptions from APIs can be extremely long — sometimes multiple paragraphs. Without clamping, one verbose listing would dominate the entire page. This technique keeps every card the same height and looking neat.
The loading spinner is pure CSS. The .spinner is a circle (created with border-radius: 50%) with a gray border on three sides and a blue border on top. The @keyframes spin animation rotates it 360 degrees continuously. No images, no JavaScript, no libraries — just four CSS properties and an animation.
@media (max-width: 640px) means "apply these styles only when the screen is 640 pixels wide or narrower." On small screens, we switch the form to a vertical stack and force the results grid to a single column. Test this by resizing your browser window or using your browser's developer tools to simulate a phone screen.
4. The Search Logic (search.js)
This is where everything comes alive. The JavaScript file is the brain of your search page. It listens for form submissions, talks to your API, and builds the job cards that users see. This is real, production-style frontend code. Every line matters, so we will go through it piece by piece after showing the full file.
Create js/search.js inside your static folder:
// ===== DOM ELEMENT REFERENCES =====
const searchForm = document.getElementById("search-form");
const jobTitleInput = document.getElementById("job-title");
const cityInput = document.getElementById("city");
const stateSelect = document.getElementById("state");
const resultsDiv = document.getElementById("results");
const loadingDiv = document.getElementById("loading");
const resultsHeader = document.getElementById("results-header");
const resultsTitle = document.getElementById("results-title");
const resultsCount = document.getElementById("results-count");
// ===== FORM SUBMIT HANDLER =====
searchForm.addEventListener("submit", function (event) {
event.preventDefault(); // Stop the page from reloading
const query = jobTitleInput.value.trim();
const city = cityInput.value.trim();
const state = stateSelect.value;
if (!query) {
alert("Please enter a job title.");
return;
}
// Build the location string (e.g. "Lansing, MI" or just "MI")
let location = "";
if (city && state) {
location = city + ", " + state;
} else if (city) {
location = city;
} else if (state) {
location = state;
}
// Build the API URL with query parameters
let apiUrl = "/api/jobs/search?query=" + encodeURIComponent(query);
if (location) {
apiUrl += "&location=" + encodeURIComponent(location);
}
// Perform the search
performSearch(apiUrl, query, location);
});
// ===== FETCH AND DISPLAY RESULTS =====
function performSearch(apiUrl, query, location) {
// Show loading, hide previous results
loadingDiv.style.display = "block";
resultsDiv.innerHTML = "";
resultsHeader.style.display = "none";
fetch(apiUrl)
.then(function (response) {
if (!response.ok) {
throw new Error("Server returned " + response.status);
}
return response.json();
})
.then(function (jobs) {
loadingDiv.style.display = "none";
// Show results header
let headerText = "Showing results for: " + query;
if (location) {
headerText += " in " + location;
}
resultsTitle.textContent = headerText;
resultsCount.textContent = jobs.length + " job(s) found";
resultsHeader.style.display = "block";
if (jobs.length === 0) {
resultsDiv.innerHTML =
'<div class="no-results">' +
" <h3>No jobs found</h3>" +
" <p>Try different keywords or a broader location.</p>" +
"</div>";
return;
}
// Save last results to sessionStorage
sessionStorage.setItem("lastSearch", JSON.stringify({
query: query,
location: location,
results: jobs
}));
// Create a card for each job
jobs.forEach(function (job) {
const card = createJobCard(job);
resultsDiv.appendChild(card);
});
})
.catch(function (error) {
loadingDiv.style.display = "none";
resultsDiv.innerHTML =
'<div class="error-message">' +
" <h3>Something went wrong</h3>" +
" <p>" + error.message + "</p>" +
"</div>";
});
}
// ===== CREATE A SINGLE JOB CARD =====
function createJobCard(job) {
const card = document.createElement("div");
card.className = "job-card";
// Company logo (use a placeholder if none provided)
const logoUrl = job.companyLogo
? job.companyLogo
: "https://via.placeholder.com/48x48?text=" +
encodeURIComponent(job.company ? job.company.charAt(0) : "?");
// Salary text
const salaryText = job.salaryMin && job.salaryMax
? "$" + job.salaryMin.toLocaleString() + " - $" +
job.salaryMax.toLocaleString()
: job.salary || "Salary not listed";
// Description snippet (first 200 characters)
const snippet = job.description
? job.description.substring(0, 200) + "..."
: "No description available.";
card.innerHTML =
'<div class="job-card-header">' +
' <img class="company-logo" src="' + logoUrl +
'" alt="' + (job.company || "Company") + ' logo">' +
" <div>" +
' <div class="job-card-title">' +
(job.title || "Untitled Position") + "</div>" +
' <div class="job-card-company">' +
(job.company || "Company not listed") + "</div>" +
" </div>" +
"</div>" +
'<div class="job-card-meta">' +
' <span class="meta-tag">📍 ' +
(job.location || "Remote") + "</span>" +
' <span class="meta-tag salary">💰 ' +
salaryText + "</span>" +
"</div>" +
'<p class="job-card-description">' + snippet + "</p>" +
'<div class="job-card-actions">' +
' <a class="btn-apply" href="' +
(job.url || "#") + '" target="_blank">Apply</a>' +
' <button class="btn-favorite" title="Save to favorites"' +
' data-job-id="' + (job.id || "") + '">☆</button>' +
"</div>";
// Wire up the favorite button
const favBtn = card.querySelector(".btn-favorite");
favBtn.addEventListener("click", function () {
handleFavorite(job, favBtn);
});
return card;
}
// ===== HANDLE FAVORITE BUTTON CLICK =====
function handleFavorite(job, button) {
console.log("Saving favorite:", job.title, "at", job.company);
fetch("/api/favorites", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jobId: job.id,
title: job.title,
company: job.company,
location: job.location,
url: job.url
})
})
.then(function (response) {
if (response.ok) {
button.classList.add("favorited");
button.textContent = "\u2605"; // Filled star
button.title = "Saved to favorites!";
} else {
console.error("Failed to save favorite:", response.status);
}
})
.catch(function (error) {
console.error("Error saving favorite:", error);
});
}
That is a substantial file, and every line has a purpose. Let us break it down into the key sections.
Section 1: DOM References
The first block grabs references to every HTML element that JavaScript needs to interact with. We use document.getElementById() — the same method you learned in Lesson 5 — to get each element by its id. We store these in const variables at the top of the file so we do not have to look them up repeatedly. This is a common pattern in professional JavaScript: gather your references once, then use them throughout.
Section 2: Form Submit Handler
The searchForm.addEventListener("submit", ...) line listens for the form's submit event. When the user clicks the Search button or presses the Enter key while focused on an input, this function runs. The very first thing it does is call event.preventDefault(). This is critical. Without it, the browser would do what forms normally do — navigate to a new page. We do not want that. We want to stay on the same page and use JavaScript to fetch the results. This is called a single-page application pattern.
Next, we read the values from each input. The .trim() method removes any extra spaces the user might have typed at the beginning or end. Then we build a location string by combining the city and state, handling all the cases: both city and state provided, only city, only state, or neither.
encodeURIComponent — Why It Matters
Notice we use encodeURIComponent(query) when building the URL. This converts special characters into URL-safe versions. For example, if someone searches for "C# developer", the # character has a special meaning in URLs (it marks a page anchor). Without encoding, the URL would break. encodeURIComponent converts it to C%23%20developer, which is safe to send. Always encode user input before putting it in a URL. This is a security and correctness best practice.
Section 3: Fetch and Display
The performSearch() function is where the magic happens. It uses the Fetch API to make an HTTP GET request to your Spring Boot backend. Let us trace the flow:
- Show loading state. We set
loadingDiv.style.display = "block"to show the spinner and clear any previous results. This gives the user immediate feedback that something is happening. - Call fetch(). The
fetch(apiUrl)call sends a GET request to your API endpoint (e.g.,/api/jobs/search?query=developer&location=MI). This is the same URL you typed into the browser in Lesson 24, but now JavaScript is doing it programmatically. - Check the response. The first
.then()checks if the response was successful. If the server returned a 404 or 500 error, we throw an exception so it gets caught by the.catch()block. - Parse JSON.
response.json()converts the raw response into a JavaScript array of job objects. This is the same JSON you saw in Lesson 24 — but now it is a real JavaScript data structure you can loop through. - Build cards. For each job in the array, we call
createJobCard(job)and append the resulting HTML element to the results container. - Handle errors. If anything goes wrong — network failure, server error, JSON parse error — the
.catch()block shows a friendly error message instead of leaving the user staring at a blank page.
Section 4: Creating Job Cards
The createJobCard() function takes a single job object (from the JSON array) and returns a fully built DOM element. It creates a <div>, sets its class to job-card, and fills its innerHTML with the card markup. This is the same DOM manipulation you practiced in Lesson 6 — just on a larger scale.
Notice how we handle missing data gracefully. If there is no company logo, we generate a placeholder image. If salary data is missing, we display "Salary not listed." If the description is null, we show "No description available." Real-world APIs are messy. Some job listings have every field filled out, and others are missing half their data. Your code must handle both cases without crashing.
Section 5: Favorite Button
Each card has a star button. When clicked, it sends a POST request to /api/favorites with the job data as JSON. If the server responds successfully, the star changes from an outline (☆) to a filled star (★) and the button gets a favorited class for styling. For now, the /api/favorites endpoint might not exist yet — that is fine. The console.log at the top of the handler lets you verify the button is working by checking your browser's developer console. You will build the favorites API in a later lesson.
/api/favorites endpoint not existing (a 404 error), that is expected — we have not built that endpoint yet. The important thing is that the JavaScript code is executing correctly and trying to make the request.
5. Test the Full Flow
You have all three files in place: index.html, css/style.css, and js/search.js. Let us see them work together.
Step 1: Make sure your project structure looks like this:
src/main/resources/static/
├── index.html
├── css/
│ └── style.css
└── js/
└── search.js
Step 2: Start your Spring Boot server from the project root:
./mvnw spring-boot:run
Step 3: Open your browser and go to http://localhost:8080. You should see the Resumator search page with the header, form, and footer. No more raw JSON — you have a real interface.
Step 4: Type a job title like "Java developer" in the search box. Optionally enter a city and select a state. Click Search.
Step 5: Watch the spinner appear briefly, then see job cards fill the page. Each card should show the job title, company name and logo, location, salary, a description snippet, an Apply button, and a Favorite star.
- "Server returned 500" — Your API key might be expired or your backend has an error. Check the Spring Boot terminal output for details.
- "Failed to fetch" — Your Spring Boot server is not running. Start it with
./mvnw spring-boot:run. - Page shows raw HTML code — You might have opened the file directly instead of through
http://localhost:8080. Always use the localhost URL. - Styles not loading — Check that your
css/folder is insidesrc/main/resources/static/, not somewhere else.
6. Polish and User Experience
Your search page works, but there are several small improvements that turn a "working prototype" into an application that feels good to use. These are the kinds of details that real developers spend significant time on. Let us add a few.
6.1 Enter Key Submits the Form
You might have noticed this already works out of the box. Because we used a proper <form> element with a submit button, pressing Enter in any input field automatically triggers the form's submit event. This is one of the benefits of using semantic HTML — you get correct keyboard behavior for free. If we had used a <div> with an onclick handler instead of a <form>, we would have had to manually listen for Enter key presses on every input. Semantic HTML saves you work.
6.2 "Showing Results For..." Header
We already built this into the JavaScript. After a successful search, the results-header div shows text like "Showing results for: Software Engineer in Lansing, MI" along with a count of how many jobs were found. This gives the user context about what they are looking at, especially if they have scrolled down and the form is no longer visible.
6.3 Session Storage for Last Results
Notice this line in the JavaScript:
sessionStorage.setItem("lastSearch", JSON.stringify({
query: query,
location: location,
results: jobs
}));
sessionStorage is a browser feature that lets you store data temporarily — it survives page refreshes but is cleared when the user closes the tab. We save the last search results so that if the user navigates to the Favorites page and comes back, we could restore their previous results instead of showing an empty page. This is a pattern used by many search applications. Right now the code saves the data. As an exercise, you could add code at the bottom of search.js that checks for saved results when the page loads and restores them.
6.4 Pagination
If your API returns many results, you might want to show them in pages of 10 or 20. To do this, your API would need to support page and size parameters (e.g., /api/jobs/search?query=developer&page=1&size=10), and your JavaScript would need "Previous" and "Next" buttons that increment the page number and re-fetch. This is a great feature to build on your own once you finish this lesson. For now, we display all results returned by the API.
7. Try It: Search Form and Card Layout
You do not need to wait until your full Spring Boot project is running to see what the cards will look like. The editor below contains a self-contained demo of the search form and a sample job card. Click Run to see it rendered in the output area. Try modifying the card content — change the job title, the company name, or the salary — and run it again.
<style>
body { font-family: sans-serif; background: #f4f6f9; padding: 1rem; }
.search-form {
background: #fff; padding: 1.25rem; border-radius: 10px;
margin-bottom: 1.5rem; box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
.search-form h3 { margin: 0 0 0.75rem; }
.form-row { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: flex-end; }
.form-group { flex: 1; min-width: 140px; }
.form-group label { display: block; font-size: 0.8rem; font-weight: 600; margin-bottom: 0.2rem; }
.form-group input, .form-group select {
width: 100%; padding: 0.5rem; border: 1px solid #d1d5db;
border-radius: 6px; font-size: 0.9rem;
}
.btn-search {
background: #2563eb; color: #fff; border: none;
padding: 0.5rem 1.5rem; border-radius: 6px; font-weight: 600; cursor: pointer;
}
.job-card {
background: #fff; padding: 1.25rem; border-radius: 10px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08); max-width: 400px;
}
.job-title { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
.job-company { font-size: 0.85rem; color: #6b7280; margin-bottom: 0.5rem; }
.meta { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
.tag { font-size: 0.75rem; background: #f3f4f6; padding: 0.2rem 0.5rem; border-radius: 4px; }
.tag.salary { background: #ecfdf5; color: #065f46; }
.description { font-size: 0.85rem; color: #4b5563; margin-bottom: 0.75rem; }
.actions { display: flex; justify-content: space-between; align-items: center; border-top: 1px solid #f3f4f6; padding-top: 0.5rem; }
.btn-apply { background: #2563eb; color: #fff; padding: 0.35rem 1rem; border-radius: 5px; text-decoration: none; font-size: 0.8rem; font-weight: 600; }
.btn-fav { background: none; border: 2px solid #d1d5db; border-radius: 5px; padding: 0.3rem 0.5rem; cursor: pointer; font-size: 1rem; }
</style>
<div class="search-form">
<h3>Find Your Next Opportunity</h3>
<div class="form-row">
<div class="form-group">
<label>Job Title</label>
<input type="text" placeholder="e.g. Software Engineer" value="Java Developer">
</div>
<div class="form-group">
<label>City</label>
<input type="text" placeholder="e.g. Lansing" value="Lansing">
</div>
<div class="form-group">
<label>State</label>
<select>
<option value="MI" selected>Michigan</option>
</select>
</div>
<button class="btn-search">Search</button>
</div>
</div>
<div class="job-card">
<div class="job-title">Senior Java Developer</div>
<div class="job-company">TechCorp Inc.</div>
<div class="meta">
<span class="tag">📍 Lansing, MI</span>
<span class="tag salary">💰 $85,000 - $120,000</span>
</div>
<p class="description">We are looking for an experienced Java developer to join our team. You will work on enterprise applications using Spring Boot, microservices, and cloud technologies...</p>
<div class="actions">
<a href="#" class="btn-apply">Apply</a>
<button class="btn-fav" title="Save to favorites">☆</button>
</div>
</div>
8. Try It: Building the Search URL and Creating Cards
The editor below demonstrates how the JavaScript builds a search URL from user input and dynamically creates job cards. This demo uses sample data instead of a live API call so you can experiment without running your Spring Boot server. Click Run to see it in action. Try modifying the sampleJobs array — add a new job, change a title, or remove the salary to see how the code handles missing data.
// Demo: Building a search URL from user inputs
const query = "Software Engineer";
const city = "Lansing";
const state = "MI";
// Build the location string
let location = "";
if (city && state) {
location = city + ", " + state;
} else if (city) {
location = city;
} else if (state) {
location = state;
}
// Build the full API URL
let apiUrl = "/api/jobs/search?query=" + encodeURIComponent(query);
if (location) {
apiUrl += "&location=" + encodeURIComponent(location);
}
console.log("API URL that would be fetched:");
console.log(apiUrl);
console.log("---");
// Simulated API response (same shape as your real API)
const sampleJobs = [
{
title: "Senior Software Engineer",
company: "TechCorp",
location: "Lansing, MI",
salaryMin: 95000,
salaryMax: 130000,
description: "Build scalable web applications using Java and Spring Boot.",
url: "https://example.com/apply/123"
},
{
title: "Full Stack Developer",
company: "StartupXYZ",
location: "Remote",
salary: "$80,000 - $110,000",
description: "Work on exciting projects with React and Node.js.",
url: "https://example.com/apply/456"
},
{
title: "Junior Developer",
company: "LearnCo",
location: "Detroit, MI",
description: "Great opportunity for new developers. No salary listed.",
url: null
}
];
// Create card text for each job (simulating what the DOM would show)
console.log("Job Cards that would be created:");
console.log("================================");
sampleJobs.forEach(function (job, index) {
// Handle salary display
let salaryText;
if (job.salaryMin && job.salaryMax) {
salaryText = "$" + job.salaryMin.toLocaleString() +
" - $" + job.salaryMax.toLocaleString();
} else if (job.salary) {
salaryText = job.salary;
} else {
salaryText = "Salary not listed";
}
// Handle missing description
const snippet = job.description
? job.description.substring(0, 100) + "..."
: "No description available.";
console.log("Card " + (index + 1) + ":");
console.log(" Title: " + (job.title || "Untitled"));
console.log(" Company: " + (job.company || "Unknown"));
console.log(" Location: " + (job.location || "Remote"));
console.log(" Salary: " + salaryText);
console.log(" Snippet: " + snippet);
console.log(" Apply: " + (job.url || "No link available"));
console.log("---");
});
console.log("Total cards: " + sampleJobs.length);
When you run this demo, look at the Console tab in the output area. You will see the exact URL that would be fetched and a text representation of each job card. In the real application, instead of console.log, the createJobCard() function builds actual DOM elements that appear on the page. But the logic for handling missing data, building the URL, and iterating through results is identical.
Test Your Knowledge
1. Why do we call event.preventDefault() inside the form submit handler?
action attribute (or reloads the current page). Calling event.preventDefault() cancels this default behavior, allowing us to handle the submission with JavaScript instead. We use fetch() to call the API and dynamically display results without ever leaving the page.2. Where must you place static files (HTML, CSS, JS) for Spring Boot to serve them automatically?
src/main/resources/static/ at the root of your web server. For example, static/index.html is served at http://localhost:8080/, and static/css/style.css is served at http://localhost:8080/css/style.css. No additional configuration is needed.3. Why do we use encodeURIComponent() when building the API URL from user input?
& separates query parameters and # marks a page anchor. If user input contains these characters, the URL would be misinterpreted. encodeURIComponent() converts characters like spaces to %20, & to %26, and # to %23, ensuring the URL is valid and the server receives the intended search text.Lesson Summary
In this lesson, you connected the frontend to the backend and built a real, usable search interface for Resumator. Here is everything you accomplished:
- Static file serving: You learned that Spring Boot automatically serves HTML, CSS, and JavaScript files from
src/main/resources/static/, eliminating the need for a separate web server. - Search page HTML: You built a complete page with a header, navigation, search form (with a job title input, city input, and state dropdown for all 50 states), and a results container — all using the HTML skills from Lessons 1 and 2.
- Professional CSS: You styled job cards with a responsive grid layout that adapts from three columns on desktop to one column on mobile. You added a loading spinner, hover effects, salary tags, and a clean visual hierarchy.
- Fetch API integration: You wrote JavaScript that captures form submissions, builds an API URL with query parameters, fetches JSON data from your Spring Boot backend, and dynamically creates DOM elements to display the results.
- Error handling: You built graceful handling for empty results ("No jobs found"), network errors ("Something went wrong"), and missing data in API responses (fallback values for salary, description, and logos).
- Favorite button: You wired up a star button on each card that sends a POST request to
/api/favoritesand visually updates when clicked. - Session storage: You saved the last search results to
sessionStorageso they can be restored when the user returns to the page.
Your Resumator application now has a working frontend and a working backend. A user can type in a job title, pick a location, and see real job listings displayed as beautiful, clickable cards. This is a fully functional web application.
In the next lesson, you will learn what happens when things go wrong — how to debug errors, handle edge cases, and build resilience into your application. Because in the real world, APIs go down, user input is unpredictable, and networks are unreliable. A good developer does not just build features — they build features that fail gracefully.
Finished this lesson?