Custom Tags: SimpleTag
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Jakarta Pages 4.0 (API jakarta.servlet.jsp:jakarta.servlet.jsp-api 4.0.0, Jakarta EE 11),
together with Jakarta Servlet 6.1, Jakarta Expression Language 6.0 and Jakarta Standard Tag Library 3.0, as implemented by
Apache Tomcat 11.0.26 (the Jasper engine) on Java 21. Everything was verified on 2026-10-08: the rules against chapter 7 of
the Pages 4.0 specification and the jakarta.servlet.jsp.tagext Javadoc, and every example by building it into a Maven WAR
(context path /g6simpletag) and running it on Tomcat 11.0.26 with curl; the outputs and error messages shown are the
ones that run produced. A custom tag is a new action that a page author writes like <c:out>, backed by a Java class
that the container instantiates and calls. Since JSP 2.0 there are two kinds of handler: the classic handlers
(Tag, IterationTag, BodyTag), a family of callbacks with return codes that is covered on the Custom Tags: Classic Handlers
page of this section, and the simple handlers of this page, where the container calls one method, doTag(),
once, and everything else (the body, the loop, the decision to stop) is ordinary Java inside that method. Simple tag
handlers are the recommended way to write a tag in Java today; the other way, tag files, which write the same kind of tag in
JSP syntax, have their own page, Tag Files & Layouts. This page builds the first two tags of a small Bookshelf tag library, <bs:rating>
and <bs:pagination>, and then the supporting cast (bs:excerpt, bs:when, bs:stopUnless, bs:link, bs:numbered
and bs:item) that shows each part of the API: the lifecycle, attributes, scoped variables, bodies as fragments, fragment
attributes, dynamic attributes, cooperating tags and SkipPageException. It ends with a catalogue page that uses all of
them, the security notes, the pitfalls, what changed from JSP 2.0 to 4.0 and where the books in the section bibliography are
out of date. The TLD file itself (its schema, discovery rules and packaging in a JAR) is described on the Tag Library Descriptors & EL Functions
page; here only the part of it that a simple tag needs is shown.
What a Simple Tag Handler Is
The specification introduces simple tags with an argument about complexity. The protocol of classic handlers is complicated
because scriptlets in a tag body can depend on variables the page created with other scriptlets, so the container has to
call the handler before and after the body, buffer it, loop it and keep the handler’s state in step with the page. Once
pages are written with the expression language and the standard tag library, a body no longer needs scriptlets, and the
protocol can be one call. JSP 2.0 therefore added the Simple Tag Extension, which can be written in two ways: in Java, by
implementing the SimpleTag interface (or, in practice, by extending the convenience class SimpleTagSupport), or in JSP,
as a tag file. Both are the same thing to the page author, and a tag file is translated by the container into a SimpleTag
handler.
| Mechanism | What you write | Status and when to choose it |
|---|---|---|
Simple tag handler (this page) |
a Java class with |
current; the default choice for a tag that needs Java (calls a service, builds markup, talks to the |
Tag file |
a |
current; the best choice for presentation-only tags (a card, a layout, a form row); no Java and no TLD entry required |
Classic tag handler |
|
legacy but still supported and used inside the standard tag library; choose it only to extend a classic class such as |
EL function |
a |
current; for a value, not for markup or flow control |
Scriptlet or |
Java in the page |
legacy; disabled with |
The simple protocol has four properties that follow from the specification and that the rest of the page uses.
-
One call. The container calls
doTag()once and only once for each invocation of the tag. All the logic, including evaluation of the body any number of times, happens inside it. The Javadoc says that this gives simple handlers the equivalent power ofBodyTagwith a much simpler lifecycle. -
A new instance per invocation. The specification states that once a simple tag handler has been instantiated "it is executed and then discarded. The same instance must not be cached and reused." The reason given is that caching a handler does not necessarily make a page faster and makes portable handlers prone to error. Instance fields are therefore safe as per-invocation state; only
staticand other shared state needs care. -
No servlet API.
SimpleTagdepends onJspContext, the superclass ofPageContext, and not onPageContext, so a simple handler is not tied to the servlet API. A handler that needs the request must cast, and should say so. -
The body is a fragment. The body of a simple tag is translated into a
JspFragmentand handed over withsetJspBody. A fragment can contain template text and actions, never scriptlets, and the handler runs it zero, one or many times.
The API: SimpleTag, SimpleTagSupport, JspFragment and JspContext
SimpleTag has five methods. SimpleTagSupport implements all of them, keeps the values the container passes in and adds
three protected or static helpers; a handler normally extends it and overrides doTag() only. The Javadoc requires every
simple handler to have a public no-argument constructor, because the generated page calls it.
| Method | What it does | Who calls it |
|---|---|---|
|
stores the context of this invocation; |
the container, first |
|
stores the enclosing tag, for cooperating tags |
the container, only when the tag is nested in another tag |
|
stores the body; not called at all for an empty element, so |
the container, after the attribute setters |
|
all the tag’s work; |
the container, once |
|
the enclosing tag, or |
the handler or its children |
|
the |
the handler (helper of |
|
the body fragment, or |
the handler (helper of |
|
the closest enclosing tag of a given class, looking through |
the handler of a cooperating tag |
JspFragment is the type of the body. It has exactly two methods, and everything a tag does with its body goes through them.
| Method | Behaviour |
|---|---|
|
runs the fragment and sends its output to |
|
the context the fragment runs in; this is where the handler puts the values the body reads |
JspContext is what the handler uses to talk to the page. Its methods are the same scoped-attribute methods as on
PageContext (the implicit objects and scopes page describes the
four scopes and their lookup order): setAttribute(name, value, scope), getAttribute(name, scope), findAttribute(name),
removeAttribute(name, scope), getOut() for the JspWriter, getELContext(), and pushBody(Writer) and popBody() for
redirecting output. The scope arguments are the integer constants PageContext.PAGE_SCOPE, REQUEST_SCOPE,
SESSION_SCOPE and APPLICATION_SCOPE; there are no scope constants on JspContext itself.
The Lifecycle: What the Container Calls and in What Order
The Javadoc of SimpleTag gives a short, non-normative description of the lifecycle, and the Javadoc of doTag() adds that
the container resynchronises AT_BEGIN and AT_END variables after the method returns. The diagram shows one invocation.
The order is easy to verify. The TraceTag below records every call the container makes on it, and a page nests one trace
tag inside another so that setParent happens as well.
public class TraceTag extends SimpleTagSupport {
private static final AtomicInteger INSTANCES = new AtomicInteger();
private final int id = INSTANCES.incrementAndGet();
private final List<String> calls = new ArrayList<>();
private String label = "-";
public TraceTag() { calls.add("constructor"); }
public void setLabel(String label) { this.label = label; calls.add("setLabel(" + label + ")"); }
@Override public void setJspContext(JspContext pc) { super.setJspContext(pc); calls.add("setJspContext"); }
@Override public void setParent(JspTag parent) { super.setParent(parent); calls.add("setParent"); }
@Override public void setJspBody(JspFragment body) { super.setJspBody(body); calls.add("setJspBody"); }
@Override
public void doTag() throws JspException, IOException {
calls.add("doTag");
getJspContext().getOut().write("[" + label + "] instance #" + id + ": " + String.join(" > ", calls) + "\n");
if (getJspBody() != null) {
getJspBody().invoke(null);
}
}
}
body-content scriptless and the attribute label)<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ taglib prefix="bs" uri="https://example.org/bookshelf/tags" %>
<bs:trace label="outer">
<bs:trace label="inner"/>
</bs:trace>
[outer] instance #1: constructor > setJspContext > setLabel(outer) > setJspBody > doTag
[inner] instance #2: constructor > setJspContext > setParent > setLabel(inner) > doTag
[outer] instance #3: constructor > setJspContext > setLabel(outer) > setJspBody > doTag
[inner] instance #4: constructor > setJspContext > setParent > setLabel(inner) > doTag
Four things are visible in it. The order is constructor, setJspContext, then setParent (only for the inner tag),
then the attribute setters, then setJspBody (only for the outer tag, whose element has a body, and not for the empty inner
one), then doTag. A new instance is created for every invocation, even for the same tag in the same page on the next
request: the instance numbers keep increasing from 1 to 4. The outer handler’s doTag() runs before the inner one’s, because
the inner tag lives in the body and the body only runs when invoke is called. And the container calls nothing after doTag()
that the handler can see: there is no release() and no doEndTag().
Declaring the Tag: the TLD Entries a Simple Tag Needs
A tag exists for the container only through a tag library descriptor. The one for the Bookshelf is a single file,
WEB-INF/bookshelf.tld, which is found without any further configuration: the container scans WEB-INF (and the
META-INF of every JAR in WEB-INF/lib) for .tld files and maps the uri element of each to the descriptor, so a page
only needs a taglib directive with that URI. The URI is an identifier, not a link; it does not have to resolve to anything.
The element order inside tag follows the schema web-jsptaglibrary_4_0.xsd: name, tag-class, body-content,
variable, attribute, dynamic-attributes.
<?xml version="1.0" encoding="UTF-8"?>
<taglib xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-jsptaglibrary_4_0.xsd"
version="4.0">
<description>Bookshelf presentation tags</description>
<tlib-version>1.0</tlib-version>
<short-name>bs</short-name>
<uri>https://example.org/bookshelf/tags</uri>
<tag>
<description>Prints a star rating, or exports it as a scoped variable</description>
<name>rating</name>
<tag-class>org.example.bookshelf.tags.RatingTag</tag-class>
<body-content>empty</body-content>
<attribute>
<name>value</name>
<required>true</required>
<rtexprvalue>true</rtexprvalue>
<type>int</type>
</attribute>
<attribute>
<name>max</name>
<rtexprvalue>true</rtexprvalue>
<type>int</type>
</attribute>
<attribute>
<name>var</name>
<rtexprvalue>false</rtexprvalue>
</attribute>
<attribute>
<name>scope</name>
<rtexprvalue>false</rtexprvalue>
</attribute>
</tag>
<tag>
<name>pagination</name>
<tag-class>org.example.bookshelf.tags.PaginationTag</tag-class>
<body-content>scriptless</body-content>
<variable>
<name-from-attribute>var</name-from-attribute>
<variable-class>java.lang.Integer</variable-class>
<scope>NESTED</scope>
</variable>
<variable>
<name-given>active</name-given>
<variable-class>java.lang.Boolean</variable-class>
<scope>NESTED</scope>
</variable>
<attribute>
<name>total</name>
<required>true</required>
<rtexprvalue>true</rtexprvalue>
<type>int</type>
</attribute>
<attribute>
<name>pageSize</name>
<rtexprvalue>true</rtexprvalue>
<type>int</type>
</attribute>
<attribute>
<name>current</name>
<rtexprvalue>true</rtexprvalue>
<type>int</type>
</attribute>
<attribute>
<name>var</name>
<required>true</required>
<rtexprvalue>false</rtexprvalue>
</attribute>
<attribute>
<name>separator</name>
<fragment>true</fragment>
</attribute>
</tag>
<!-- bs:excerpt, bs:when, bs:stopUnless, bs:link (with dynamic-attributes), bs:numbered, bs:item ... -->
</taglib>
| Element | Meaning for a simple tag | Notes |
|---|---|---|
|
the action name after the prefix: |
case sensitive |
|
the handler class, which must implement |
a |
|
|
|
|
name, |
the setter name is derived from the attribute name: |
|
a scripting variable or scoped variable that the tag creates: |
|
|
|
see the dynamic attributes section |
|
documentation and tool information |
not used at run time |
Attributes: Building bs:rating
The first tag prints a star rating. It has a required integer attribute value, an optional max (default 5), and the
pair var and scope that the standard tag library also uses: with var the tag stores the result in a scoped variable
instead of printing it. Every attribute is a JavaBean property of the handler, so the container calls
setValue(int), setMax(int), setVar(String) and setScope(String). The TLD says which attributes accept an expression
(rtexprvalue true) and of which type; the container converts the value to the setter’s type with the same rules as for
every other action (a string literal is converted at translation time, an EL result at request time).
public class RatingTag extends SimpleTagSupport {
private int value;
private int max = 5;
private String var;
private String scope = "page";
public void setValue(int value) { this.value = value; }
public void setMax(int max) { this.max = max; }
public void setVar(String var) { this.var = var; }
public void setScope(String scope) { this.scope = scope; }
@Override
public void doTag() throws JspException, IOException {
if (max < 1 || max > 10) {
throw new JspTagException("bs:rating: max must be between 1 and 10 but was " + max);
}
if (value < 0 || value > max) {
throw new JspTagException("bs:rating: value " + value + " is outside 0.." + max);
}
String stars = "★".repeat(value) + "☆".repeat(max - value);
JspContext ctx = getJspContext();
if (var != null) {
ctx.setAttribute(var, stars, scopeConstant(scope));
return;
}
ctx.getOut().write("<span class=\"rating\" title=\"" + value + " of " + max + "\">" + stars + "</span>");
}
static int scopeConstant(String scope) throws JspTagException {
return switch (scope) {
case "page" -> PageContext.PAGE_SCOPE;
case "request" -> PageContext.REQUEST_SCOPE;
case "session" -> PageContext.SESSION_SCOPE;
case "application" -> PageContext.APPLICATION_SCOPE;
default -> throw new JspTagException("Invalid scope: " + scope);
};
}
}
The handler validates its attributes in doTag() and signals a mistake with a JspTagException (a JspException that the
page’s error handling shows like any other exception, see the error handling
page); it writes with getJspContext().getOut(), the page’s own JspWriter, so that its output is in order with the template
text around it and obeys the page buffer.
<%@ page contentType="text/plain;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" uri="https://example.org/bookshelf/tags" %>
<c:forEach var="b" items="${books}">
${b.title} -> <bs:rating value="${b.rating}"/>
</c:forEach>
Dune -> <span class="rating" title="5 of 5">★★★★★</span>
Neuromancer -> <span class="rating" title="4 of 5">★★★★☆</span>
The Dispossessed -> <span class="rating" title="3 of 5">★★★☆☆</span>
<script>alert('x')</script> -> <span class="rating" title="2 of 5">★★☆☆☆</span>
The fourth line shows something that is true of every tag on this page and is easy to miss: the tag escaped nothing because
it printed nothing from the request, but the page’s own ${b.title} did not escape the title either. A tag does not
change the escaping rules of the template text next to it.
The var and scope form exports a value instead of printing it. A scoped variable written by a handler is read back by
the EL like any other, and it follows the normal scope rules.
<bs:rating value="3" max="10"/>
<bs:rating value="2" var="stars" scope="request"/>
page=${stars} request=${requestScope.stars}
<bs:rating value="2" var="s2"/>
${s2} / ${pageScope.s2}
<span class="rating" title="3 of 10">★★★☆☆☆☆☆☆☆</span>
page=★★☆☆☆ request=★★☆☆☆
★★☆☆☆ / ★★☆☆☆
${stars} found the request attribute because the EL resolves a bare name through the scopes in the order page, request,
session, application. The default scope of the handler is page, which is why ${pageScope.s2} works for the third tag.
The mistakes a page author can make with attributes are caught at three different times, and the message tells which.
| Page | When | Result |
|---|---|---|
|
translation |
|
|
translation |
|
|
translation |
|
|
translation |
|
|
translation, in the compiler |
|
|
request |
|
|
request, in |
|
a TLD attribute |
translation |
|
a |
translation |
|
The last row is the reason why bs:rating declares no variable element for its optional var: a
name-from-attribute variable makes the attribute mandatory in practice, because the translator cannot compute the name
without it. The EL does not need the declaration, since it reads scoped variables at run time; the declaration matters only
for a scripting variable that a scriptlet could read, which a modern page does not use. A tag whose var is required, like
bs:pagination below, can and should declare it.
The Body: JspFragment, invoke and Writers
The body of the tag is what the page author writes between the start and the end tag. The TLD’s body-content decides what
is allowed there, and the handler decides what happens to it. Three patterns cover almost every tag.
| Pattern | In doTag() |
Example |
|---|---|---|
Conditional |
call |
|
Iteration |
call |
|
Process the body |
call |
|
Ignore the body |
do not call |
|
Processing the Body: bs:excerpt
bs:excerpt shortens its body to a maximum length and prints it escaped. It hands invoke a StringWriter, so the body’s
output never reaches the page; the handler gets it as a string and decides what to do with it. This is the simple tag
replacement for the classic BodyContent buffering, and the specification points out that, unlike BodyContent, the
output "does not need to be buffered" unless the handler wants to look at it.
public class ExcerptTag extends SimpleTagSupport {
private int max = 40;
public void setMax(int max) { this.max = max; }
@Override
public void doTag() throws JspException, IOException {
if (getJspBody() == null) {
return; // <bs:excerpt/> has no body: nothing to do
}
StringWriter captured = new StringWriter();
getJspBody().invoke(captured); // body output goes to our writer, not to the page
String text = captured.toString().strip();
if (text.length() > max) {
text = text.substring(0, max).stripTrailing() + "…";
}
getJspContext().getOut().write(escape(text));
}
static String escape(String s) {
StringBuilder sb = new StringBuilder(s.length() + 16);
for (char c : s.toCharArray()) {
switch (c) {
case '&' -> sb.append("&");
case '<' -> sb.append("<");
case '>' -> sb.append(">");
case '"' -> sb.append(""");
case '\'' -> sb.append("'");
default -> sb.append(c);
}
}
return sb.toString();
}
}
<c:forEach var="b" items="${books}">
${b.title}: <bs:excerpt max="30">${b.blurb}</bs:excerpt>
</c:forEach>
Dune: Fear is the mind-killer & the…
Neuromancer: The sky above the port was the…
The Dispossessed: <b>An ambiguous utopia</b> on…
<script>alert('x')</script>: short
The third line shows the point of the tag: the blurb of that book contains markup, and the tag printed it as text. The
fourth line shows that the tag does not clean up the text that surrounds it (${b.title} in the page is still raw).
Two more cases from excerpt2.jsp:
| Page | Output | Why |
|---|---|---|
|
|
no body: |
|
|
a body that is present but empty prints nothing |
|
|
|
The decision about escaping belongs in the tag’s documentation: bs:excerpt says "the body is text; do not escape it
yourself". A tag that takes markup in its body and passes it through would be documented the other way round, and the
author of a library has to choose one and keep it.
Conditional Tags: bs:when
A conditional tag decides in Java whether to run its body. bs:when is the smallest possible case; the JSTL equivalent is
c:if, which is the better choice when one exists (the JSTL core page covers it),
and a custom conditional tag is worth writing when the condition is application-specific.
public class WhenTag extends SimpleTagSupport {
private boolean test;
public void setTest(boolean test) { this.test = test; }
@Override
public void doTag() throws JspException, IOException {
if (test && getJspBody() != null) {
getJspBody().invoke(null);
}
}
}
<c:forEach var="b" items="${books}">
${b.title}<bs:when test="${b.year lt 1970}"> (classic)</bs:when>
</c:forEach>
Dune (classic)
Neuromancer
The Dispossessed
<script>alert('x')</script>
Iteration with Variables the Body Reads: bs:pagination
The second headline tag of the page is bs:pagination, which runs its body once for every page of a long list and tells
the body which page it is on. It teaches the part of the API that classic handlers needed doAfterBody() for: a loop that
puts values where the body reads them. The handler computes the number of pages from the list size (total) and the page
size, and for each page it stores the page number under the name given in var and a boolean active that is true for the
current page, both in the page scope of the JspContext, and then calls invoke(null). The body is a fragment compiled
once; it sees the new values on every invocation, because it evaluates the EL again each time. The specification calls this
"parameterising a fragment": a handler sets page-scoped attributes on the JspContext that the fragment is bound to, and
the fragment reads them through the EL.
public class PaginationTag extends SimpleTagSupport {
private int total;
private int pageSize = 10;
private int current = 1;
private String var;
private JspFragment separator;
public void setTotal(int total) { this.total = total; }
public void setPageSize(int pageSize) { this.pageSize = pageSize; }
public void setCurrent(int current) { this.current = current; }
public void setVar(String var) { this.var = var; }
public void setSeparator(JspFragment separator) { this.separator = separator; }
@Override
public void doTag() throws JspException, IOException {
if (pageSize < 1) {
throw new JspTagException("bs:pagination: pageSize must be at least 1 but was " + pageSize);
}
JspFragment body = getJspBody();
if (body == null) {
return;
}
JspContext ctx = getJspContext();
int pages = Math.max(1, (total + pageSize - 1) / pageSize);
Object savedVar = ctx.getAttribute(var, PageContext.PAGE_SCOPE);
Object savedActive = ctx.getAttribute("active", PageContext.PAGE_SCOPE);
try {
for (int p = 1; p <= pages; p++) {
ctx.setAttribute(var, p, PageContext.PAGE_SCOPE);
ctx.setAttribute("active", p == current, PageContext.PAGE_SCOPE);
if (p > 1 && separator != null) {
separator.invoke(null);
}
body.invoke(null);
}
} finally {
restore(ctx, var, savedVar);
restore(ctx, "active", savedActive);
}
}
private static void restore(JspContext ctx, String name, Object saved) {
if (saved == null) {
ctx.removeAttribute(name, PageContext.PAGE_SCOPE);
} else {
ctx.setAttribute(name, saved, PageContext.PAGE_SCOPE);
}
}
}
The try and finally at the end are there because of an observed behaviour, explained after the example. The page that
uses the tag chooses the bold current page and a plain number for the others:
<c:set var="cur" value="${empty param.p ? 1 : param.p}"/>
<bs:pagination total="45" pageSize="10" current="${cur}" var="n">
<c:choose><c:when test="${active}">[${n}]</c:when><c:otherwise>${n}</c:otherwise></c:choose>
</bs:pagination>
after: n=${n} active=${active}
[1]
2
3
4
5
after: n= active=
1
2
[3]
4
5
after: n= active=
The page numbers appear in the loop, the current page is marked, and after the tag the variables are gone. That last line is
the result of the finally block. Without it (the first version of the handler, which only set the variables), the same page
printed after: n=5 active=false: the variables the handler set were still in the page scope after the tag. A second page
made the problem worse:
<c:set var="n" value="outer"/>
<bs:pagination total="25" pageSize="10" var="n">${n};</bs:pagination>
after: n=${n} active=${active}
without restore: 1;2;3;
after: n=3 active=false
with restore: 1;2;3;
after: n=outer active=
The n the page set before the tag was overwritten and never came back. The TLD says that n is a NESTED variable, and
the specification defines NESTED as visible only in the body, with the value saved at the start of the tag and restored at
the end; but that synchronisation table (8.9) is written for tag files, which the container generates itself. For a handler
written in Java, the Javadoc of doTag() promises only that the container resynchronises AT_BEGIN and AT_END variables
after the call, and Tomcat 11.0.26 did not save or restore the NESTED ones. A handler that exposes variables to its body
should therefore save the previous values and restore them in a finally block, as the final version does. The same code is
what a tag file gets for free.
Fragment Attributes: jsp:attribute and the separator
The separator attribute of bs:pagination is declared in the TLD with <fragment>true</fragment>, so its type is
JspFragment and its value is not a string but a piece of page, written with jsp:attribute. When a custom tag has
jsp:attribute children, its body has to be written explicitly with jsp:body. The container
turns the body of the jsp:attribute into a fragment and passes it to setSeparator. The fragment must be scriptless, and
the handler calls separator.invoke(null) between the pages.
<c:set var="cur" value="${empty param.p ? 1 : param.p}"/>
<bs:pagination total="45" pageSize="10" current="${cur}" var="n">
<jsp:attribute name="separator"> | </jsp:attribute>
<jsp:body><c:choose><c:when test="${active}">[${n}]</c:when><c:otherwise>${n}</c:otherwise></c:choose></jsp:body>
</bs:pagination>
<bs:pagination total="0" var="n">never printed</bs:pagination>
[1]|2|3|4|5
never printed
Two observations. The white space around the separator (a space, a bar and a space) was trimmed: jsp:attribute trims leading and trailing
white space of its body at translation time by default, and the trim="false" attribute keeps it (with it,
<jsp:attribute name="separator" trim="false"> | </jsp:attribute> printed 1 | 2 | 3 in a separate test). And
bs:pagination total="0" still printed "never printed" once, because the handler computes Math.max(1, ...) pages: an empty
list has one (empty) page. That is a choice of this tag, documented in its description; a tag with a different rule would
call invoke zero times, which is the way to write a body that appears only sometimes.
Dynamic Attributes: bs:link
Some tags mimic an HTML element and cannot know which attributes the page author needs: class, title, data-id,
aria-current. Since JSP 2.0 a handler can declare that it accepts attributes with dynamic names. It implements
DynamicAttributes, whose single method setDynamicAttribute(String uri, String localName, Object value) is called by the
container for every attribute that the TLD does not name, and the tag’s TLD entry carries <dynamic-attributes>true</dynamic-attributes>.
Without that element, an unknown attribute is a translation error (as for colour above). The handler must remember the
values itself, and a JspException thrown from the method means "I do not accept this one": the container then does not call
doTag().
bs:link is an anchor with one declared attribute, href, and any number of dynamic attributes. It is written to be safe:
the names are limited to a conservative pattern, style and every on... event attribute are refused, the javascript:
scheme is refused, and every value and the href are escaped.
public class LinkTag extends SimpleTagSupport implements DynamicAttributes {
private static final Pattern NAME = Pattern.compile("[a-z][a-z0-9-]*");
private String href;
private final Map<String, String> extra = new LinkedHashMap<>();
public void setHref(String href) { this.href = href; }
@Override
public void setDynamicAttribute(String uri, String localName, Object value) throws JspException {
String name = localName.toLowerCase(Locale.ROOT);
if (!NAME.matcher(name).matches() || name.startsWith("on") || name.equals("style")) {
throw new JspTagException("bs:link does not accept the attribute \"" + localName + "\"");
}
if (value != null) { // an EL expression that evaluates to null drops the attribute
extra.put(name, value.toString());
}
}
@Override
public void doTag() throws JspException, IOException {
String safe = href == null ? "" : href.strip();
if (safe.toLowerCase(Locale.ROOT).startsWith("javascript:")) {
throw new JspTagException("bs:link: refusing a javascript: URL");
}
Writer out = getJspContext().getOut();
out.write("<a href=\"" + ExcerptTag.escape(safe) + "\"");
extra.forEach((n, v) -> {
try {
out.write(" " + n + "=\"" + ExcerptTag.escape(v) + "\"");
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
out.write(">");
if (getJspBody() != null) {
getJspBody().invoke(null);
}
out.write("</a>");
}
}
<c:forEach var="b" items="${books}">
<bs:link href="/books/${b.id}" class="book" title="${b.title}" data-id="${b.id}">${b.title}</bs:link>
</c:forEach>
<a href="/books/1" class="book" title="Dune" data-id="1">Dune</a>
<a href="/books/2" class="book" title="Neuromancer" data-id="2">Neuromancer</a>
<a href="/books/3" class="book" title="The Dispossessed" data-id="3">The Dispossessed</a>
<a href="/books/4" class="book" title="<script>alert('x')</script>" data-id="4"><script>alert('x')</script></a>
The title attribute of the fourth book is escaped by the tag, as it should be; the link text is the body, the raw
${b.title} of the page, and is not (the third pitfall in the table at the end of the page). The attribute checks, with
the three kinds of dangerous input:
| Page | Result |
|---|---|
|
|
|
|
the same page with |
|
The uri argument is the namespace of the attribute, null for the default namespace; it matters only for XML-syntax pages
that write prefix:name attributes. An EL expression that evaluates to null reaches setDynamicAttribute as a null
value, which is why the handler above skips it: aria-current="${active ? 'page' : null}" writes the attribute on the
current page link and nothing on the others. If the dynamic attribute is written with jsp:attribute, its body is
evaluated and passed as a String.
Cooperating Tags: getParent, findAncestorWithClass and TagAdapter
Tags nested in other tags can talk to each other through the tree the container builds. setParent gives each handler its
enclosing tag, and SimpleTagSupport.findAncestorWithClass(from, klass) walks up the chain and returns the closest
ancestor of a class. bs:numbered is an ordered list
that numbers its bs:item children, and the item finds its parent by class.
public class NumberedTag extends SimpleTagSupport {
private int counter;
int next() { return ++counter; }
@Override
public void doTag() throws JspException, IOException {
getJspContext().getOut().write("<ol>\n");
if (getJspBody() != null) {
getJspBody().invoke(null);
}
getJspContext().getOut().write("</ol>\n");
}
}
public class ItemTag extends SimpleTagSupport {
@Override
public void doTag() throws JspException, IOException {
NumberedTag parent = (NumberedTag) findAncestorWithClass(this, NumberedTag.class);
if (parent == null) {
throw new JspTagException("bs:item must be nested in bs:numbered");
}
StringWriter text = new StringWriter();
getJspBody().invoke(text);
getJspContext().getOut().write("<li value=\"" + parent.next() + "\">"
+ ExcerptTag.escape(text.toString().strip()) + "</li>\n");
}
}
<bs:numbered>
<bs:item>Dune</bs:item>
<bs:item>Neuromancer</bs:item>
</bs:numbered>
<bs:item>orphan</bs:item>
Without the last line the page prints <ol> with the items <li value="1">Dune</li> and <li value="2">Neuromancer</li>. With
it, the container reports JspTagException: bs:item must be nested in bs:numbered and the whole response is an error page:
the output of the list is in the response buffer and is discarded with it (see Error Handling for the buffer rules).
SimpleTag does not extend Tag, so the two families have a seam. A classic handler nested in a simple tag cannot have a
SimpleTag as its parent, because Tag.setParent accepts only a Tag; the container wraps the simple parent in a
TagAdapter and gives that to the classic child. TagSupport.findAncestorWithClass compares classes literally and
therefore does not see through the adapter, while SimpleTagSupport.findAncestorWithClass unwraps it:
public class LegacyItemTag extends TagSupport {
private boolean useSimple;
public void setUseSimple(boolean useSimple) { this.useSimple = useSimple; }
@Override
public int doStartTag() throws JspException {
Object found = useSimple
? SimpleTagSupport.findAncestorWithClass(this, NumberedTag.class)
: TagSupport.findAncestorWithClass(this, NumberedTag.class);
// writes: parent class=..., found=...
return SKIP_BODY;
}
}
<li>parent class=TagAdapter, found=null</li> useSimple="false": TagSupport.findAncestorWithClass
<li>parent class=TagAdapter, found=NumberedTag</li> useSimple="true": SimpleTagSupport.findAncestorWithClass
The rule from the specification is to use SimpleTagSupport.findAncestorWithClass in every case in which the wanted
ancestor may be a simple tag, and getAdaptee() on the adapter to reach the simple tag from classic code.
Stopping the Page: SkipPageException
A classic handler stops the rest of the page by returning SKIP_PAGE from doEndTag(). A simple handler throws
SkipPageException from doTag() (or a fragment does, which a handler propagates). The Javadoc is explicit about the effect:
the calling page must cease evaluation. It is the mechanism behind a guard tag such as "stop unless the request has an id", and
it is also thrown by the container itself when a tag file invokes a classic handler that returned SKIP_PAGE.
public class StopUnlessTag extends SimpleTagSupport {
private boolean test;
public void setTest(boolean test) { this.test = test; }
@Override
public void doTag() throws JspException, IOException {
if (!test) {
throw new SkipPageException("condition not met");
}
}
}
bs and fn are left out)<%-- stop.jsp --%>
before
<bs:stopUnless test="${not empty param.id}"/>
after: id=${fn:escapeXml(param.id)}
<%-- stopinc.jsp --%>
outer before
<jsp:include page="/stop.jsp"/>
outer after
| Request | Body of the response |
|---|---|
|
|
|
|
|
|
The text before the tag was sent, the text after it was not, and the status is 200, because stopping the page is not an
error. The third row shows the scope of the stop: the page that included stop.jsp carried on, since a jsp:include is a
separate request dispatch (see the includes and forwards
page); only the page that contains the tag stops. Compare a JspTagException, which discards the buffer and produces an error
response. Use SkipPageException only for "the rest of this page makes no sense", for example after a redirect the tag has
already sent; the Javadoc adds that it "should not be thrown manually in a JSP page or tag file", meaning in a scriptlet.
The Rest of the API: Context, Injection, Ids and Listeners
JspContext, PageContext and tag files. A simple handler receives a JspContext. On Tomcat the object is the page’s
PageContextImpl, so a cast to PageContext for the request, the response or the session works in an ordinary page, and
this is common in practice. It is not portable to every context: the same handler called from inside a tag file got a
JspContextWrapper (the page context of the tag file) in the test, and on Tomcat that class is also a PageContext:
context=org.apache.jasper.runtime.PageContextImpl, isPageContext=true
context=org.apache.jasper.runtime.JspContextWrapper, isPageContext=true
The specification only promises a JspContext, and a handler meant for reuse should use the JspContext methods and test with
instanceof PageContext before it needs the servlet API.
Resource injection. Handlers that implement Tag or SimpleTag may be annotated for injection (@Resource, @EJB,
@PersistenceContext, @PostConstruct, @PreDestroy and the others the Servlet specification lists in section 15.5); the
container injects them right after the instance is constructed and before any attribute is set. Injection is not supported for
JSP pages or tag files, and a container that is not a full Jakarta EE server is encouraged but not required to support it, so a
tag that relies on it is not portable to a plain servlet container.
JspIdConsumer. A handler that implements JspIdConsumer receives a jspId through setJspId, a string that is unique for
every tag in a page and the same for a given tag on every request. It is the way to generate stable HTML ids; the Javadoc notes
that such handlers cannot be reused, which is no restriction for simple tags.
Event listeners. A tag library may list listener classes in its TLD; the container registers them before the application starts. The mechanism is the same for classic and simple libraries and is described on the tag library descriptors page.
Scripting variables. A variable declaration can create a scripting variable for scriptlets, but a tag body of a simple tag
is a fragment and cannot contain scriptlets: a translation error occurs if it does. The variables of this page are scoped
variables read through the EL.
Pooling. The container does not pool simple handlers (the trace above shows a new instance for every use), so the Jasper
option enablePooling, which controls the reuse of classic handlers, has no effect on them; the classic handlers page of this
section covers the option and the 2024 Tomcat vulnerability in tag recycling.
Worked Example: the Bookshelf Catalogue Page
The pieces come together in one page: a catalogue of two books per page with a link, a rating, a "classic" badge, an excerpt of
the blurb, and a pager. The application scope holds the four books (the ShelfListener of the scratch project sets them), and
the page chooses the books to show with c:forEach begin and end from the p request parameter.
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<%@ taglib prefix="bs" uri="https://example.org/bookshelf/tags" %>
<c:set var="size" value="2"/>
<c:set var="cur" value="${empty param.p ? 1 : param.p}"/>
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Bookshelf</h1>
<ul>
<c:forEach var="b" items="${books}" begin="${(cur - 1) * size}" end="${cur * size - 1}">
<li>
<bs:link href="${pageContext.request.contextPath}/books/${b.id}" class="title"><c:out value="${b.title}"/></bs:link>
<bs:rating value="${b.rating}"/>
<bs:when test="${b.year lt 1970}"><em>classic</em></bs:when>
<p><bs:excerpt max="40">${b.blurb}</bs:excerpt></p>
</li>
</c:forEach>
</ul>
<nav>
<bs:pagination total="${fn:length(books)}" pageSize="${size}" current="${cur}" var="n">
<jsp:attribute name="separator"> | </jsp:attribute>
<jsp:body><bs:link href="?p=${n}" class="page" aria-current="${active ? 'page' : null}">${n}</bs:link></jsp:body>
</bs:pagination>
</nav>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<body>
<h1>Bookshelf</h1>
<ul>
<li>
<a href="/g6simpletag/books/1" class="title">Dune</a>
<span class="rating" title="5 of 5">★★★★★</span>
<em>classic</em>
<p>Fear is the mind-killer & the little-dea…</p>
</li>
<li>
<a href="/g6simpletag/books/2" class="title">Neuromancer</a>
<span class="rating" title="4 of 5">★★★★☆</span>
<p>The sky above the port was the color of…</p>
</li>
</ul>
<nav>
<a href="?p=1" class="page" aria-current="page">1</a>|<a href="?p=2" class="page">2</a>
</nav>
</body>
</html>
And the second page (worked.jsp?p=2) shows the third and fourth books, the last one with the hostile title, which c:out
(inside the bs:link body) and the tag’s own escaping render harmlessly, and moves aria-current to the second link:
<a href="/g6simpletag/books/4" class="title"><script>alert('x')</script></a>
<span class="rating" title="2 of 5">★★☆☆☆</span>
<p>short</p>
...
<a href="?p=1" class="page">1</a>|<a href="?p=2" class="page" aria-current="page">2</a>
The page has no scriptlet and no Java, the tags do the presentation, and each of them is testable on its own. The decisions
that make it work: c:out for the title, because the body of bs:link is not escaped; the tag escapes the attribute values
and the excerpt, because they cross from data to markup inside the tag; the page uses fn:length for the total instead of
asking the tag to count; and the pager’s separator is a fragment so that the page author, not the tag, decides what goes
between the numbers.
Security Notes
-
A tag is code that writes HTML. Decide where escaping happens and document it. In the example the tags escape what they receive as attributes and as captured text (
bs:link,bs:excerpt), and do not touch what they pass through unmodified (the body ofbs:link, a fragment passed toinvoke(null)). The page author must know which is which; the tag’sdescriptionin the TLD is the place to say it. -
Dynamic attributes are an injection channel.
bs:linkaccepts any attribute name. An unchecked name can carryonclick,styleor a name with spaces and quotes, and an unchecked value can end the attribute and start another. Restrict names with a pattern, refuse event attributes, escape every value, and refusejavascript:in a URL (the three checks are in the example and were exercised above). -
Never build EL or scripting from attribute values. A handler that evaluates a string with
ELProcessoror theJspApplicationContextfactory turns a request value into code. Take the value as data; let the container evaluate the attribute (rtexprvalue) and give the handler the result. -
Validate in the handler and fail loudly.
max,pageSizeand the scope name are checked before use; an uncheckedpageSizeof 0 would divide by zero. ThrowJspTagExceptionwith a message that names the tag and the attribute, so the page author finds it. Put no secrets, SQL or file paths in these messages: they reach the error page. -
Instance fields are private to one use,
staticfields are not. The per-invocation instance makes the handler itself thread-safe, but a static counter, cache orSimpleDateFormatis shared between all requests; use thread-safe types and never keep aJspContext, a request or a fragment in a static field. -
Do not let a tag read request data it was not given. A tag that calls
((PageContext) ctx).getRequest().getParameter(...)hides an input that the page author cannot see. Pass values as attributes so that the page shows what feeds the tag. -
SkipPageExceptionis not an access check. A guard tag that stops the page is a convenience; an authorisation rule belongs in a filter or in the security constraints ofweb.xml, because a page reached through a controller should never depend on a tag to refuse a request. -
Keep tag libraries on the right side of the trust line. A TLD and its classes run with the web application’s privileges. Add only libraries from sources you trust, check them for the dependencies they bring, and remember that a
listenerelement in a TLD registers code that runs when the application starts.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
|
|
|
|
a scriptlet in the body of a simple tag or in a |
EL and actions in the body; compute in the handler or the controller |
|
an attribute in the TLD with no matching |
add |
|
an attribute the TLD does not declare, on a tag without |
declare it, or implement |
|
a |
make the attribute |
|
an EL expression in an attribute with |
a literal, or declare the attribute |
|
a request-time value that cannot be converted to the setter’s type |
validate in the controller, or take a |
a body that never appears, or |
the element is empty, so |
test for |
the body appears twice, or in the wrong place |
|
one call per output; capture in a |
a tag’s output appears before the surrounding text, or is lost |
the handler wrote to |
write to |
the output of the body is HTML-escaped twice (observed) |
the body already escaped with |
decide which side escapes, and document it |
the link text is a live |
the tag escaped its attributes, but the body is raw template text |
|
|
the container did not save and restore |
save the old values and restore them in |
a separator or a fragment attribute loses its spaces (observed) |
|
|
|
a tag with |
put the body inside |
|
|
check for |
|
|
use |
state kept in an instance field seems to be lost, or a counter always restarts |
every use creates a new handler; it cannot carry state between uses |
keep the state in a scope attribute (request or session) or in a service |
state kept in a |
a static field is shared by all requests and threads |
thread-safe types, or no static state |
|
the handler cast the |
work with |
|
the buffer is discarded on an exception (observed for |
validate early; |
the page after |
the stop applies to the page that holds the tag (observed with |
expect the outer page to continue; use a filter or controller for rules that must stop everything |
a handler written for |
the class was compiled against the Java EE package names |
recompile against |
What Changed Recently
| Version | Change |
|---|---|
Jakarta Pages 4.1 (under development, milestone 4.1.0-M2) |
a milestone of the next release; it is not on Tomcat 11, and nothing on this page was verified against it |
Jakarta Pages 4.0 (Jakarta EE 11) |
Java 17 minimum; the deprecated |
Jakarta Pages 3.1 (Jakarta EE 10, 2022) |
the meaning of "scope" for the scripting variables of a custom action is clarified; |
Jakarta Server Pages 3.0 (Jakarta EE 9, 2020) |
the package changes from |
JSP 2.1 (Java EE 5, 2006) |
the unified expression language; |
JSP 2.0 (J2EE 1.4, 2003) |
introduces the Simple Tag Extension ( |
JSP 1.2 (J2EE 1.3, 2001) |
classic handlers only: |
Two leftovers in the official material are worth knowing. The Javadoc of SimpleTag.doTag() mentions the resynchronisation
of AT_BEGIN and AT_END variables only, while the variable synchronisation table of the specification (section 8.9, written
for tag files) lists NESTED as well; as the test above shows, a Java handler cannot rely on the container for the NESTED
rule. And the Javadoc of SimpleTagSupport.findAncestorWithClass still contains the typo "TagAdpater" in its text.
Where the Books Are Outdated
The section bibliography lists Web Development with JavaServer Pages (Manning, 2001, JSP 1.2), whose chapters on custom tags describe the tag extension mechanism of its time, and JSTL in Action (Manning, 2002), whose last chapter builds custom tags that cooperate with JSTL. The ideas that these chapters teach are still sound: a tag library as a vocabulary, a TLD that describes it, attributes as bean properties, scoped variables as the way a tag gives data to its body, tags that cooperate through their parent, and the discipline of separating tags from business logic. What changed is the API and the amount of code needed.
| The books' era (JSP 1.2, JSTL 1.0) | Today (Pages 4.0, JSTL 3.0) |
|---|---|
only classic handlers: |
|
loops written with |
a loop in |
scripting variables declared through |
scoped variables set in the |
attribute values of type |
the container evaluates the EL in every attribute ( |
tags that need to accept any attribute were impossible; HTML-mimicking tags declared dozens of attributes |
|
a tag body needed to be processed with |
|
|
|
tags written only in Java, with a JSP 1.2 TLD in a DTD |
the same tags as tag files in plain JSP for presentation-only needs, and an XML Schema TLD (version 4.0) in the Jakarta namespace |
|
|
handler pooling as a performance argument for classic tags |
simple handlers are never reused, because caching them did not bring the expected gain |
Source: Manning — JSTL in Action
References
-
Jakarta Pages 4.0 — 7.1.7 Simple Examples of Simple Tag Handlers
-
Jakarta Pages 4.0 — 7.3.10 Tag and Tag Library Extension Elements
-
Jakarta Pages 4.0 API — SimpleTagSupport.findAncestorWithClass
-
Jakarta Pages 4.0 API — DynamicAttributes.setDynamicAttribute
-
Manning — Web Development with JavaServer Pages, Second Edition