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.

Table 1. Simple tags compared with the other ways of adding an action to a page
Mechanism What you write Status and when to choose it

Simple tag handler (this page)

a Java class with doTag(); attributes as JavaBean setters; the body as a JspFragment

current; the default choice for a tag that needs Java (calls a service, builds markup, talks to the JspContext)

Tag file

a .tag file in JSP syntax with tag, attribute and variable directives and jsp:doBody

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

doStartTag(), doAfterBody(), doEndTag() with return codes, optional BodyContent buffering, handler pooling

legacy but still supported and used inside the standard tag library; choose it only to extend a classic class such as LoopTagSupport or to maintain old code

EL function

a public static method declared in a TLD and called as ${bs:initials(name)}

current; for a value, not for markup or flow control

Scriptlet or jsp:useBean helper

Java in the page

legacy; disabled with scripting-invalid in a modern application

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 of BodyTag with 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 static and other shared state needs care.

  • No servlet API. SimpleTag depends on JspContext, the superclass of PageContext, and not on PageContext, 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 JspFragment and handed over with setJspBody. 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.

Table 2. The methods of SimpleTag and SimpleTagSupport (Jakarta Pages 4.0)
Method What it does Who calls it

void setJspContext(JspContext pc)

stores the context of this invocation; SimpleTagSupport keeps it for getJspContext()

the container, first

void setParent(JspTag parent)

stores the enclosing tag, for cooperating tags

the container, only when the tag is nested in another tag

void setJspBody(JspFragment body)

stores the body; not called at all for an empty element, so getJspBody() returns null

the container, after the attribute setters

void doTag() throws JspException, IOException

all the tag’s work; SimpleTagSupport.doTag() does nothing

the container, once

JspTag getParent()

the enclosing tag, or null

the handler or its children

protected JspContext getJspContext()

the JspContext of this invocation (in practice the PageContext)

the handler (helper of SimpleTagSupport)

protected JspFragment getJspBody()

the body fragment, or null

the handler (helper of SimpleTagSupport)

static JspTag findAncestorWithClass(JspTag from, Class<?> klass)

the closest enclosing tag of a given class, looking through TagAdapter wrappers

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.

Table 3. The two methods of JspFragment
Method Behaviour

void invoke(Writer out)

runs the fragment and sends its output to out, or, when out is null, to the JspWriter of the fragment’s JspContext; throws JspException, IOException and, if the page is to stop, SkipPageException

JspContext getJspContext()

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.

sequenceDiagram participant P as Translated page (servlet) participant H as Tag handler participant F as JspFragment (the body) P->>H: new, public no-argument constructor P->>H: setJspContext(pageContext) opt the tag is nested in another tag P->>H: setParent(enclosing tag) end P->>H: one setter per attribute that was written opt the element has a body P->>H: setJspBody(fragment) end P->>H: doTag() loop zero, one or many times H->>F: invoke(null) or invoke(writer) F-->>H: body output goes to the page or to the writer end H-->>P: returns, or throws JspException or SkipPageException Note over P,H: the instance is now garbage, it is never reused
Figure 1. The life of a simple tag handler: one invocation, one instance

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.

TraceTag: the handler records the calls it receives
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);
        }
    }
}
trace.jsp: a trace tag inside a trace tag (the TLD entry declares 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>
The first and the second request to trace.jsp (observed on Tomcat 11.0.26)
[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.

WEB-INF/bookshelf.tld (the tags of this page, abridged; the full file declares every tag shown below)
<?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>
Table 4. The TLD elements of a simple tag
Element Meaning for a simple tag Notes

name

the action name after the prefix: <bs:rating>

case sensitive

tag-class

the handler class, which must implement SimpleTag

a Tag class here makes the tag classic; the container decides from the interface

body-content

empty (the element must have no body), scriptless (template text, EL and actions), tagdependent (the container does not interpret the body; only empty and scriptless are exercised on this page)

JSP is invalid for a simple tag; omitted means no default in JSP 2.0 and later, so always write it

attribute

name, required, rtexprvalue (true: an EL expression is accepted), type (the Java type; default java.lang.String), fragment (the attribute is a JspFragment)

the setter name is derived from the attribute name: value needs setValue

variable

a scripting variable or scoped variable that the tag creates: name-given or name-from-attribute, variable-class, scope (NESTED, AT_BEGIN, AT_END), declare

name-from-attribute requires the attribute to be present on every use

dynamic-attributes

true if the handler implements DynamicAttributes and accepts attributes the TLD does not name

see the dynamic attributes section

example, description, display-name, tag-extension

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).

RatingTag.java
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.

rating.jsp: one tag per book (the application scope holds the four Bookshelf books, each with a rating)
<%@ 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>
The output of rating.jsp (observed)
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.

rating2.jsp: a different maximum, and the result stored in two scopes
<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}
The output of rating2.jsp (observed; blank lines removed)
<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.

