Lesson 6 of 8
Tracking API Usage with Thymeleaf
Estimated time: 2.5–3 hours
What You Will Learn
- Understand what Thymeleaf is and how server-side rendering differs from client-side rendering
- Add Thymeleaf to a Spring Boot project and create templates
- Build reusable page fragments (shared header, navigation)
- Design and create the
api_requeststracking table in MySQL - Build the
ApiRequestentity and repository - Update
JobSearchServiceto track every API call - Create a
PageControllerthat serves Thymeleaf pages with model data - Implement API quota enforcement (200 requests/month limit)
1. The Problem
Your Resumator application is working. You can search for jobs, save favorites, add notes, and manage your job hunt. But there is a problem lurking under the surface that you cannot see — and it is going to bite you eventually.
The JSearch API you are using has a free tier limit of 200 requests per month. Every time your application calls the API to search for jobs, that counts as one request. Two hundred sounds like a lot, but think about how quickly you can burn through them. A search for "Java Developer in Austin" is one request. Change it to "Python Developer in Austin" — that is two. Try "Remote Software Engineer" — three. Show it to a friend who searches five more times — eight. Run your application during development and test the search ten times in an afternoon — eighteen. You can see where this is going.
Right now, you have zero visibility into how many requests you have used. There is no counter anywhere. There is no warning when you are getting close. There is no way to check your usage without logging into the RapidAPI dashboard. And here is the worst part: if you run out of requests, your searches silently fail. The API returns an error, your application might show an empty results list or a generic error message, and you have no idea why. Did the API change? Is the server down? Did you misspell the query? No — you simply ran out of requests, and you have no way of knowing.
This lesson solves that problem. You are going to:
- Track every API call in a database table, recording the query, the result count, the status, and the timestamp
- Calculate remaining requests by counting how many calls you have made since the 1st of the current month
- Display that count on every page so you always know where you stand
- Block searches when the quota is exhausted, showing a clear message instead of a confusing failure
But wait — "display that count on every page" means you need the same header, with the same API usage badge, on the search page and the favorites page and any other page you might add later. If you copy and paste the same HTML header into every page, what happens when you need to change it? You have to update every single file. Miss one, and your pages are inconsistent. This is fragile, error-prone, and exactly the kind of duplication that real applications avoid.
The solution is a template engine. You define the header once, in one file, and every page includes it. Change it in one place, and every page updates automatically. The template engine we are going to use is called Thymeleaf, and it is the industry standard for Spring Boot applications.
2. What is Thymeleaf?
Thymeleaf is a server-side template engine for Java and Spring Boot. That sentence contains the most important concept you need to understand: server-side. Let us break down what that means.
Right now, your Resumator works like this: the browser requests a static HTML file from the server, the server sends it exactly as-is, and then JavaScript in the browser makes API calls to fetch data and dynamically builds the page. The server has no idea what the page looks like when the user sees it. It just hands over files.
With Thymeleaf, the process is different. The browser requests a page. The server receives that request, runs your Java code to gather data (like the number of API requests remaining), injects that data directly into the HTML, and sends the fully assembled page to the browser. By the time the HTML reaches the browser, it is complete. The data is already there. No JavaScript needed to fill it in.
Thymeleaf uses special th: attributes in your HTML to mark where data should be inserted. Here is a simple example:
<!-- Thymeleaf processes this on the server -->
<p th:text="'Hello, ' + ${userName}">Hello, placeholder</p>
<!-- Browser receives this -->
<p>Hello, Jane</p>
Look at what happened. The template contains th:text="'Hello, ' + ${userName}". When the server processes this template, it evaluates the expression ${userName}, which pulls the value from the model data your Java controller provided. If the controller set userName to "Jane", Thymeleaf replaces the element's text content with "Hello, Jane". The placeholder text "Hello, placeholder" is what you see if you open the file directly in a browser without the server — that is a Thymeleaf feature called natural templates. The HTML is valid and viewable even without the server running.
So why Thymeleaf instead of just static HTML and JavaScript?
Shared Layouts
Define your header, navigation, and footer once in a fragment file. Every page includes the fragment. Change it in one place, and every page updates. No more copying and pasting HTML across files.
Server-Rendered Data
The page arrives in the browser with its data already filled in. The user does not see a blank page that flickers while JavaScript loads and fetches data. The HTML is complete from the moment it arrives.
Natural Templates
Thymeleaf templates are valid HTML. A designer can open them in a browser and see a working page with placeholder content, without needing a running Java server. This makes collaboration between designers and developers easier.
Industry Standard for Spring Boot
Thymeleaf is the officially recommended template engine for Spring Boot. It is well-documented, actively maintained, and widely used across the Java ecosystem. When you learn Thymeleaf, you are learning a skill that appears in job postings.
This is a question that comes up constantly in professional development. Here is the simple answer:
- Thymeleaf is ideal for server-rendered web applications, admin panels, dashboards, and content-heavy pages where the data is assembled on the server and the page is relatively straightforward.
- JavaScript frameworks like React or Vue are ideal for highly interactive single-page applications (SPAs) with real-time updates, complex state management, and rich client-side interactions.
- Many applications use both. A company might use Thymeleaf for its internal admin dashboard and React for its customer-facing product. Or even within one app, some pages might be server-rendered with Thymeleaf while interactive widgets use JavaScript. This hybrid approach is exactly what you are building — Thymeleaf renders the page shell with server data, and JavaScript handles the interactive search and favorites features.
3. Adding Thymeleaf to the Project
Adding Thymeleaf to a Spring Boot project is remarkably easy. You add a single dependency to your pom.xml, and Spring Boot auto-configures everything. Open your pom.xml and add the following inside the <dependencies> section:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
That is it. One dependency. Spring Boot sees this on the classpath and automatically configures Thymeleaf as the template engine. It knows where to look for templates, how to process them, and how to integrate them with your controllers. You do not need to configure anything else in application.properties for basic usage.
But you do need to understand where files go now. With Thymeleaf, your project has two separate resource directories that serve different purposes:
src/main/resources/
├── static/
│ ├── css/style.css
│ └── js/
│ ├── search.js
│ └── favorites.js
└── templates/
├── fragments/
│ └── header.html # Shared header fragment
├── search.html # Search page (was static/index.html)
└── favorites.html # Favorites page (was static/favorites.html)
templates/ vs. static/
src/main/resources/templates/ is where Thymeleaf looks for template files. These files are processed by the server before being sent to the browser. Thymeleaf evaluates the th: attributes, injects data, resolves fragments, and produces the final HTML.
src/main/resources/static/ is where static assets live — CSS files, JavaScript files, images. These are served directly to the browser without any processing. The server hands them over exactly as they are.
If you put an HTML file in static/, the server sends it as-is. If you put it in templates/, Thymeleaf processes it first. This distinction is critical. Your search and favorites pages need to move from static/ to templates/ so that Thymeleaf can inject the API usage count and include the shared header fragment.
static/ directory. If you do, the server serves them as plain HTML files and the th: attributes are ignored. Your dynamic data will not appear. Templates must be in the templates/ directory for Thymeleaf to process them.
4. Fragments: Define Once, Use Everywhere
A fragment in Thymeleaf is a reusable piece of HTML that you define in one file and include in multiple pages. Think of it like a Java method for HTML. Instead of copying your header into every page, you define it once as a fragment and include it wherever you need it.
This is the feature that solves the problem we identified in Part 1. Your API usage badge needs to appear on every page. If you have two pages now and five pages later, you do not want to maintain five copies of the same header HTML. You want one source of truth.
Create a new file at src/main/resources/templates/fragments/header.html:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<nav th:fragment="siteHeader(requestsRemaining)" class="site-nav">
<div class="nav-container">
<a href="/" class="nav-logo">Resumator</a>
<div class="nav-links">
<a href="/search"
th:classappend="${#httpServletRequest.requestURI == '/search'
? 'active' : ''}">Search</a>
<a href="/favorites"
th:classappend="${#httpServletRequest.requestURI == '/favorites'
? 'active' : ''}">Favorites</a>
</div>
<div class="nav-status">
<span class="api-badge"
th:classappend="${requestsRemaining < 20
? 'api-badge-warning' : ''}"
th:text="${requestsRemaining} + ' requests left'">
200 requests left
</span>
</div>
</div>
</nav>
</body>
</html>
There is a lot happening in this file. Let us walk through every Thymeleaf attribute:
th:fragment="siteHeader(requestsRemaining)"
This defines a named fragment called siteHeader. It takes one parameter: requestsRemaining. When another page includes this fragment, it passes the value for that parameter. Think of this exactly like a method definition: siteHeader is the method name, and requestsRemaining is the argument.
th:classappend
This conditionally adds a CSS class to an element. For the navigation links, it checks the current URL. If the user is on /search, the Search link gets the active class. If the user is on /favorites, the Favorites link gets it. This is how you highlight the current page in the navigation — a pattern you see on almost every website.
th:text
This replaces the text content of an element with a dynamic value. The expression ${requestsRemaining} + ' requests left' concatenates the number with the string. If 150 requests remain, the badge shows "150 requests left." The placeholder text "200 requests left" only appears if you open the HTML file directly in a browser without the server.
Now, to use this fragment in any page, you include it with a single line:
<div th:replace="~{fragments/header :: siteHeader(${requestsRemaining})}"></div>
Let us decode that expression piece by piece:
th:replace— tells Thymeleaf to replace this entire<div>with the fragment's content~{...}— the fragment expression syntaxfragments/header— the path to the template file (relative to thetemplates/directory, without the.htmlextension)::— separates the template path from the fragment namesiteHeader(${requestsRemaining})— the fragment name with the parameter value being passed from the current page's model
When Thymeleaf processes this line, it finds the fragment file, locates the siteHeader fragment inside it, evaluates the parameter, builds the complete HTML, and replaces the <div> with the result. The browser never sees th:replace or th:fragment — it receives clean, standard HTML.
fragments/header.html once. Every page that includes this fragment automatically shows the new link the next time it is loaded. This is the power of fragments. It eliminates one of the most common sources of bugs in web applications: inconsistent headers across pages.
5. The API Request Tracking Table
Before you can track API requests in Java, you need a place to store them. That means creating a new database table. This is the fourth table you have created in the Resumator project (after the jobs table, the favorite_jobs table, and any others from earlier lessons). The process should feel familiar by now.
Here is the complete CREATE TABLE statement:
CREATE TABLE api_requests (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
search_query VARCHAR(255),
search_location VARCHAR(255),
status VARCHAR(20) NOT NULL,
new_results_count INT DEFAULT 0,
existing_results_count INT DEFAULT 0,
response_code INT,
error_message TEXT,
requested_at DATETIME NOT NULL
);
Let us go through every column and understand why it exists:
id (BIGINT AUTO_INCREMENT PRIMARY KEY)
The unique identifier for each API request log entry. Auto-incremented, just like every other table you have built. This is the primary key — the column that uniquely identifies each row.
search_query (VARCHAR(255))
The search term the user typed. "Java Developer," "Remote Python Engineer," "Marketing Intern" — whatever they searched for. This lets you see what people are searching for and how they are using the application.
search_location (VARCHAR(255))
The location the user searched in, like "Austin, TX" or "New York." Nullable, because the user might search without specifying a location.
status (VARCHAR(20) NOT NULL)
Whether the API call succeeded or failed. Values will be "SUCCESS" or "ERROR". This column is NOT NULL because every request must have a status — there is no such thing as a request with an unknown outcome.
new_results_count (INT DEFAULT 0)
How many new job results the API returned that were not already in your database. This tells you whether your searches are productive — are you finding new jobs, or are you seeing the same ones over and over?
existing_results_count (INT DEFAULT 0)
How many results from the API were jobs you had already seen in a previous search. If this number is consistently high and new_results_count is consistently low, you know the same jobs keep coming back. That is useful information for deciding when to search and when to wait.
response_code (INT)
The HTTP response code from the API. 200 means success. 429 means you have been rate-limited. 500 means the API server had an error. This helps you distinguish between different types of failures.
error_message (TEXT)
If the API call failed, this stores the error message. TEXT because error messages can be long and unpredictable. Nullable, because successful requests do not have error messages.
requested_at (DATETIME NOT NULL)
The exact moment the API call was made. This is the most important column for quota tracking. To calculate how many requests you have used this month, you count rows where requested_at is after the 1st of the current month.
This table gives you visibility into three important things:
- Burn rate: How quickly are you using your 200 monthly requests? At the current pace, will you run out before the month ends?
- Productive searches: Are your API calls returning new results, or are they wasting quota on jobs you have already seen?
- Error patterns: Are there recurring errors? Is the API failing during certain times? Is a specific query causing problems?
api_requests has no foreign keys. It does not reference the jobs table or the favorite_jobs table. Not every table needs relationships. This is a log table — its job is to record events independently, like a diary. Each row is a self-contained record of one API call. This is the fourth table you have created in the Resumator project, and it is the first one that stands completely alone with no connections to other tables. That is perfectly normal and common in real applications.
6. The Java Entity and Repository
Now you need the Java side — the entity that maps to the api_requests table and the repository that provides database operations. By now, you have built several entities. This one follows the same pattern.
Create a new file called ApiRequest.java in your model package:
package com.example.resumator.model;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "api_requests")
public class ApiRequest {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "search_query")
private String searchQuery;
@Column(name = "search_location")
private String searchLocation;
@Column(nullable = false, length = 20)
private String status;
@Column(name = "new_results_count")
private int newResultsCount;
@Column(name = "existing_results_count")
private int existingResultsCount;
@Column(name = "response_code")
private Integer responseCode;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@Column(name = "requested_at", nullable = false)
private LocalDateTime requestedAt;
@PrePersist
protected void onCreate() {
this.requestedAt = LocalDateTime.now();
}
// Default constructor required by JPA
public ApiRequest() {}
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getSearchQuery() { return searchQuery; }
public void setSearchQuery(String searchQuery) { this.searchQuery = searchQuery; }
public String getSearchLocation() { return searchLocation; }
public void setSearchLocation(String searchLocation) { this.searchLocation = searchLocation; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public int getNewResultsCount() { return newResultsCount; }
public void setNewResultsCount(int newResultsCount) { this.newResultsCount = newResultsCount; }
public int getExistingResultsCount() { return existingResultsCount; }
public void setExistingResultsCount(int existingResultsCount) {
this.existingResultsCount = existingResultsCount;
}
public Integer getResponseCode() { return responseCode; }
public void setResponseCode(Integer responseCode) { this.responseCode = responseCode; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public LocalDateTime getRequestedAt() { return requestedAt; }
public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; }
}
This should look familiar. It follows the exact same pattern as FavoriteJob: @Entity marks it as a database entity, @Table sets the table name, @Id and @GeneratedValue handle the primary key, @Column maps each field to a column, and @PrePersist automatically sets the timestamp before saving. You have done this before. The pattern is the same. The only thing that changed is the fields.
Notice one subtle difference: newResultsCount and existingResultsCount use the primitive int type (lowercase), while responseCode uses the wrapper Integer type (uppercase). The primitive int defaults to 0, which makes sense for counts — if you do not set a count, zero is a reasonable default. But responseCode might be null if the API call never completed (for example, a network timeout), and primitive int cannot be null. Using Integer (the object wrapper) allows null values.
Now create the repository. Create a new file called ApiRequestRepository.java:
package com.example.resumator.repository;
import com.example.resumator.model.ApiRequest;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDateTime;
public interface ApiRequestRepository extends JpaRepository<ApiRequest, Long> {
long countByRequestedAtAfter(LocalDateTime dateTime);
long countByStatusAndRequestedAtAfter(String status, LocalDateTime dateTime);
}
Two custom methods. Let us read them using the Spring Data naming convention you learned in the favorites lesson:
countByRequestedAtAfter(LocalDateTime dateTime) — count means "return the number of rows." By means "filter by the following field." RequestedAt refers to the requestedAt field. After means "where the value is after (greater than) the given parameter." So the full meaning is: "How many API requests have been made since this date?" Spring generates the SQL: SELECT COUNT(*) FROM api_requests WHERE requested_at > ?
This is the method that powers your quota tracking. To find out how many requests you have used this month, you call it with the first moment of the current month:
LocalDateTime startOfMonth = LocalDateTime.now()
.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
long used = apiRequestRepository.countByRequestedAtAfter(startOfMonth);
long remaining = 200 - used;
countByStatusAndRequestedAtAfter(String status, LocalDateTime dateTime) adds another filter: it only counts requests with a specific status. You can use this to count only successful requests, or only errors, within a given time period. For example, countByStatusAndRequestedAtAfter("ERROR", startOfMonth) tells you how many failed requests you have had this month.
7. Updating JobSearchService
Now comes the integration. Your JobSearchService is the class that makes the actual API calls to JSearch. Right now, it calls the API, parses the results, and returns them. You need to add tracking: before and after every API call, record what happened in the api_requests table.
Here is the updated searchJobs method. The new tracking code is woven into the existing logic:
package com.example.resumator;
@Service
public class JobSearchService {
private final ApiRequestRepository apiRequestRepository;
// ... other existing fields (RestTemplate, job repository, etc.)
public JobSearchService(ApiRequestRepository apiRequestRepository
/* ... other existing parameters */) {
this.apiRequestRepository = apiRequestRepository;
// ... other existing assignments
}
public List<Job> searchJobs(String query, String location) {
// Create the tracking record before the call
ApiRequest apiRequest = new ApiRequest();
apiRequest.setSearchQuery(query);
apiRequest.setSearchLocation(location);
try {
// Make the API call (existing code)
String url = buildApiUrl(query, location);
ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
// Parse results (existing code)
List<Job> jobs = parseJobResults(response.getBody());
// Count new vs. existing results
int newCount = 0;
int existingCount = 0;
for (Job job : jobs) {
if (jobRepository.existsByJobId(job.getJobId())) {
existingCount++;
} else {
jobRepository.save(job);
newCount++;
}
}
// Record success
apiRequest.setStatus("SUCCESS");
apiRequest.setResponseCode(response.getStatusCode().value());
apiRequest.setNewResultsCount(newCount);
apiRequest.setExistingResultsCount(existingCount);
apiRequestRepository.save(apiRequest);
return jobs;
} catch (Exception e) {
// Record the error
apiRequest.setStatus("ERROR");
apiRequest.setErrorMessage(e.getMessage());
apiRequestRepository.save(apiRequest);
// Re-throw so the controller can handle it
throw e;
}
}
}
Let us walk through the tracking logic step by step:
Before the API call: You create an ApiRequest object and set the search query and location. You do this before the call because you want to capture what was searched regardless of whether the call succeeds or fails.
On success: After parsing the results, you loop through the jobs and count how many are new (not already in your database) and how many are existing (duplicates of jobs you have already seen). This gives you insight into how productive each search is. You set the status to "SUCCESS", record the HTTP response code, set the counts, and save the tracking record.
On failure: If anything goes wrong — a network error, a rate limit, an API server failure — the catch block records the error. It sets the status to "ERROR", captures the error message, and saves the tracking record. Then it re-throws the exception so that the controller can handle it and return an appropriate error response to the user.
throw e; at the end of the catch block. This is important. If you catch an exception, log it, but do not re-throw it, the calling code will think everything went fine. The controller would receive null or an empty list and might show "no results found" instead of an error message. By re-throwing, you let the error propagate to where it can be properly handled and communicated to the user.
apiRequestRepository.save(apiRequest) call appears in both the try block and the catch block. This means the tracking record is saved no matter what happens. Success? Saved with status "SUCCESS." Failure? Saved with status "ERROR." The API request is always logged. This is a pattern called guaranteed logging, and it is essential for any monitoring or tracking system. If you only logged successes, you would have no visibility into failures — which are often more important to know about.
8. Converting Controllers to Serve Pages
Up until now, your controllers have been @RestController classes that return JSON data. The browser sends a request, the controller processes it, and returns data that JavaScript on the page uses to build the display. That pattern still works for your API endpoints. But now you also need controllers that return complete HTML pages — pages that Thymeleaf renders with the API usage count already embedded.
This is where a new type of controller comes in. Create a new file called PageController.java:
package com.example.resumator.controller;
import com.example.resumator.repository.ApiRequestRepository;
import com.example.resumator.repository.FavoriteJobRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import java.time.LocalDateTime;
@Controller
public class PageController {
private final ApiRequestRepository apiRequestRepository;
private final FavoriteJobRepository favoriteJobRepository;
public PageController(ApiRequestRepository apiRequestRepository,
FavoriteJobRepository favoriteJobRepository) {
this.apiRequestRepository = apiRequestRepository;
this.favoriteJobRepository = favoriteJobRepository;
}
@GetMapping("/search")
public String searchPage(Model model) {
model.addAttribute("requestsRemaining", getRequestsRemaining());
model.addAttribute("favoriteCount", favoriteJobRepository.count());
return "search";
}
@GetMapping("/favorites")
public String favoritesPage(Model model) {
model.addAttribute("requestsRemaining", getRequestsRemaining());
model.addAttribute("favoriteCount", favoriteJobRepository.count());
return "favorites";
}
private long getRequestsRemaining() {
LocalDateTime startOfMonth = LocalDateTime.now()
.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
long used = apiRequestRepository.countByRequestedAtAfter(startOfMonth);
return Math.max(0, 200 - used);
}
}
This controller looks different from what you have built before. Let us examine every important detail:
@Controller vs. @RestController
This is one of the most important distinctions in Spring Boot. @Controller (without "Rest") tells Spring that the return value of each method is a view name — the name of a Thymeleaf template to render. When searchPage() returns the string "search", Spring looks for a template file at templates/search.html, processes it through Thymeleaf, and sends the resulting HTML to the browser.
@RestController tells Spring that the return value is the actual response data, which Spring serializes to JSON and sends directly. Your JobSearchController and FavoriteController use @RestController because they return data objects, not template names.
The Model Parameter
The Model parameter is how you pass data from your Java controller to your Thymeleaf template. When you call model.addAttribute("requestsRemaining", getRequestsRemaining()), you are saying: "Make this value available in the template under the name requestsRemaining." In the template, ${requestsRemaining} retrieves this value. The model is the bridge between Java and HTML.
The getRequestsRemaining() Method
This private helper method calculates the remaining API quota. It builds a LocalDateTime representing the first moment of the current month (day 1, midnight), queries the repository for the count of requests since then, and subtracts from 200. The Math.max(0, 200 - used) ensures the result never goes negative — if somehow more than 200 requests were made (perhaps the limit was not enforced earlier), the display shows 0 rather than a confusing negative number.
Here is the critical point to understand: both types of controllers exist side by side. Your application now has a hybrid architecture:
PageController(a@Controller) serves Thymeleaf-rendered HTML pages at/searchand/favoritesJobSearchController(a@RestController) serves JSON data at/api/jobs/searchFavoriteController(a@RestController) serves JSON data at/api/favorites
When a user navigates to /search in their browser, the PageController runs, builds the model with the API usage count, and returns the Thymeleaf-rendered page with the header already populated. Then, when the user types a search query and clicks the button, JavaScript on the page calls /api/jobs/search, which hits the @RestController and returns JSON that JavaScript uses to build the results list.
9. Converting the Templates
Now you need to convert your existing HTML pages into Thymeleaf templates. This involves three changes: moving the files, adding the Thymeleaf namespace, and replacing the hardcoded header with the fragment include.
Step 1: Move the files. Move search.html from src/main/resources/static/ to src/main/resources/templates/. Do the same for favorites.html. Remember: files in templates/ are processed by Thymeleaf. Files in static/ are served as-is.
Step 2: Add the Thymeleaf namespace. At the top of each file, update the <html> tag to include the Thymeleaf namespace:
<!-- Before -->
<html lang="en">
<!-- After -->
<html lang="en" xmlns:th="http://www.thymeleaf.org">
The xmlns:th namespace declaration tells the HTML parser that attributes starting with th: are valid Thymeleaf attributes. Without this, some IDEs and validators might flag th: attributes as errors. It does not change how the template works, but it makes your tooling happy.
Step 3: Replace the hardcoded header. Remove the entire header/navigation HTML that was previously copied into each file and replace it with the fragment include:
<!-- Remove the old hardcoded header HTML and replace with: -->
<div th:replace="~{fragments/header :: siteHeader(${requestsRemaining})}"></div>
That is the entire conversion. Three steps: move, namespace, fragment. Everything else stays the same.
search.js and favorites.js files remain in static/js/. They still work the same way — they still make fetch() calls to your REST API endpoints and dynamically build the results. The only thing Thymeleaf handles is the page shell: the header with the API usage badge, the navigation links, and the basic page structure. The interactive search and favorites features are still powered by JavaScript. This is the hybrid approach in action.
After these changes, your search page works like this:
- User navigates to
/search PageController.searchPage()runs, calculates remaining API requests, adds the count to the model- Thymeleaf processes
templates/search.html, includes the header fragment with the request count, and sends the complete HTML to the browser - The browser renders the page with the API usage badge already showing the correct count
- User types a query and clicks search
- JavaScript calls
/api/jobs/search, which hits the@RestController - The
JobSearchServicemakes the API call, tracks it in the database, and returns the results - JavaScript builds the job cards and displays them on the page
Server-rendered shell. Client-rendered interactivity. Both working together.
10. Quota Enforcement
Tracking API requests is valuable, but it is reactive — it tells you what already happened. You also need something proactive: a mechanism that prevents searches when the quota is exhausted. Without this, a user who has used all 200 requests would click search, the API call would fail with a rate-limit error, and they would see a confusing error message. It is much better to check the quota before making the call and show a clear, helpful message.
Add a quota check at the very beginning of the searchJobs method in JobSearchService:
public List<Job> searchJobs(String query, String location) {
// Check quota BEFORE making the API call
LocalDateTime startOfMonth = LocalDateTime.now()
.withDayOfMonth(1).withHour(0).withMinute(0).withSecond(0);
long used = apiRequestRepository.countByRequestedAtAfter(startOfMonth);
if (used >= 200) {
throw new RuntimeException(
"Monthly API limit reached. Resets on the 1st.");
}
// Create the tracking record
ApiRequest apiRequest = new ApiRequest();
apiRequest.setSearchQuery(query);
apiRequest.setSearchLocation(location);
try {
// ... rest of the existing method
} catch (Exception e) {
// ... existing error handling
}
}
This check runs before the API call is made. It queries the database for the count of requests made since the start of the current month. If that count is 200 or more, it immediately throws a RuntimeException with a clear message. The API call never happens. No quota is wasted on a call that would fail anyway.
On the frontend side, you need to handle this error gracefully. When your JavaScript receives an error response from the search endpoint, check if the error message mentions the API limit and display it prominently:
<!-- In your search.html template -->
<div id="quota-warning" class="alert alert-warning" style="display: none;">
You have reached the monthly API limit of 200 requests.
Your quota resets on the 1st of next month.
</div>
// In your search.js
async function performSearch(query, location) {
try {
const response = await fetch(
`/api/jobs/search?query=${query}&location=${location}`
);
if (!response.ok) {
const error = await response.json();
if (error.message && error.message.includes('API limit')) {
document.getElementById('quota-warning').style.display = 'block';
return;
}
throw new Error(error.message || 'Search failed');
}
const jobs = await response.json();
displayResults(jobs);
} catch (error) {
console.error('Search error:', error);
// Show user-friendly error message
}
}
The Thymeleaf header fragment already shows the remaining count on every page. The quota enforcement in JobSearchService prevents wasted API calls. And the JavaScript error handling shows a clear message when the limit is reached. Together, these three layers provide complete protection:
- Awareness: The header badge shows remaining requests at all times
- Prevention: The server-side check blocks calls when the quota is exhausted
- Communication: The frontend shows a clear, helpful message explaining what happened and when it will reset
JobSearchService is the real enforcement. The frontend check is a courtesy that provides a better user experience. Both matter, but the server is the authority.
11. Creating the Table in MySQL Workbench
This is the fourth table you are creating in the Resumator project. The process should feel familiar by now. Open MySQL Workbench, connect to your local MySQL server, and make sure you are using the resumator database:
USE resumator;
Now run the CREATE TABLE statement:
CREATE TABLE api_requests (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
search_query VARCHAR(255),
search_location VARCHAR(255),
status VARCHAR(20) NOT NULL,
new_results_count INT DEFAULT 0,
existing_results_count INT DEFAULT 0,
response_code INT,
error_message TEXT,
requested_at DATETIME NOT NULL
);
Click the lightning bolt icon to execute. You should see a green checkmark. Verify the table was created:
DESCRIBE api_requests;
You should see all nine columns listed with their types, null constraints, and defaults. If you see that output, the table is ready.
api_requests has no foreign keys and no relationships to other tables. It is a standalone log table. Each row is an independent record of one API call. This is intentional. Not every table needs to be connected to other tables. Log tables, audit tables, and event tables are commonly standalone. They record what happened without needing to reference other data. This is perfectly normal and a common pattern in production databases.
After creating the table, restart your Spring Boot application and perform a search. Then check the tracking table:
SELECT * FROM api_requests;
You should see a row with the search query you just ran, the status SUCCESS, the counts of new and existing results, the HTTP response code 200, and the timestamp of when the call was made. Every future search will add another row. You now have complete visibility into your API usage.
Test Your Knowledge
1. What is a Thymeleaf fragment and why is it useful?
th:fragment and include in multiple pages using th:replace. This eliminates code duplication — instead of copying the same header HTML into every page, you define it once and include it everywhere. When you need to change the header, you change it in one place and every page updates automatically.2. What is the difference between @Controller and @RestController?
@Controller tells Spring that the return value of each method is a view name — the name of a Thymeleaf template that Spring should process and render into HTML. @RestController tells Spring that the return value is the actual response data, which Spring serializes to JSON and sends directly. Both can exist in the same application, serving different purposes: @Controller for page rendering and @RestController for API endpoints.3. Why do we track API requests in a database instead of just counting in memory?
int variable), the count would reset to zero every time you restarted the server. You would lose all history and have no way to analyze patterns over time. Database tracking persists across restarts, gives you a complete history of every API call with details like the query, status, and timing, and lets you analyze usage patterns to make informed decisions about how you use your limited quota.Lesson Summary
What You Built
Your Resumator now has Thymeleaf-rendered pages with a shared header fragment showing API requests remaining, a fully tracked api_requests table logging every API call, and quota enforcement that prevents exceeding the monthly limit.
Let us recap everything you accomplished in this lesson:
- You learned what Thymeleaf is and how server-side rendering differs from client-side rendering. You understand that the server builds the complete HTML before sending it to the browser, and that Thymeleaf uses
th:attributes to inject dynamic data into HTML templates. - You added Thymeleaf to your Spring Boot project with a single dependency and learned the difference between the
templates/directory (server-processed) and thestatic/directory (served as-is). - You built a reusable fragment for the site header, including navigation with active-state highlighting and an API usage badge with a warning state. This fragment is defined once and included in every page.
- You designed and created the
api_requeststable in MySQL, with columns for tracking the search query, location, status, result counts, response code, error messages, and timestamps. - You built the
ApiRequestentity and repository, using the same JPA patterns you have practiced throughout the Resumator track, with custom query methods for counting requests within a time period. - You updated
JobSearchServiceto track every API call — both successes and failures — with guaranteed logging that records the outcome no matter what happens. - You created a
PageControllerthat uses@Controller(not@RestController) to serve Thymeleaf-rendered pages with model data, and you understand the hybrid architecture where both types of controllers coexist. - You implemented quota enforcement with server-side blocking, frontend error handling, and a persistent header badge that keeps you informed at all times.
This lesson introduced two major concepts — server-side templating and API usage tracking — and wove them together into a cohesive feature. The Thymeleaf skills you learned here (fragments, model attributes, @Controller vs. @RestController) are foundational skills for Spring Boot development that you will use in every professional project. The tracking table pattern is one you will see in production systems everywhere, from API usage monitoring to audit logs to analytics dashboards.
Your Resumator is becoming a more sophisticated, production-quality application with every lesson. You have a search feature, a favorites system, and now visibility and control over your API consumption. In the next lesson, you will add search features and security basics to take it even further.
Finished this lesson?