Results & Result Types

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 Apache Struts 7.4.0 (released 2026-10-02), running on Apache Tomcat 11.0.26 (Jakarta Servlet 6.1, Jakarta Pages 4.0) with Java 21. Everything was verified on 2026-10-08: the facts against the Struts documentation, the struts-default.xml, default.properties and struts-plugin.xml files inside the 7.4.0 jars and the 7.4.0 sources of org.apache.struts2.result, and every example by building it into a Maven WAR with the struts2-bom and deploying it on Tomcat 11.0.26, where each URL was called with curl; the outputs shown are the outputs that run produced (headers that do not matter and blank lines are left out). An action decides what happened; a result decides what the browser receives. Struts keeps the two apart on purpose: the action returns a short code such as success or input, and the configuration maps that code to a result type that renders a JSP, redirects, streams a file, writes JSON or sets a status line. The running scenario is the Bookshelf catalogue: the page grows from one JSP result to a configuration that redirects after a POST, exports a CSV file, answers a health check and serves JSON, with every example run against Struts 7.4.0.

What a Result Is

Once the action method (and the interceptors around it) has finished, the framework needs to produce the response. It asks the action invocation for a Result and calls its execute method. In Struts 7 the contract is the interface org.apache.struts2.result.Result (before 7.0 it was com.opensymphony.xwork2.Result; the package rename is part of the 6 to 7 migration) with exactly one method, execute(ActionInvocation). Almost every built-in result extends StrutsResultSupport, which adds the location property, the parse and encode switches and the evaluation of ${...} expressions against the value stack. What the action method returns decides how the framework reaches a Result:

Table 1. What an action method may return and what the framework does with it (7.4.0 DefaultActionInvocation)
Return value Effect

A String result code

Looked up by name in the action’s result configurations; the matching result element names the result type and its parameters

null or none

No result is looked up or run; the action is expected to have written the response itself (or nothing is needed)

A Result object (any type)

That object is used directly and no configuration is consulted; the framework injects its dependencies (container.inject)

A Callable

Treated as an asynchronous action: the framework suspends the invocation, runs the callable and processes its outcome as one of the above

The result is the last step before the response, but it is not the very last thing that happens. PreResultListener objects registered during the action or by an interceptor run first and may still change the result code (see Listeners Around the Result), and interceptors get control back after the result has run, so an interceptor that wraps invoke() sees a response that already exists.

flowchart TD A["Action method returns"] --> B{"String, Result or Callable?"} B -->|"Result object"| X["Run that Result directly"] B -->|"String code"| L["PreResultListeners run, they may change the code"] L --> C["Look the code up in the action's results (local ones override global ones)"] C -->|"found"| G["Build the Result from the result element: type + params"] C -->|"not found"| D["Look for the result named star"] D -->|"found"| G D -->|"not found"| E["Ask the unknown handlers, if any are registered"] E -->|"nothing"| F["ConfigurationException: No result defined for action ... and result ..."] G --> H["Result.execute: render, redirect, stream, write"] X --> H

The diagram shows why a typo in a result name is a runtime problem and not a startup problem: the framework cannot know which codes an action method will return, so the lookup happens per request. The rest of the page follows the diagram from the left: the configuration (Result Configuration), the shared parameters, and then each result type.

The Bookshelf Used on This Page

The scratch application behind the examples is the Bookshelf of the Getting Started page, extended with one action per result type. Its struts.xml begins with the pieces every example below relies on: a package that extends struts-default and the plugin packages that register the extra result types (see Plugin Results), one custom result type, and two global results.

The package the examples share (the actions are added to it one at a time)
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE struts PUBLIC
        "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN"
        "https://struts.apache.org/dtds/struts-6.0.dtd">
<struts>
    <constant name="struts.allowlist.packageNames" value="org.example.bookshelf"/>

    <package name="default" namespace="/"
             extends="struts-default,json-default,velocity-default,xslt-default,tiles-default">
        <result-types>
            <result-type name="label" class="org.example.bookshelf.LabelResult"/>
        </result-types>

        <global-results>
            <result name="login" type="redirectAction">login-form</result>
            <result name="error">/WEB-INF/views/error.jsp</result>
        </global-results>

        <!-- the actions of the following sections go here -->
    </package>
</struts>

Three details of that file come from the 7.4.0 DTD and defaults and are easy to miss. The element order inside package is fixed (result-types, interceptors, default-interceptor-ref, default-action-ref, default-class-ref, global-results, global-allowed-methods, global-exception-mappings, then the actions), so result-types must precede global-results. A package may extend several packages with a comma-separated list, which is how one package gets the json, velocity, xslt and tiles result types at once; only the packages named in extends contribute their result types. The struts.allowlist.packageNames constant is explained in Security: Dynamic Values and the Allowlist; without it the custom result of this page prints empty values.

Table 2. The actions used in the examples and the result type each one demonstrates
Action Result type What it does

books

dispatcher

Lists the catalogue with books.jsp

add

redirectAction (and dispatcher for input)

Adds a book from a POST, then redirects to books (Post/Redirect/Get)

old-catalogue

redirect

Permanent redirect (301) from a retired URL

featured

chain

Runs books after computing a headline

export

stream

Downloads the catalogue as a CSV file

ping, missing

httpheader

Answers 204 with headers; answers 404 with a message

terms

plainText

Shows an HTML snippet as plain text

health

PlainResult

Writes a small JSON document from Java

partner, receive

postback

Re-posts the request parameters to another action

book-card, book-card-vm

freemarker, velocity

Render one book as an HTML fragment

catalogue, catalogue-xml

json, xslt

The catalogue as JSON and as XML

home

tiles

A page assembled by a Tiles layout

label, restricted

custom label result

A custom Result and a parameter-restricted subclass

Result Configuration

A result element has two jobs: it gives a logical name (the code the action returns) and it names a result type with its parameters. Every part of the element has a default, so the common case is very short.

The same result written with fewer and fewer defaults (all four behave identically)
<result name="success" type="dispatcher">
    <param name="location">/WEB-INF/views/books.jsp</param>
</result>

<result>
    <param name="location">/WEB-INF/views/books.jsp</param>
</result>

<result>/WEB-INF/views/books.jsp</result>

<result name="success" type="dispatcher">/WEB-INF/views/books.jsp</result>

Three defaults make the short forms possible, and each one can be changed:

  • Default name. A result without a name is named success.

  • Default type. A result without a type uses the result type registered with default="true". In struts-default.xml that is dispatcher; a package can register its own default with <result-type name="..." class="..." default="true"/>, and a child package inherits it from its parent unless it sets another.

  • Default parameter. Each result type declares which parameter receives the element’s text. For dispatcher, freemarker, velocity, redirect, plainText and postback it is location; for redirectAction and chain it is actionName; for stream it is inputName; for xslt it is stylesheetLocation; httpheader has none. So <result type="chain">books</result> and <result type="chain"><param name="actionName">books</param></result> are the same.

Table 3. The result types registered by struts-default.xml (7.4.0) and their default parameter
Type name Class (org.apache.struts2...) Default parameter

dispatcher (default)

result.ServletDispatcherResult

location

redirect

result.ServletRedirectResult

location

redirectAction

result.ServletActionRedirectResult

actionName

chain

result.ActionChainResult

actionName

stream

result.StreamResult

inputName

httpheader

result.HttpHeaderResult

none; every value is set by name

plainText

result.PlainTextResult

location

postback

result.PostbackResult

location

freemarker

views.freemarker.FreemarkerResult

location

Nine types are registered in struts-default.xml; velocity, xslt, json, tiles, jasper, jasperReport7 and chart come from plugins and exist only in the plugin’s own package.

Result Codes

The code an action returns is a plain String. ActionSupport (and the Action interface in org.apache.struts2.action) define five constants, and applications are free to invent more.

Table 4. Predefined result codes (org.apache.struts2.action.Action)
Constant Value Typical meaning

SUCCESS

success

The action did its job; show the normal view. This is also the default result name

INPUT