Table 5. What happens with wrong attributes (observed on Tomcat 11.0.26)
Page When Result

<bs:rating/>

translation

According to the TLD or the tag file, attribute [value] is mandatory for tag [rating]

<bs:rating value="3" colour="red"/>

translation

Attribute [colour] invalid for tag [rating] according to TLD

<bs:rating value="3" var="${param.v}"/>

translation

According to TLD or attribute directive in tag file, attribute [var] does not accept any expressions

<bs:rating value="3"><%= 1+1 %></bs:rating>

translation

According to TLD, tag [bs:rating] must be empty, but is not

<bs:rating value="abc"/>

translation, in the compiler

Unable to compile class for JSP, root cause NumberFormatException: For input string: "abc"

<bs:rating value="${param.v}"/> with v=abc

request

jakarta.el.ELException: Cannot convert [abc] of type [class java.lang.String] to [int]

<bs:rating value="7"/>

request, in doTag()

JspTagException: bs:rating: value 7 is outside 0..5

a TLD attribute nothing without a setNothing method

translation

Unable to find setter method for attribute: [nothing]

a name-from-attribute variable whose attribute is not written

translation

Unable to determine scripting variable name from attribute [var]

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.

Table 6. What a handler can do with its body
Pattern In doTag() Example

Conditional

call invoke(null) only when the condition holds

bs:when

Iteration

call invoke(null) in a loop, setting page-scoped values the body reads

bs:pagination

Process the body

call invoke(writer) with a StringWriter, change the text, write the result

bs:excerpt

Ignore the body

do not call invoke at all (and use empty in the TLD if no body should be written)

bs:rating

flowchart TD A["doTag() starts"] --> B{"getJspBody() is null?"} B -->|yes| C["the element was empty: nothing to run"] B -->|no| D{"what should the tag do with the body?"} D -->|"conditional"| E{"condition true?"} E -->|yes| F["invoke(null): the body writes to the page"] E -->|no| G["do not invoke: the body is skipped"] D -->|"iteration"| H["loop: set page-scoped values, then invoke(null)"] D -->|"process the text"| I["invoke(stringWriter), edit the text, write it to getJspContext().getOut()"] D -->|"stop the page"| J["throw SkipPageException"]

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.

ExcerptTag.java
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("&amp;");
                case '<' -> sb.append("&lt;");
                case '>' -> sb.append("&gt;");
                case '"' -> sb.append("&#034;");
                case '\'' -> sb.append("&#039;");
                default -> sb.append(c);
            }
        }
        return sb.toString();
    }
}
excerpt.jsp and its output (observed)
<c:forEach var="b" items="${books}">
${b.title}: <bs:excerpt max="30">${b.blurb}</bs:excerpt>
</c:forEach>
Dune: Fear is the mind-killer &amp; the…
Neuromancer: The sky above the port was the…
The Dispossessed: &lt;b&gt;An ambiguous utopia&lt;/b&gt; 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:

Table 7. Edge cases of bs:excerpt (observed)
Page Output Why

[<bs:excerpt/>]

[]

no body: getJspBody() is null, and the handler returns

[<bs:excerpt max="10"></bs:excerpt>]

[]

a body that is present but empty prints nothing

[<bs:excerpt max="12"> Hello <c:out value="<b>"/> world, again </bs:excerpt>]

[Hello &amp;lt;b&amp;…]

c:out already escaped <b> to &lt;b&gt;, and the tag escaped the ampersand again: the text is escaped twice

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.

WhenTag.java
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);
        }
    }
}
when.jsp and its output (observed)
<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.

PaginationTag.java
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:

pagination.jsp: a pager for 45 books, 10 per page
<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}
Requests to pagination.jsp and pagination.jsp?p=3 (observed; blank lines removed)
[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:

pagination3.jsp: a variable with the same name already exists before the tag (observed with the version without the finally block)
<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.

pagination2.jsp: a fragment attribute and an empty list
<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>
The output of pagination2.jsp (observed; blank lines removed)
[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.

LinkTag.java
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>");
    }
}
link.jsp and its output (observed; one line per book)
<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="&lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt;" 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:

Table 8. Dynamic attribute refusals and escaping (observed)
Page Result

<bs:link href="/x" onclick="steal()">x</bs:link>

JspTagException: bs:link does not accept the attribute "onclick"

<bs:link href="${param.u}" ...> with u=javascript:alert(1)

