Thymeleaf Ajax Search: Fragment Pattern and Common Mistakes
Building an Ajax search page with Spring MVC and Thymeleaf looks straightforward until you hit a few subtle design traps. This post walks through the fragment-based pattern for partial page updates, and the specific mistakes that can eat hours of debugging time.
The Core Pattern: Main View + Fragment
An Ajax search page needs two views — not one.
Main view — includes your layout decorator, handles the initial full-page render on first load.
Fragment view — no layout, only returned as an Ajax response.
Structure it like this:
page/sign/
├── searchPage.html # Main view (layout:decorate included)
└── inc/
└── searchResult.html # Fragment only
In the main view, use th:replace to include the fragment for the initial render:
<div id="resultArea" class="mt30">
<th:block th:replace="page/sign/inc/searchResult :: resultFragment"></th:block>
</div>
The fragment file has no layout — just the th:fragment definition:
<html xmlns:th="http://www.thymeleaf.org">
<th:block th:fragment="resultFragment">
<!-- Result list here -->
</th:block>
</html>
Your Ajax controller endpoint returns this fragment file as its view. If the file includes layout:decorate, the full page HTML gets injected into your result area and everything breaks.
The replaceWith() Trap
This one catches a lot of people. When you use jQuery's replaceWith() to update the search results, the first search works fine. The second search does nothing.
Here's why: replaceWith() removes the target element from the DOM entirely and substitutes the new content. After the first Ajax call, #resultArea is gone. On the second search, $('#resultArea') returns an empty jQuery object, and the replaceWith() call silently no-ops.
// Wrong — removes #resultArea from DOM on first call
$('#resultArea').replaceWith(result);
// Correct — replaces inner content, keeps the div
$('#resultArea').html(result);
A clean pattern that handles both search and pagination through the same function:
var fn = {
search: function() {
$("#pageNum").val(1); // Reset to page 1 on new search
this.loadResult();
},
paging: function(pageNum, pageSize) {
$("#pageNum").val(pageNum);
$("#pageSize").val(pageSize);
this.loadResult();
},
loadResult: function() {
$.ajax({
url: '/sign/searchResult',
type: 'get',
data: $("#searchForm").serialize()
}).done(function(result) {
$('#resultArea').html(result);
});
}
};
Split Your DAO and DTO
It's tempting to stuff search parameters (searchType, keyword) directly into the result DTO. That works for small cases but creates confusion as the codebase grows. Keep the two roles separate:
// DAO — search conditions only
@Data
@NoArgsConstructor
public class SearchDao {
private String userId; // Injected from session
private String companyId; // Injected from session
private String searchType;
private String keyword;
}
// DTO — result mapping only
@Data
public class ResultDto {
private String memberName;
private String memberId;
private String deptCode;
private String deptName;
private String availableBudget;
}
Don't skip @NoArgsConstructor. Spring MVC needs a no-args constructor to bind request parameters to your DAO object. If there's any other constructor present and you don't explicitly declare a no-args one, the compiler won't generate it — and every field comes in as null.
MyBatis Dynamic Query for Column-Switching Search
When users can choose which column to search (member name, ID, department name), use <choose><when> to switch columns dynamically:
<select id="selectResults" parameterType="com.example.SearchDao"
resultType="com.example.ResultDto">
SELECT B.MEMBER_NAME, B.MEMBER_ID, C.DEPT_CODE, D.DEPT_NAME
FROM MEMBER_LIST A
INNER JOIN MEMBER_BASE B ON A.MBR_NO = B.MBR_NO
INNER JOIN MEMBER_INFO C ON B.MBR_NO = C.MBR_NO
INNER JOIN DEPT_INFO D ON C.DEPT_NO = D.DEPT_NO
WHERE A.APPROVER_ID = #{userId}
<if test="searchType != null and searchType != '' and keyword != null and keyword != ''">
<choose>
<when test="searchType == 'memberName'"> AND B.MEMBER_NAME LIKE CONCAT('%', #{keyword}, '%')</when>
<when test="searchType == 'memberId'"> AND B.MEMBER_ID LIKE CONCAT('%', #{keyword}, '%')</when>
<when test="searchType == 'deptName'"> AND D.DEPT_NAME LIKE CONCAT('%', #{keyword}, '%')</when>
</choose>
</if>
</select>
Always use CONCAT('%', #{keyword}, '%') for LIKE searches, not '%${keyword}%'. The ${} syntax injects the value directly as a string — no PreparedStatement binding, full SQL injection exposure. #{} keeps the binding intact.
Debugging When Ajax Values Aren't Arriving
If your search runs but the server receives null for all search parameters, check these two things in order:
Open Chrome DevTools → Network tab → click the Ajax request → Payload tab.
Query string is empty → Your HTML input or select is missing the name attribute. jQuery's serialize() collects name attributes only — id alone does nothing.
Query string has the values → @NoArgsConstructor is missing on your DAO, or the field names don't match the request parameter names exactly.
table vs. Custom Flex Layout
Many projects' reset.css applies table { width:100%; table-layout:fixed; border-collapse:collapse; } globally, but leaves th and td completely unstyled. If you drop in a plain <table>, you're writing all the padding and border styles from scratch.
If your project already has a list component with consistent styling built on flex divs, use that instead. You get visual consistency for free and avoid reset.css conflicts entirely. There's rarely a good reason to insist on <table> in this situation.
Quick Checklist
- Fragment lives in a separate file with no
layout:decorate - Ajax result replacement uses
.html(), never.replaceWith() - Search conditions in DAO, result mapping in DTO — don't mix them
@NoArgsConstructoron any DAO that gets request params bound to it- LIKE queries use
CONCAT('%', #{keyword}, '%')— never${}for user input