input

The input was not acceptable; show the form again. The workflow interceptor returns it when the validation interceptor recorded field or action errors (validate() in the Bookshelf add action)

ERROR

error

Something failed; show an error view

LOGIN

login

The caller must authenticate first; usually a global result

NONE

none

Skip result processing: the action already wrote the response. Returning null has the same effect

The book Struts 2 in Action shows @Result(name="SUCCESS") in an annotation example; the constant’s value is lower case success, so that spelling never matches the code the action returns.

Several Results for One Action: the Bookshelf add Action

The add action validates its input in validate() and returns input when the title is empty. One result serves two codes (error, input, a list with a comma, supported since Struts 2.5), and the code that is not named explicitly is success, which redirects. The redirect result is explained in Redirect, Redirect Action and Post/Redirect/Get.

The add action: the form is shown again on error or input, otherwise the browser is redirected
<action name="add" class="org.example.bookshelf.AddBookAction">
    <result name="error, input">/WEB-INF/views/add.jsp</result>
    <result type="redirectAction">
        <param name="actionName">books</param>
        <param name="added">${newId}</param>
        <param name="anchor">book-${newId}</param>
    </result>
</action>
The action class behind it
public class AddBookAction extends ActionSupport {
    private String title;
    private String author;
    private int newId;

    public String getTitle() { return title; }

    @StrutsParameter
    public void setTitle(String title) { this.title = title; }

    public String getAuthor() { return author; }

    @StrutsParameter
    public void setAuthor(String author) { this.author = author; }

    public int getNewId() { return newId; }

    @Override
    public void validate() {
        if (title == null || title.isBlank()) {
            addFieldError("title", "Title is required");
        }
    }

    @Override
    public String execute() {
        newId = BookStore.add(title, author == null ? "" : author).getId();
        return SUCCESS;
    }
}
A POST with a title is answered by a redirect (the new id becomes a parameter and an anchor), a POST without one by the form
$ curl -i -X POST -d 'title=Hyperion&author=Dan+Simmons' http://localhost:18080/g8res/add
HTTP/1.1 302
Location: /g8res/books.action?added=3#book-3

$ curl -i -X POST -d 'title=&author=x' http://localhost:18080/g8res/add
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
...
<span class="errorMessage">Title is required</span>

The * "Other" Result and Duplicate Names

A result named * is selected only when no result with the exact returned name exists. The documentation stresses that it is not a wildcard pattern, only a fallback name, and that an unrecognised result name is usually a programming error that should be fixed rather than hidden. Without any match the request fails.

An action that returns weird, with and without a * result
<action name="weird" class="org.example.bookshelf.WeirdAction">
    <result name="other">/WEB-INF/views/books.jsp</result>
</action>

<action name="weird-star" class="org.example.bookshelf.WeirdAction">
    <result name="other">/WEB-INF/views/books.jsp</result>
    <result name="*" type="httpheader">
        <param name="error">422</param>
    </result>
</action>
$ curl -i http://localhost:18080/g8res/weird
HTTP/1.1 404
...
Message: No result defined for action org.example.bookshelf.WeirdAction and result weird

$ curl -i http://localhost:18080/g8res/weird-star
HTTP/1.1 422

The first request is logged by the dispatcher at WARN ("Could not find action or result") and answered with a 404, because the ConfigurationException is handled like a missing action. The same 404 appears when a result points at a template that does not exist; the container, not Struts, reports it (JSP file [/WEB-INF/views/nope.jsp] not found on Tomcat 11). Two result elements with the same name in one action do not raise an error either: in the run, the later success replaced the earlier one, so a copy-and-paste duplicate silently wins.

Global Results

Results that many actions share (a login redirect, a generic error page) are declared once per package in global-results. The framework looks for a local result in the action first and checks the global results only if no local match exists, so an action can override a global result by declaring one with the same name. Global results of a parent package are inherited by child packages.

A shared login result and the action that uses it (the guard is reduced to a constant here)
<global-results>
    <result name="login" type="redirectAction">login-form</result>
    <result name="error">/WEB-INF/views/error.jsp</result>
</global-results>

<action name="secret" class="org.example.bookshelf.SecretAction"/>
$ curl -i http://localhost:18080/g8res/secret
HTTP/1.1 302
Location: /g8res/login-form.action

SecretAction.execute() returns LOGIN and has no result of its own; the package’s login global result answers. Exception mappings use the same lookup (an exception is converted into a result code), which is why global results and global exception mappings are normally written together; that is the subject of the Exception Handling and Global Results page of this section.

Dynamic Results

The text of a result element and of its parameters may contain ${expression}, which is evaluated against the value stack when the result runs, so the action can compute the destination. In the Bookshelf the add result above builds an anchor (book-${newId}) and a query parameter (added=${newId}) from the id the action generated, and a wizard decides at runtime where to go next.

A result whose target is computed by the action
<action name="wizard" class="org.example.bookshelf.WizardAction">
    <result name="next" type="redirectAction">${nextAction}</result>
</action>
public class WizardAction extends ActionSupport {
    private int step = 1;

    public int getStep() { return step; }

    @StrutsParameter
    public void setStep(int step) { this.step = step; }

    /** Computed from server-side state only, never from a request parameter. */
    public String getNextAction() { return step >= 2 ? "books" : "wizard-done"; }

    @Override
    public String execute() { return "next"; }
}
$ curl -i 'http://localhost:18080/g8res/wizard?step=2'
HTTP/1.1 302
Location: /g8res/books.action

$ curl -i 'http://localhost:18080/g8res/wizard?step=1'
HTTP/1.1 302
Location: /g8res/wizard-done.action

Which expressions are allowed, and why ${nextAction} above is safe while ${target} taken from the request is not, is the subject of Security: Dynamic Values and the Allowlist.

Returning a Result Object

An action method may return a Result instead of a code, which skips the configuration altogether. This is useful when the destination is built in code, and it is also how the functional PlainResult (see The Plain Result) works. The ServletActionRedirectResult constructors take the action name, an optional namespace, method and anchor.

An action method that returns a ready Result; no result element is needed
public class ShortcutAction {
    public Result go() {
        return new ServletActionRedirectResult("books");
    }
}
<action name="shortcut" class="org.example.bookshelf.ShortcutAction" method="go"/>
$ curl -i http://localhost:18080/g8res/shortcut
HTTP/1.1 302
Location: /g8res/books.action

NONE: the Action Wrote the Response Itself

Returning none (or null) skips the result lookup. The action must then have completed the response, typically through the servlet response. Everything the result types below do for you (content type, character set, flushing) becomes the action’s job.

An action that writes directly and returns NONE
public class RawAction extends ActionSupport {
    @Override
    public String execute() throws IOException {
        var response = ServletActionContext.getResponse();
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().write("written by the action; no result runs");
        return NONE;
    }
}
$ curl -i http://localhost:18080/g8res/raw
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8

written by the action; no result runs

For anything beyond a one-liner prefer a result type (stream, plain, json): they keep the action testable and the response handling in one place.

Parameters Every Result Shares

Results built on StrutsResultSupport (all of the built-in ones except chain, httpheader and the Result objects you return) share three parameters, and the redirect family adds more.

Table 5. The StrutsResultSupport parameters (7.4.0 source)
Parameter Default Effect

location

none

The target (a template, a URL, an action path). The default parameter of most types

parse

true

Evaluate ${...} expressions in the location (and, for most types, in the other parameters). false takes the text literally

encode

false

URL-encode the values substituted into the location. Needed when an expression is placed inside a query string

The parse and encode switches matter most for redirect, where an expression sits inside a URL. In the run, the same location produced three different Location headers.

parse and encode on a redirect whose location carries a request value (target=a b&c)
<action name="encode-off" class="org.example.bookshelf.GoAction">
    <result type="redirect">/books?filter=${target}</result>
</action>

<action name="encode-on" class="org.example.bookshelf.GoAction">
    <result type="redirect">
        <param name="location">/books?filter=${target}</param>
        <param name="encode">true</param>
    </result>