JspTagException: bs:link: refusing a javascript: URL

the same page with u=/a" onmouseover="x and t="><b>

<a href="/a&#034; onmouseover=&#034;x" title="&#034;&gt;&lt;b&gt;">x</a>: both values stay inside their attributes

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.

NumberedTag.java and ItemTag.java
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");
    }
}
nested.jsp: two items in a list, then an item outside a list
<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:

A classic handler under a simple one: two ways to look for the parent (observed)
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.

StopUnlessTag.java
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");
        }
    }
}
stop.jsp, and an including page stopinc.jsp (the taglib directives for 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
Table 9. What the requests return (observed; all status 200)
Request Body of the response

stop.jsp

before

stop.jsp?id=7

before and after: id=7

stopinc.jsp

outer before, before and outer after

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:

ContextTag, called from a page and from a tag file (observed)
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.

worked.jsp
<%@ 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>
The response to /g6simpletag/worked.jsp (observed; blank lines removed)
<!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 &amp; 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">&lt;script&gt;alert(&#039;x&#039;)&lt;/script&gt;</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 of bs:link, a fragment passed to invoke(null)). The page author must know which is which; the tag’s description in the TLD is the place to say it.

  • Dynamic attributes are an injection channel. bs:link accepts any attribute name. An unchecked name can carry onclick, style or 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 refuse javascript: 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 ELProcessor or the JspApplicationContext factory 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, pageSize and the scope name are checked before use; an unchecked pageSize of 0 would divide by zero. Throw JspTagException with 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, static fields are not. The per-invocation instance makes the handler itself thread-safe, but a static counter, cache or SimpleDateFormat is shared between all requests; use thread-safe types and never keep a JspContext, 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.

  • SkipPageException is 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 of web.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 listener element in a TLD registers code that runs when the application starts.

Pitfalls and Error Names

Table 10. Simple tag pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or error Cause Fix

The TLD for the class [...] specifies an invalid body-content (JSP) for a SimpleTag. (observed)

body-content JSP on a simple handler

scriptless, empty or tagdependent

Scripting elements ( <%!, <jsp:declaration, <%=, <jsp:expression, <%, <jsp:scriptlet ) are disallowed here. (observed)

a scriptlet in the body of a simple tag or in a jsp:attribute fragment

EL and actions in the body; compute in the handler or the controller

Unable to find setter method for attribute: [nothing] (observed)

an attribute in the TLD with no matching setXxx method, or a name that differs in case

add setNothing(...), spelled from the attribute name

Attribute [colour] invalid for tag [rating] according to TLD (observed)

an attribute the TLD does not declare, on a tag without dynamic-attributes

declare it, or implement DynamicAttributes

Unable to determine scripting variable name from attribute [var] (observed)

a variable with name-from-attribute while the page leaves the attribute out

make the attribute required, or drop the variable

According to TLD or attribute directive in tag file, attribute [var] does not accept any expressions (observed)

an EL expression in an attribute with rtexprvalue false

a literal, or declare the attribute rtexprvalue true if a dynamic value makes sense

jakarta.el.ELException: Cannot convert [abc] of type [class java.lang.String] to [int] (observed)

a request-time value that cannot be converted to the setter’s type

validate in the controller, or take a String and parse it with a clear error

a body that never appears, or NullPointerException in doTag()

the element is empty, so getJspBody() is null; or doTag() never calls invoke

test for null; call invoke(null) where the body belongs

the body appears twice, or in the wrong place

invoke(null) writes to the page every time; invoke(writer) to the writer only

one call per output; capture in a StringWriter when the handler must process the text

a tag’s output appears before the surrounding text, or is lost

the handler wrote to System.out, or to a Writer it kept from another context

write to getJspContext().getOut()

the output of the body is HTML-escaped twice (observed)

the body already escaped with c:out and the tag escapes again

decide which side escapes, and document it

the link text is a live <script> although the href and title are escaped (observed)

the tag escaped its attributes, but the body is raw template text

c:out in the body, or capture and escape in the tag

after: n=5 (observed): a variable the tag set is still there after it, or an earlier n is overwritten (observed)

the container did not save and restore NESTED variables of a Java handler

save the old values and restore them in finally

a separator or a fragment attribute loses its spaces (observed)

jsp:attribute trims leading and trailing white space

trim="false", or markup such as  

Must use jsp:body to specify tag body for [<bs:pagination] if jsp:attribute is used. (observed)

a tag with jsp:attribute children and a body written as plain text; with no jsp:body the tag has an empty body

put the body inside jsp:body

<bs:item> outside <bs:numbered> reports a JspTagException (observed)

findAncestorWithClass returned null

check for null and fail with a message, as the example does

findAncestorWithClass returns null for a simple parent from a classic child (observed)

TagSupport.findAncestorWithClass does not unwrap the TagAdapter

use SimpleTagSupport.findAncestorWithClass, or getAdaptee()

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 static field is wrong under load

a static field is shared by all requests and threads

thread-safe types, or no static state

ClassCastException: ... cannot be cast to jakarta.servlet.jsp.PageContext

the handler cast the JspContext of a context that is not a page context (a tag file in another container, or a non-servlet use)

work with JspContext, and cast only after instanceof

JspTagException thrown for bad input and the page shows an error page with half the output removed

the buffer is discarded on an exception (observed for bs:item)

validate early; SkipPageException stops without discarding

the page after SkipPageException still runs in the including page

the stop applies to the page that holds the tag (observed with jsp:include)

expect the outer page to continue; use a filter or controller for rules that must stop everything

a handler written for javax.servlet.jsp fails with NoClassDefFoundError: javax/servlet/jsp/tagext/SimpleTagSupport on Tomcat 10.1 or 11

the class was compiled against the Java EE package names

recompile against jakarta.servlet.jsp, or migrate with the Tomcat migration tool

What Changed Recently

Table 11. Simple tags and the tag extension API from JSP 2.0 to Pages 4.0
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.servlet.jsp.el classes, BodyTag.EVAL_BODY_TAG, JspException.getRootCause, isThreadSafe and jsp:plugin are removed; the SimpleTag, SimpleTagSupport, JspFragment and DynamicAttributes APIs are unchanged

Jakarta Pages 3.1 (Jakarta EE 10, 2022)

the meaning of "scope" for the scripting variables of a custom action is clarified; errorOnELNotFound is added; isThreadSafe and jsp:plugin are deprecated; the tag APIs are not changed

Jakarta Server Pages 3.0 (Jakarta EE 9, 2020)

the package changes from javax.servlet.jsp to jakarta.servlet.jsp; handlers and TLD namespaces must be recompiled or migrated; deprecated methods get @Deprecated, and the API uses generics

JSP 2.1 (Java EE 5, 2006)

the unified expression language; JspIdConsumer; resource injection into tag handlers; deferred attribute values in the TLD

JSP 2.0 (J2EE 1.4, 2003)

introduces the Simple Tag Extension (SimpleTag, SimpleTagSupport, JspFragment, TagAdapter), tag files, dynamic attributes, jsp:attribute, jsp:body, jsp:element, jsp:invoke and jsp:doBody, and the XML Schema TLD

JSP 1.2 (J2EE 1.3, 2001)

classic handlers only: Tag, IterationTag, BodyTag, listeners in the TLD and the first validators

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.

Table 12. The books' tag material and its current equivalent
The books' era (JSP 1.2, JSTL 1.0) Today (Pages 4.0, JSTL 3.0)

only classic handlers: Tag, IterationTag, BodyTag, return codes such as EVAL_BODY_TAG (removed in Pages 4.0), BodyContent buffering, doInitBody, doAfterBody

SimpleTag and SimpleTagSupport with a single doTag(); the body is a JspFragment run with invoke; no return codes and no handler reuse

loops written with doAfterBody() returning EVAL_BODY_AGAIN and a scripting variable the body reads with <%= %>

a loop in doTag() that calls invoke(null) and sets page-scoped values read with the EL (shown by bs:pagination)

scripting variables declared through TagExtraInfo and VariableInfo, read by scriptlets

scoped variables set in the JspContext and read through the EL; variable elements in the TLD only for the tools and for scriptlets

attribute values of type String evaluated by hand with ExpressionEvaluatorManager.evaluate(...) from Jakarta Taglibs (JSTL 1.0)

the container evaluates the EL in every attribute (rtexprvalue), converts it to the setter’s type, and passes the result; no helper library

tags that need to accept any attribute were impossible; HTML-mimicking tags declared dozens of attributes

DynamicAttributes and <dynamic-attributes>true</dynamic-attributes>

a tag body needed to be processed with BodyContent and getEnclosingWriter()

invoke(Writer) with a StringWriter, or invoke(null)

SKIP_PAGE returned from doEndTag()

SkipPageException thrown from doTag()

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

javax.servlet.jsp.tagext.*, JSTL jars named standard.jar and jstl.jar

jakarta.servlet.jsp.tagext.*, jakarta.servlet.jsp.jstl-api 3.0.2 and the org.glassfish.web implementation 3.0.1

handler pooling as a performance argument for classic tags

simple handlers are never reused, because caching them did not bring the expected gain

References