</action>

<action name="parse-off" class="org.example.bookshelf.GoAction">
    <result type="redirect">
        <param name="location">/books?filter=${target}</param>
        <param name="parse">false</param>
    </result>
</action>
$ curl -i 'http://localhost:18080/g8res/encode-off?target=a%20b%26c'
Location: /g8res/books?filter=a b&c

$ curl -i 'http://localhost:18080/g8res/encode-on?target=a%20b%26c'
Location: /g8res/books?filter=a+b%26c

$ curl -i 'http://localhost:18080/g8res/parse-off?target=a%20b'
Location: /g8res/books?filter=${target}

Without encode the value is pasted into the URL as it is, so the & in a b&c starts a second parameter. Putting values into the query string as separate param elements (next section) avoids the problem because the framework builds and encodes the query string itself.

The Dispatcher Result: Forward to a View

dispatcher is the default type and the one almost every action ends with. It hands the request to a web resource, usually a JSP, through the servlet RequestDispatcher, so the target sees the same request and response objects and the action is still on the value stack (the JSP’s s:property tags read it). The documentation lists three ways the result can run, and the 7.4.0 source confirms the order of the checks:

  1. If a JSP PageContext is available (the result runs inside a page, for example from the action tag), the page context’s include is called.

  2. Otherwise, if the response is not committed and the request is not already an include, forward is called. A forward lets the page set the content type and status, and it records struts.view_uri and struts.request_uri as request attributes.

  3. In every other case include is called.

Table 6. Parameters of the dispatcher result
Parameter Default Effect

location

none

The resource to dispatch to. Start it with / so it is resolved from the web application root

parse

true

Evaluate ${...} in the location; set false to take it literally

The target of a dispatch may be any resource of the web application, including one under WEB-INF, which a browser can never request directly. That is why the Bookshelf keeps every JSP in /WEB-INF/views/: the URL space belongs to the actions, and a visitor cannot bypass them to reach a view.

The books action: a dispatcher result to a JSP under WEB-INF
<action name="books" class="org.example.bookshelf.BookAction">
    <result>/WEB-INF/views/books.jsp</result>
</action>
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html>
<body>
<h1 id="top">Bookshelf (Struts)</h1>
<s:if test="headline != null"><p><s:property value="headline"/></p></s:if>
<p>Filter: <s:property value="filter"/></p>
<ul>
<s:iterator value="books"><li id="book-<s:property value='id'/>"><s:property value="title"/> by <s:property value="author"/></li></s:iterator>
</ul>
</body>
</html>
$ curl -i http://localhost:18080/g8res/books
HTTP/1.1 200
Content-Type: text/html;charset=UTF-8
...
<h1 id="top">Bookshelf (Struts)</h1>
<p>Filter: </p>
<ul>
<li id="book-1">Dune by Frank Herbert</li><li id="book-2">Neuromancer by William Gibson</li>
</ul>

A dispatch to a template that does not exist is reported by the container, not by Struts: on Tomcat 11 the request answers 404 with JSP file [/WEB-INF/views/nope.jsp] not found, so a mistyped location is found by calling the action, never at startup.

Redirect, Redirect Action and Post/Redirect/Get

A dispatcher result answers the current request. If that request was a POST, the browser’s address bar still shows the POST URL, and pressing reload sends the form again. The remedy, known as Post/Redirect/Get, is to answer a successful POST with a redirect, so the browser issues a GET to a different URL. Struts has two result types for it: redirect sends the browser to a URL, and redirectAction sends it to an action by name, asking the configured ActionMapper for the URL.

sequenceDiagram participant B as Browser participant F as Struts filter participant A as AddBookAction B->>F: POST /add (title, author) F->>A: validate(), execute() A-->>F: success (newId = 3) F-->>B: 302 Location books.action?added=3 at book-3 B->>F: GET /books.action?added=3 F-->>B: 200 books.jsp (a reload only repeats this GET)

A redirect is a new request, so the action instance, its field errors and its messages are gone by the time the next action runs. The redirect result documentation says this plainly: the only ways to pass data are the session or request parameters. The Bookshelf passes the new id as the added parameter. The store interceptor (MessageStoreInterceptor, registered in struts-default.xml but not part of the default stack) exists to carry action messages and errors across a redirect; it belongs to the interceptors page of this section.

The Redirect Result

redirect calls HttpServletResponse.sendRedirect with the location. Besides location, parse and encode, it accepts these parameters; every other parameter in the result element is added to the URL as a query parameter, parsed for ${...} and encoded by the framework.

Table 7. Parameters of the redirect and redirectAction results (7.4.0 source; the documentation tables omit statusCode, encode and method)
Parameter Default Effect

location (redirect)

none

The target URL. A location that does not start with / and is not an absolute URL gets the namespace of the current action prepended (documented example)

actionName (redirectAction)

none

The action to redirect to; the URL is built by the ActionMapper, so it follows the configured extension and mapping

namespace (redirectAction)

current namespace

The namespace of the target action

method (redirectAction)

none

An action method, passed to the mapper (relevant when dynamic method invocation or method-in-URL mapping is in use)

anchor

none

A fragment appended after #; parsed for ${...}

statusCode

302

The status of the redirect, for example 301 or 303. For anything other than 302 Struts sets the status and the Location header itself and writes the escaped URL as the body

prependServletContext

true

Prefix a path-only location with the context path; set false to emit the path as written

suppressEmptyParameters

false

Leave out query parameters whose value is empty

parse / encode

true / false

As in the shared parameters

any other name

none

Becomes a query parameter of the redirect URL

The Bookshelf keeps an old catalogue URL alive with a permanent redirect that carries a filter parameter and an anchor.

A permanent redirect with a query parameter and an anchor
<action name="old-catalogue" class="org.example.bookshelf.OldCatalogueAction">
    <result type="redirect">
        <param name="location">/books</param>
        <param name="filter">${filter}</param>
        <param name="statusCode">301</param>
        <param name="anchor">top</param>
    </result>
</action>
$ curl -i 'http://localhost:18080/g8res/old-catalogue?filter=a%20b%26c%3D1'
HTTP/1.1 301
Location: /g8res/books?filter=a+b%26c%3D1#top
Content-Length: 35

/g8res/books?filter=a+b%26c%3D1#top

Note how the value a b&c=1 arrives encoded: a value placed in its own param is encoded by the framework, while a value pasted into the location text is not (the encode example above). Two more observations from the run. With prependServletContext set to false the Location is /books without /g8res, which is right only for an application deployed at the root. And with parse set to false a value such as ${target} is sent literally to the browser.

The Redirect Action Result

redirectAction is the form to use between actions: the destination is named by action and namespace, and the URL is generated by the ActionMapper, so changing the extension (struts.action.extension) or a mapping rule does not break the redirect. The documentation recommends it over redirect whenever the target is an action. In the default 7.4.0 configuration the generated URLs carry the first configured extension: books.action.

The Post/Redirect/Get result of the add action again, in the namespace form
<result type="redirectAction">
    <param name="actionName">books</param>
    <param name="namespace">/</param>
    <param name="added">${newId}</param>
    <param name="anchor">book-${newId}</param>
</result>

Empty values are sent as name= unless suppressEmptyParameters is true. In the run, an unset filter produced books.action?filter= in one action and plain books.action in the other.

suppressEmptyParameters on a redirectAction
<action name="empty-keep" class="org.example.bookshelf.GoAction">
    <result type="redirectAction">
        <param name="actionName">books</param>
        <param name="filter">${target}</param>
    </result>
</action>

<action name="empty-params" class="org.example.bookshelf.GoAction">
    <result type="redirectAction">
        <param name="actionName">books</param>
        <param name="filter">${target}</param>
        <param name="suppressEmptyParameters">true</param>
    </result>
</action>
$ curl -i http://localhost:18080/g8res/empty-keep
Location: /g8res/books.action?filter=

$ curl -i http://localhost:18080/g8res/empty-params
Location: /g8res/books.action

$ curl -i 'http://localhost:18080/g8res/empty-params?target=x'
Location: /g8res/books.action?filter=x

The REST plugin registers its own redirect and redirectAction types in its rest-default package with statusCode set to 303 (read from the plugin’s struts-plugin.xml), which is the status the REST conventions use after a POST, PUT or DELETE; that plugin is covered by the JSON Plugin and Ajax page of this section.

The Chain Result

The chain result runs an entire other action, with its own interceptor stack and result, inside the current request. The browser sees one request and one URL; unlike a redirect, the first action remains on the value stack, and the chain interceptor (part of the default stack) copies the properties of the earlier action to the later one. Its parameters are actionName (the default parameter), namespace, method and skipActions (a comma-separated list of action names that must not be chained to).

The featured action chains to books, and books.jsp reads the headline property of the first action
<action name="featured" class="org.example.bookshelf.FeaturedAction">
    <result type="chain">books</result>
</action>
public class FeaturedAction extends ActionSupport {
    public String getHeadline() {
        return "Featured: " + BookStore.all().get(0).getTitle();
    }
}
$ curl -s http://localhost:18080/g8res/featured
<!DOCTYPE html>
<html>
<body>
<h1 id="top">Bookshelf (Struts)</h1>
<p>Featured: Dune</p>
<p>Filter: </p>
<ul>
<li id="book-1">Dune by Frank Herbert</li><li id="book-2">Neuromancer by William Gibson</li>

The framework’s own guidance is cautious: the Action Chaining page advises against chaining as a rule and names the Redirect After Post technique as the first alternative to try. Chained actions are hard to follow, couple one action to another’s properties, and run a second interceptor stack on the same request. ActionChainResult keeps a chain history in the action context, and a chain that comes back to an action already in it fails: two actions chaining to each other answered 500 with Infinite recursion detected: [//loop-a!execute, //loop-b, //loop-a, //loop-b] (a StrutsException). The detailed behaviour of the chain interceptor, and the session and scope alternatives, belong to the action-chaining page of this section; here the rule of thumb is to chain only for read-only lookups that need no fresh request, and to redirect for everything that changes state.

The Stream Result: Downloads and Generated Files

stream copies an InputStream from the action straight to the response. It is the result for file downloads, generated images, PDF documents and CSV exports, because nothing is rendered: the bytes go out as they are, with the headers the configuration sets. The Bookshelf export is a good example because every parameter has a visible effect.

Table 8. Parameters of the stream result (documentation and 7.4.0 source)
Parameter Default Effect

inputName

inputStream

Name of the action property (a getter returning an InputStream) to stream. The stream is closed when the result has finished

contentType

text/plain

The MIME type sent to the browser

contentLength

none

Length in bytes; lets the browser show progress. A value that is not a number is logged and the header is left out

contentDisposition

inline

The Content-Disposition value; use attachment;filename="name.ext" to force a download

contentCharSet

none

If set, ;charset=value is added to the content type; if not set, the response character encoding is reset to null

bufferSize

1024

Size of the copy buffer

allowCaching

true

false adds Pragma: no-cache and Cache-Control: no-cache

Since Struts 2.5.2 a parameter takes its value from the action only if the configuration says so with an expression: to read contentType from getContentType(), write <param name="contentType">${contentType}</param>. Before 2.5.2 each parameter was resolved automatically from the value stack, including inputName; a configuration migrated from an older version that relied on that behaviour streams nothing or the wrong thing.

The CSV export: every header comes from the result configuration or from a getter of the action
<action name="export" class="org.example.bookshelf.ExportAction">
    <result type="stream">
        <param name="contentType">text/csv</param>
        <param name="contentCharSet">UTF-8</param>
        <param name="inputName">csvStream</param>
        <param name="contentLength">${csvLength}</param>
        <param name="contentDisposition">attachment;filename="${fileName}"</param>
        <param name="allowCaching">false</param>
    </result>
</action>
public class ExportAction extends ActionSupport {
    private byte[] csv;

    @Override
    public String execute() {
        StringBuilder sb = new StringBuilder("id,title,author\r\n");
        for (Book b : BookStore.all()) {
            sb.append(b.getId()).append(',').append(cell(b.getTitle())).append(',').append(cell(b.getAuthor())).append("\r\n");
        }
        csv = sb.toString().getBytes(StandardCharsets.UTF_8);
        return SUCCESS;
    }

    /** Quotes a cell and defuses spreadsheet formulas. */
    private static String cell(String value) {
        String safe = value.matches("^[=+\\-@\\t\\r].*") ? "'" + value : value;
        return '"' + safe.replace("\"", "\"\"") + '"';
    }

    public InputStream getCsvStream() { return new ByteArrayInputStream(csv); }

    public long getCsvLength() { return csv.length; }

    public String getFileName() { return "bookshelf-books.csv"; }
}
$ curl -i http://localhost:18080/g8res/export
HTTP/1.1 200
Content-Disposition: attachment;filename="bookshelf-books.csv"
Pragma: no-cache
Cache-Control: no-cache
Content-Type: text/csv;charset=UTF-8
Content-Length: 105

id,title,author
1,"Dune","Frank Herbert"
2,"Neuromancer","William Gibson"
3,"Hyperion","Dan Simmons"

Three details of the example are worth stating. The getter returns a new stream on each call; the result asks the action for the stream once, but returning a fresh object keeps the action reusable if it is ever called twice. The cell method defuses CSV injection: a value that starts with =, +, -, @, a tab or a carriage return is prefixed with an apostrophe because a spreadsheet would otherwise evaluate it as a formula. And the file name comes from the server, never from the request; user-controlled text in Content-Disposition or in the path of the stream is a header-injection and path-traversal risk (the File Upload and Download page of this section develops a safe download).

If the property named by inputName is missing or returns null, the request fails with an IllegalArgumentException whose message names the parameter and hints at the cause. In the run, a result with inputName set to nope answered 500 with Can not find a java.io.InputStream with the name [nope] in the invocation stack. Check the <param name="inputName"> tag specified for this action is correct, not excluded and accepted.. The words "excluded and accepted" refer to the allowlist and the accepted-patterns checks described in Security: Dynamic Values and the Allowlist: an inputName that is itself an expression is evaluated only if it passes them.

The HttpHeader Result: Status Codes and Headers Without a Body

httpheader sets the status line, headers and, optionally, an error on the response and writes no body. It suits endpoints that answer with a status (204 after a delete, 304, 405), and actions that must signal an error to the container so that the configured error-page renders. The parameters are status, headers (a map), error, errorMessage and parse (default true: values are evaluated against the value stack).

A 204 with two headers (one computed), and a 404 with a message
<action name="ping" class="org.example.bookshelf.PingAction">
    <result type="httpheader">
        <param name="status">204</param>
        <param name="headers['X-Book-Count']">${bookCount}</param>
        <param name="headers.XShelf">main</param>
    </result>
</action>

<action name="missing" class="org.example.bookshelf.PingAction" method="missing">
    <result name="notFound" type="httpheader">
        <param name="error">404</param>
        <param name="errorMessage">no such book</param>
    </result>
</action>
$ curl -i http://localhost:18080/g8res/ping
HTTP/1.1 204
X-Book-Count: 3
XShelf: main

$ curl -i http://localhost:18080/g8res/missing
HTTP/1.1 404
Content-Type: text/html;charset=utf-8
...
Message: no such book

A header name with a hyphen cannot be written in the dotted form: the documentation’s headers.a works, but in the run headers.X-Book-Count set no header at all (the name is read as an expression), while the indexed form headers['X-Book-Count'] worked. The two ways to signal a problem differ. status calls setStatus and leaves the response open, so nothing else is rendered. error calls sendError, which hands control to the container: its error page, or the default one that shows the message, takes over. The second form is the one to use for a 404 or 403 that should look like the rest of the site.

The PlainText Result

plainText sends the content of a web resource as text/plain, without interpreting it. The documentation describes it as useful for showing the raw content of a JSP or an HTML file. The resource is read through the servlet context, so anything the application can read, including files under WEB-INF, can be served. Parameters: location (the default) and charSet.

Showing an HTML snippet as text
<action name="terms" class="org.example.bookshelf.TermsAction">
    <result type="plainText">
        <param name="location">/WEB-INF/views/terms.html</param>
        <param name="charSet">UTF-8</param>
    </result>
</action>
$ curl -i http://localhost:18080/g8res/terms
HTTP/1.1 200
Content-Disposition: inline
Content-Type: text/plain;charset=UTF-8

<h2>Terms</h2>
<p>Lending period: 21 days. Café &amp; reading room open daily.</p>

Without charSet the content type is a bare text/plain, and in the run the container then answered text/plain;charset=ISO-8859-1, so a UTF-8 file with accented characters is garbled; always set charSet. The result is meant for diagnostics. Pointing it at a JSP, a configuration file or a template with a location that comes from the request would publish source code and secrets; the location must be a constant.

The Plain Result: Writing the Response from Java

Since Struts 6.0.0 an action method may return a PlainResult, a functional interface with one method that receives a ResponseBuilder. No result element is needed. The builder collects the body, headers and cookies; the default content type is text/plain; charset=UTF-8, and helper methods switch it to HTML or JSON. The result refuses to write when the response is already committed (a StrutsException) unless the implementation overrides ignoreCommitted().

A health check written with PlainResult
public class HealthAction {
    public PlainResult execute() {
        Object ready = ServletActionContext.getServletContext().getAttribute("dispatcherReady");
        return response -> response
                .withContentTypeJson()
                .withHeader("Cache-Control", "no-store")
                .write("{\"status\":\"UP\",\"books\":" + BookStore.all().size() + ",\"dispatcherReady\":" + ready + "}");
    }
}
<action name="health" class="org.example.bookshelf.HealthAction"/>
$ curl -i http://localhost:18080/g8res/health
HTTP/1.1 200
Cache-Control: no-store
Content-Type: application/json;charset=UTF-8

{"status":"UP","books":3,"dispatcherReady":true}

write and writeLine append the string exactly as given (the 7.4.0 BodyWriter is a plain StringWriter), so a PlainResult that writes HTML or JSON built from user input must escape it itself; for JSON use the json result, which serialises objects correctly. The action class here is a POJO: it does not extend ActionSupport, which would also work, but execute() could not then change its return type.

The Postback Result

postback answers with a small HTML page whose form carries the current request’s parameters as hidden fields and submits itself to another destination at once. It exists to turn the current request into a POST to another action or URL, for example to continue a flow with a third party. Parameters: location (default; a URL), or actionName, namespace and method (resolved as expressions) for an action, plus prependServletContext (default true) and cache (default true; false adds no-cache headers).

Re-posting the parameters of the request to the receive action
<action name="partner" class="org.example.bookshelf.PartnerAction">
    <result type="postback">
        <param name="actionName">receive</param>
    </result>
</action>

<action name="receive" class="org.example.bookshelf.ReceiveAction"/>
public class ReceiveAction {
    private String title;

    @StrutsParameter
    public void setTitle(String title) { this.title = title; }

    public PlainResult execute() {
        return response -> response.write("received title=" + title);
    }
}
$ curl -i 'http://localhost:18080/g8res/partner?title=Dune%20%26%20Co'
HTTP/1.1 200
Content-Type: text/html;charset=ISO-8859-1

<!DOCTYPE html><html><body><form action="/g8res/receive.action" method="POST"><input type="hidden" name="title" value="Dune+%26+Co"/><script>setTimeout(function(){document.forms[0].submit();},0);</script></html>

$ curl -s -X POST --data-urlencode 'title=Dune+%26+Co' http://localhost:18080/g8res/receive
received title=Dune+%26+Co

The run exposed a quirk worth knowing before relying on this result. The hidden field’s value is URL-encoded by the result (Dune & Co became Dune+%26+Co) and a browser submits that text as it is, so the receiving action got the encoded string, and a value with an accent arrives as Caf%C3%A9. The page is also sent without a character set (the container answered ISO-8859-1). The destination must decode the value itself, or the parameters sent through a postback should be restricted to ASCII identifiers. The result also skips parameters whose name starts with action:, and the form submits through a script, so a page served with a strict content security policy needs the script allowed.

Template Results: FreeMarker and Velocity

Two result types render a template instead of dispatching to a JSP. freemarker is part of struts-default.xml (the FreeMarker library is a dependency of struts2-core), velocity comes from the Velocity plugin. Both take location as the default parameter and both expose the action and the value stack to the template, so ${book.title} reads the property of the action. FreeMarker templates load from the web application root (/WEB-INF/views/home.ftl) or from the classpath; the freemarker result also accepts contentType (default text/html), parse and writeIfCompleted (default false: write to the response only if the template completed without an error; template_exception_handler=rethrow in freemarker.properties has the same effect). The FreeMarker and Velocity Views page of this section covers the template language and the tag libraries; here is only what the result type itself does, using one Bookshelf fragment in both engines.

The same action rendered through freemarker and through velocity
<action name="book-card" class="org.example.bookshelf.BookCardAction">
    <result type="freemarker">/WEB-INF/views/book-card.ftl</result>
</action>

<action name="book-card-vm" class="org.example.bookshelf.BookCardAction">
    <result type="velocity">/WEB-INF/views/book-card.vm</result>
</action>
<article>
  <h2>${book.title}</h2>
  <p>by ${book.author} (id ${book.id?c})</p>
</article>
<article>
  <h2>$book.title</h2>
  <p>by $book.author (id $book.id)</p>
</article>
Both are called for an id that does not exist, where the action returns a book titled Unknown <script>
$ curl -s 'http://localhost:18080/g8res/book-card?id=99'
<article>
  <h2>Unknown &lt;script&gt;</h2>
  <p>by n/a (id 0)</p>
</article>

$ curl -s 'http://localhost:18080/g8res/book-card-vm?id=99'
<article>
  <h2>Unknown <script></h2>
  <p>by n/a (id 0)</p>
</article>

The difference between the two outputs is the most important fact about these result types. In the run, FreeMarker escaped the title (HTML auto-escaping is on in 7.4.0) while Velocity printed it as it was, so the Velocity template is a cross-site scripting hole as soon as the value contains user input; escape each reference with a Velocity escape tool or use the Struts tags. The FreeMarker side has a related trap: the old ${title?html} built-in is rejected under auto-escaping with a ParseException that says legacy ?html escaping is not allowed when auto-escaping is on, so templates written for Struts 2.3 or 2.5 fail at the first request. Drop the built-in and let the engine escape.

Plugin Results

Every plugin that adds a result type registers it in its own package, and an application package uses the type only if it extends that package or registers the type itself. The table below was read from the struts-plugin.xml of each 7.4.0 plugin jar.

Table 9. Result types contributed by the plugins (7.4.0 struts-plugin.xml files)
Plugin Package to extend Result type and class Notes

JSON

json-default

json (org.apache.struts2.json.JSONResult) and jsonActionRedirect (JSONActionRedirectResult)

Serialises an action or part of it to JSON; the JSON and AJAX page of this section covers the interceptor and REST

Velocity

velocity-default

velocity (org.apache.struts2.views.velocity.result.VelocityResult)

Templates (.vm)

XSLT

xslt-default

xslt (org.apache.struts2.result.xslt.XSLTResult)

Transforms the action, serialised to XML, with a stylesheet

Tiles

tiles-default

tiles (org.apache.struts2.views.tiles.TilesResult)

Page layouts; see the Tiles notes below

JasperReports

jasperreports-default

jasper (org.apache.struts2.views.jasperreports.JasperReportsResult)

PDF, CSV and other report formats with JasperReports 6.x

JasperReports 7

jasperreports7-default

jasperReport7 (org.apache.struts2.views.jasperreports7.JasperReport7Result)

JasperReports 7.x, since Struts 7.1.0; independent of the 6.x plugin

JFreeChart

jfreechart-default

chart (org.apache.struts2.dispatcher.ChartResult)

Streams a generated chart image; the plugin’s width/height default to 200 by 150

REST

rest-default

overrides redirect and redirectAction with statusCode 303

No new type

The json Result

The JSON plugin serialises the action (or one property of it, chosen with root) and writes it with the content type application/json. includeProperties and excludeProperties take comma-separated regular expressions; excludes win over includes. Besides those, the documented parameters include wrapPrefix/wrapSuffix, noCache, statusCode, errorCode, excludeNullProperties, enableGZIP, callbackParameter (JSONP), contentType, encoding, enumAsBean and ignoreHierarchy. Since Struts 7.3.0 Java records serialise by their components, Optional values serialise as the contained value or null.

The catalogue as JSON: the root is the books list and only id and title are included
<action name="catalogue" class="org.example.bookshelf.CatalogueAction">
    <result type="json">
        <param name="root">books</param>
        <param name="includeProperties">^\[\d+\]\.id,^\[\d+\]\.title</param>
    </result>
</action>
$ curl -i http://localhost:18080/g8res/catalogue
HTTP/1.1 200
Content-Type: application/json;charset=UTF-8
Content-Length: 56

[{"id":1,"title":"Dune"},{"id":2,"title":"Neuromancer"}]

Two points of care. Without root and a property list the whole action is serialised, including everything public the action exposes (the locale, error maps and any getter), so name what goes out. And the JSON plugin has had advisories of its own (three CVE records, CVE-2026-73631, CVE-2026-73632 and CVE-2026-73633, were published in August 2026 for shared parsing and serialisation state and an unbounded request-body read); keep it on the latest 7.4.x and read the Security Bulletins page of this section.

The xslt Result

The XSLT result serialises the action to XML and runs a stylesheet over it. The stylesheet’s root template must match result, and the default parameter is stylesheetLocation. The serialisation walks every property the action exposes and there is no parameter to prune it: the matchingPattern and excludingPattern parameters that the official page documents have no setter in the 7.4.0 XSLTResult and are silently ignored (verified, see the Other Plugins page), so the stylesheet itself selects what it needs. The constant struts.xslt.nocache disables stylesheet caching in development. To see what the stylesheet receives, run an identity transform first.

The XML the XSLT result builds for CatalogueAction (abbreviated: the locale element has about a dozen children)
<result>
    <actionErrors/>
    <actionMessages/>
    <books>
        <item><author>Frank Herbert</author><id>1</id><title>Dune</title></item>
        <item><author>William Gibson</author><id>2</id><title>Neuromancer</title></item>
    </books>
    <fieldErrors/>
    <locale>...</locale>
    <texts>null</texts>
</result>
A stylesheet that selects the books branch, and the result that runs it
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
  <xsl:output method="xml" indent="yes"/>
  <xsl:template match="result">
    <catalogue>
      <xsl:for-each select="books/item">
        <entry id="{id}"><xsl:value-of select="title"/></entry>
      </xsl:for-each>
    </catalogue>
  </xsl:template>
</xsl:stylesheet>
<action name="catalogue-xml" class="org.example.bookshelf.CatalogueAction">
    <result type="xslt">
        <param name="stylesheetLocation">/WEB-INF/views/catalogue.xsl</param>
    </result>
</action>
$ curl -i http://localhost:18080/g8res/catalogue-xml
HTTP/1.1 200
Content-Type: text/xml;charset=UTF-8

<?xml version="1.0" encoding="UTF-8"?><catalogue>
    <entry id="1">Dune</entry>
    <entry id="2">Neuromancer</entry>
</catalogue>

The location parameter of this result was dropped in Struts 2.5; use stylesheetLocation. A list property serialises as item elements, not as an element per class name.

The tiles Result

The Tiles plugin renders a named definition from tiles.xml (the plugin loads every file matching tiles*.xml from the classpath or WEB-INF). It needs a listener in web.xml, org.apache.struts2.tiles.StrutsTilesListener, and a package that extends tiles-default.

The listener, the definitions and the action
<listener>
    <listener-class>org.apache.struts2.tiles.StrutsTilesListener</listener-class>
</listener>
<tiles-definitions>
    <definition name="bookshelfLayout" template="/WEB-INF/tiles/layout.jsp">
        <put-attribute name="title" value="Bookshelf"/>
        <put-attribute name="header" value="/WEB-INF/tiles/header.jsp"/>
    </definition>
    <definition name="homePage" extends="bookshelfLayout">
        <put-attribute name="body" value="/WEB-INF/tiles/body.jsp"/>
    </definition>
</tiles-definitions>
<action name="home" class="org.example.bookshelf.TilesHomeAction">
    <result type="tiles">homePage</result>
</action>
$ curl -s http://localhost:18080/g8res/home
<!DOCTYPE html>
<html>
<head><title>Bookshelf</title></head>
<body>
<header><p>Bookshelf header</p>
</header>
<main><p>Welcome to the Bookshelf</p>
</main>
</body>
</html>

Apache Tiles is a retired project, and the plugin shows the signs. With only struts2-tiles-plugin on the classpath (and the dependencies it resolves), the listener failed at startup in the run with java.lang.NoClassDefFoundError: org/apache/commons/logging/LogFactory, because the plugin’s commons-digester3 pulls in commons-beanutils 1.8.3 without Commons Logging. The plugin jar contains the Tiles classes itself (org.apache.tiles.api and org.apache.tiles.core packages), so there is no separate Tiles dependency to manage. Adding commons-beanutils 1.11.0 (the current release) fixed the startup error, since that version brings commons-logging, and the page rendered as shown; it also replaces an old BeanUtils 1.8.3 that dependency scanners flag because CVE-2014-0114 names versions up to 1.9.2. Whether that CVE is reachable from a Tiles definition was not examined. In 7.4.0 the legacy OGNL: expression evaluator in tiles.xml is disabled by default (struts.tiles.ognl.legacy.enabled=false, visible in the plugin’s struts-plugin.xml); use the S2: prefix to read a value-stack property. Tiles and its alternatives (a JSP tag-file layout, FreeMarker macros, SiteMesh) are compared in the Tiles and Page Layouts page of this section.

Report and Chart Results

The jasper and jasperReport7 results turn an action into a PDF, CSV or other report from a compiled .jasper file. Their common parameters are location (the compiled report), dataSource (an expression for a list on the value stack), format (PDF when none is given), contentDisposition, documentName, reportParameters, exportParameters, parse, and connection for a JDBC connection. Since 7.4.0 dataSource is optional: when neither it nor connection is set the report is filled from report parameters alone, which allows non-JDBC query executors to be driven through reportParameters. The JasperReports 7 plugin no longer brings the jasperreports library transitively in 7.4.0; the application declares it (and jasperreports-pdf for PDF). The JFreeChart plugin’s chart result streams a JFreeChart property of the action as an image, with width and height parameters. These three need libraries and compiled report files that are outside the scratch application, so no examples of them are shown here; the configuration is the same result element as for any other type, and the plugin pages give the full parameter lists and examples. The PDF and report-writing topics have their own pages in this documentation set; the Struts side of JasperReports is on Other Plugins.

Writing a Custom Result

A result type is any class that implements org.apache.struts2.result.Result. Extending StrutsResultSupport is almost always easier, because the base class provides the location property, the parse and encode switches and the evaluation of ${...}; the subclass implements only doExecute(String finalLocation, ActionInvocation invocation). The framework creates the result for each request, sets the param values through setters and injects its own beans into @Inject methods. Registering takes one line in a package’s result-types, after which the type name is available to every action of that package and of the packages that extend it.

A label result that writes its location, with the expressions already evaluated, as one line of text
public class LabelResult extends StrutsResultSupport {
    @Override
    protected void doExecute(String finalLocation, ActionInvocation invocation) throws IOException {
        var response = invocation.getInvocationContext().getServletResponse();
        response.setContentType("text/plain;charset=UTF-8");
        response.getWriter().write(finalLocation);
    }
}
<result-types>
    <result-type name="label" class="org.example.bookshelf.LabelResult"/>
</result-types>

<action name="label" class="org.example.bookshelf.BookCardAction">
    <result type="label">${book.title} -- ${book.author}</result>
</action>
$ curl -s 'http://localhost:18080/g8res/label?id=2'
Neuromancer -- William Gibson

The first run of this example printed ` — ` and nothing else. The cause is in the next section: the Book class was not on the OGNL allowlist, so book.title evaluated to nothing and the log showed a warning. A custom result is also the place to put a response format the built-in types do not offer (a vCard, an image with a computed watermark), but a plain action method returning a PlainResult is the shorter route when the output is a string.

Limiting the Parameters a Result Accepts

A result takes every param of its element, including ones a careless configuration supplied. A result type that should accept only some of them implements ParamNameAwareResult; the StrutsResultBuilder asks acceptableParameterName(name, value) before setting each parameter and skips the ones for which it answers false. The documentation’s example spells the method acceptableParamName, which is not the name in 7.4.0 (see the errata below): an implementation with that name does not satisfy the interface.

A result type that accepts only location
public class RestrictedLabelResult extends LabelResult implements ParamNameAwareResult {
    @Override
    public boolean acceptableParameterName(String name, String value) {
        return "location".equals(name);
    }
}
<action name="restricted" class="org.example.bookshelf.BookCardAction">
    <result type="restrictedLabel">
        <param name="location">${book.title}</param>
        <param name="parse">false</param>
    </result>
</action>

<action name="unrestricted" class="org.example.bookshelf.BookCardAction">
    <result type="label">
        <param name="location">${book.title}</param>
        <param name="parse">false</param>
    </result>
</action>
$ curl -s 'http://localhost:18080/g8res/restricted?id=1'
Dune

$ curl -s 'http://localhost:18080/g8res/unrestricted?id=1'
${book.title}

The restricted type ignored parse=false and so evaluated the expression; the plain type honoured it. The mechanism is the tool for result types whose parameters might ever come from an untrusted source.

Listeners Around the Result

Two small interfaces let code observe or influence the result phase without being a result.

A PreResultListener runs after the action (and the interceptors above it) has produced a result code and before the result is looked up. An action or an interceptor registers it on the ActionInvocation; beforeResult(invocation, resultCode) may inspect or modify the response, the action or even the result code. The documentation shows the registration from an action and from an interceptor (AbstractInterceptor.intercept); the Custom Interceptors page of this section builds on the second form.

An action that registers a PreResultListener which adds a header carrying the result code
public class ListenerAction extends ActionSupport {
    @Override
    public String execute() {
        ActionContext.getContext().getActionInvocation().addPreResultListener((invocation, resultCode) ->
                invocation.getInvocationContext().getServletResponse().setHeader("X-Result-Code", resultCode));
        return SUCCESS;
    }
}
$ curl -i http://localhost:18080/g8res/listener
HTTP/1.1 200
X-Result-Code: success
Content-Disposition: inline
Content-Type: text/plain;charset=ISO-8859-1

A DispatcherListener is called when the Dispatcher (the framework’s central object, created by the filter) is initialised and when it is destroyed. It is registered statically with Dispatcher.addDispatcherListener, so the registration must happen before the filter initialises: a ServletContextListener runs before filters, and the example sets a servlet context attribute that the health action above reports.

A listener that registers a DispatcherListener before the Struts filter starts
@WebListener
public class DispatcherReadyListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        var context = sce.getServletContext();
        Dispatcher.addDispatcherListener(new DispatcherListener() {
            @Override
            public void dispatcherInitialized(Dispatcher dispatcher) {
                context.setAttribute("dispatcherReady", Boolean.TRUE);
            }

            @Override
            public void dispatcherDestroyed(Dispatcher dispatcher) {
                context.removeAttribute("dispatcherReady");
            }
        });
    }
}

The health response shown earlier ("dispatcherReady":true) is the proof that the callback ran.

Security: Dynamic Values and the Allowlist

Results are where request data meets the response, and three rules keep that meeting safe.

Expressions in results are evaluated, so their inputs must be trusted. A ${...} in a result is evaluated against the value stack, the same mechanism that powers tags. The expressions in the examples above refer to properties the action computes (${newId}, ${nextAction}, ${fileName}). A property filled from a request parameter and then used as the whole target of a redirect is an open redirect: the visitor chooses where the browser goes. The run shows both forms.

A redirect target taken from the request, and the allowlist version
<action name="unsafe-go" class="org.example.bookshelf.GoAction">
    <result type="redirect">${target}</result>
</action>

<action name="go" class="org.example.bookshelf.GoAction">
    <result type="redirect">${destination}</result>
</action>
public class GoAction extends ActionSupport {
    private static final Map<String, String> DESTINATIONS = Map.of(
            "catalogue", "/books",
            "export", "/export");

    private String target;

    public String getTarget() { return target; }

    @StrutsParameter
    public void setTarget(String target) { this.target = target; }

    /** Allow-list: the request chooses a key, never a URL. */
    public String getDestination() { return DESTINATIONS.getOrDefault(target, "/books"); }
}
$ curl -i 'http://localhost:18080/g8res/unsafe-go?target=https://evil.example/x'
HTTP/1.1 302
Location: https://evil.example/x

$ curl -i 'http://localhost:18080/g8res/go?target=https://evil.example/'
HTTP/1.1 302
Location: /g8res/books

$ curl -i 'http://localhost:18080/g8res/go?target=export'
HTTP/1.1 302
Location: /g8res/export

Only parameters the action opted into can be set, and only allowlisted classes can be reached. Struts 7 enables both struts.parameters.requireAnnotations (a request parameter may set only a property whose setter or getter carries @StrutsParameter) and struts.allowlist.enable (OGNL expressions may reach only the minimal set of classes the application needs); both are new defaults of 7.0. The 6.x to 7.x migration guide says most applications need no configuration, but those that use POJOs or DTOs for parameter injection and template rendering must allow their classes or packages with struts.allowlist.packageNames or struts.allowlist.classes. The Bookshelf Book class was not allowlisted until the constant in the shared package named its package, and the log said so:

The warning the unlisted class produced (log4j2 at WARN for org.apache.struts2)
WARN SecurityMemberAccess - Declaring class [class org.example.bookshelf.Book] of member type [public java.lang.String org.example.bookshelf.Book.getTitle()] is not allowlisted! Add to 'struts.allowlist.classes' or 'struts.allowlist.packageNames' configuration.

The allowlist does not apply to FreeMarker, which reads the property through its own object wrapper: in the run the same book.title printed in book-card.ftl while the OGNL expression in the label result printed nothing. That asymmetry is why a result that works in one engine can fail in another.

The stream result evaluates inputName only when it passes the accepted-patterns check, and a configuration that takes the name from a request value would let a visitor choose any stream property; keep inputName, like location, a constant. The same constants (struts.excludedClasses, struts.excludedPackageNames, and the accepted patterns) that protect parameters protect these expressions; the Security Hardening page of this section lists them.

Choosing a Result Type

Table 10. Which result type fits which response
The response is Use Remember

An HTML page rendered from the action’s data

dispatcher with a JSP under WEB-INF

Views are not URLs; the action is the entry point

The answer to a successful POST

redirectAction (Post/Redirect/Get)

The action instance is gone; pass an id, not an object

A move of a URL

redirect with statusCode 301 or 308

Browsers cache permanent redirects

A file, image or CSV

stream with attachment

Set contentType and contentCharSet; take the file name from the server

A status code only

httpheader with status or error

error hands over to the container’s error page

A string written from Java

PlainResult

Escape anything from users yourself

JSON for a script

json (or PlainResult for a fixed string)

List includeProperties

Another action’s output without a new request

chain

Rarely the right answer

A page with a shared layout

tiles, or a JSP tag-file layout

Tiles is retired; consider the alternatives

A template instead of JSP

freemarker (core) or velocity (plugin)

Velocity does not escape by default

The current parameters sent as a POST elsewhere

postback

Values are URL-encoded in the form

A report or a chart

jasper, jasperReport7, chart

Plugin libraries and compiled reports

Pitfalls and Error Names

Table 11. Result problems, their causes and the fix (rows marked "observed" were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Symptom or error Cause Fix

404 No result defined for action ... and result weird (observed)

The action returned a code with no result and no * result

Add the result, return a known code, or add a deliberate * that answers with a status

404 JSP file [...] not found (observed)

The location of a dispatcher result is mistyped or the file was not deployed

Start locations with /; call every action once in a smoke test

There is no result type defined for type 'json' mapped with name 'success' and the whole application fails to start (observed)

The package does not extend json-default (or the other plugin package) or register the type

Add the plugin package to extends, or register the type with result-type

Two results with the same name, only the last one runs (observed)

A duplicated result element silently replaces the earlier one

One result per name; use name="a, b" to share

A result with a * name hides programming errors

Every unknown code lands on the fallback

Use * only to answer with a clear status such as 422, and log it

Page reloaded and the form is submitted again

A POST was answered by a dispatcher result

Answer with redirectAction (Post/Redirect/Get)

Messages and field errors vanish after a redirect

A redirect is a new request; the action instance is gone

Pass an id or a code as a parameter, or use the store interceptor

Location lacks the context path (observed)

prependServletContext is false

Leave it true unless the application is deployed at the root

a b&c splits into two parameters (observed)

A value pasted into the location was not encoded

Use separate param elements, or encode true

${target} sent literally to the browser (observed)

parse is false

Leave parse on unless the text must stay literal

Open redirect to another site (observed)

A request value became the whole target

Map a key to a fixed destination (allowlist)

Infinite recursion detected (observed)

Two actions chain to each other

Redirect instead of chaining, or break the cycle

500 Can not find a java.io.InputStream with the name [x] (observed)

inputName names a missing property, or the expression is not accepted

Match inputName to a getter returning an InputStream; keep it a constant

Download opens in the browser instead of saving

contentDisposition is inline (the default)

attachment;filename="..."

Garbled accented characters in a plain-text result (observed)

No charSet; the container fell back to ISO-8859-1

Set charSet (or contentCharSet) to UTF-8

A hyphenated header is missing (observed)

headers.X-Book-Count is read as an expression

Use headers['X-Book-Count']

ParseException ... legacy ?html escaping is not allowed (observed)

A template written for FreeMarker in Struts 2.3 uses ?html under auto-escaping

Remove the built-in

Velocity page prints raw HTML from a value (observed)

Velocity does not escape

Escape in the template or use FreeMarker or the Struts tags

Result prints an empty value in OGNL but not in FreeMarker (observed)

The class is not allowlisted

Add the package to struts.allowlist.packageNames

Tiles startup NoClassDefFoundError: org/apache/commons/logging/LogFactory (observed)

commons-digester3 brings commons-beanutils 1.8.3 without Commons Logging

Add commons-beanutils 1.11.0 (or commons-logging)

@Override on acceptableParamName does not compile

The interface method is acceptableParameterName in 7.4.0

Use the name in the Javadoc of the 7.4.0 jar

Postback target receives Dune+%26+Co (observed)

The result URL-encodes the hidden field values

Decode in the target, or send only simple values

Struts 2.x code imports com.opensymphony.xwork2.Result

The package moved to org.apache.struts2.result in 7.0

Search and replace as the migration guide says

What Changed Recently

Table 12. Result-related changes by version
Version Change

2.5

A result may carry several names (name="error, input"); the xslt result drops its deprecated location parameter in favour of stylesheetLocation

2.5.2

The stream result no longer resolves each parameter automatically from the value stack; write ${name} for the ones the action supplies

6.0.0

PlainResult, the programmatic result with a ResponseBuilder

7.0.0

com.opensymphony.xwork2.* moved to org.apache.struts2.*: the Result interface is now org.apache.struts2.result.Result, ActionChainResult lives in org.apache.struts2.result; the OGNL allowlist and @StrutsParameter become the defaults

7.1.0

The JasperReports 7 plugin and its jasperReport7 result

7.3.0

The JSON result serialises Java records and Optional; custom struts.json.writer/reader beans are honoured again

7.4.0

jasper can be filled from report parameters (no dataSource needed), report parameters resolve from the value stack again, the JasperReports 7 plugin no longer brings JasperReports transitively; the Tiles legacy OGNL: evaluator is disabled by default

Where the Books and the Docs Are Outdated

Struts in Action (Struts 1, ActionForward and ActionMapping chapters) and Struts 2 in Action (the results chapter) are listed in the section bibliography. Their concepts survive: a name chosen by the controller, a mapping to a view, a global list for shared outcomes. The details do not.

Table 13. Book-era material and today’s equivalent
The book’s era Struts 7.4

Struts 1 ActionForward found with mapping.findForward("success") and declared in struts-config.xml (<forward>, global and local)

A result code and a result element; global forwards are global-results

Struts 1 forward versus redirect chosen by a redirect="true" attribute

Two result types: dispatcher forwards, redirect and redirectAction redirect

Struts 1 runtime parameters and dynamic forwards built in code

${expression} in the result text or parameters, and the redirect extra parameters

Struts 1 ActionMapping attributes (path, type, name, scope, validate, input, parameter, roles)

An action element; the input attribute is the input result, form beans are the action’s own properties

@Result(name="SUCCESS") in an annotation example (a book errata)

The code is lower case success; use @Result(name = "success", location = "...") with the Convention plugin

Result classes in com.opensymphony.xwork2 and XWork wording

org.apache.struts2.result since 7.0

A hand-written JSON result or XStream plugin

The JSON plugin’s json result with root and includeProperties

Dojo-based AJAX results and the ajax theme

Removed with the Dojo plugin; plain fetch calls to JSON endpoints

.do links and redirect to *.do

Default extension action,, and redirectAction, which asks the mapper for the URL

The official XSL Result page’s matchingPattern and excludingPattern parameters

Not in the 7.4.0 XSLTResult: no setter exists, the parameters are silently ignored and the output is identical with or without them; select the nodes in the stylesheet

JSP-only views

FreeMarker is part of the core, Velocity is a plugin, and the JSP stays a good default under WEB-INF

The official documentation has gaps and stale spots of its own, found while testing for this page.

Table 14. Documentation inconsistencies found in the Core Developers Guide result pages (7.4.0)
Where What is stale or missing

Result Types, Extending

The ParamNameAwareResult example implements acceptableParamName; the 7.4.0 interface method is acceptableParameterName

Result Types, Predefined Result Types

Refers to com.opensymphony.xwork2.Result; it is org.apache.struts2.result.Result since 7.0. The Stream Result example also imports com.opensymphony.xwork2.Action and com.opensymphony.xwork2.ActionSupport

Result Types, Default Parameters

The freemarker example sets foo.fm, and the "equivalent" configuration below it names foo.vm

Redirect and Redirect Action Result tables

Omit statusCode, encode and (for redirectAction) method, which exist in the 7.4.0 classes; one Redirect Action example shows &amp; literally inside a URL

Redirect Action Result, examples

The generated URL in a comment ends in .action, which depends on the struts.action.extension constant

Stream Result

The sample getters are private; a getter that OGNL reads must be public (and the class allowlisted)

Redirect Result examples

One example opens its XML comment with <-- instead of <!--

Postback Result

Does not mention that the hidden values are URL-encoded

Summary

A result is the second half of an action: the action returns a code, result elements map codes to types, and the type turns the code into a response. Most result elements need only a location because the name, the type and the parameter all have defaults. dispatcher forwards to a JSP under WEB-INF, redirectAction ends a POST with a redirect, stream sends files, httpheader sends statuses, PlainResult writes strings from Java, and the plugin packages add JSON, XSLT, Tiles, Velocity and reports. Treat every expression in a result as code that runs on the server: compute it in the action, never take it from the request, and let the allowlist stay on.

References