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:
| Return value | Effect |
|---|---|
A |
Looked up by name in the action’s result configurations; the matching |
|
No result is looked up or run; the action is expected to have written the response itself (or nothing is needed) |
A |
That object is used directly and no configuration is consulted; the framework injects its dependencies ( |
A |
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.
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.
<?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.
| Action | Result type | What it does |
|---|---|---|
|
|
Lists the catalogue with |
|
|
Adds a book from a POST, then redirects to |
|
|
Permanent redirect (301) from a retired URL |
|
|
Runs |
|
|
Downloads the catalogue as a CSV file |
|
|
Answers 204 with headers; answers 404 with a message |
|
|
Shows an HTML snippet as plain text |
|
|
Writes a small JSON document from Java |
|
|
Re-posts the request parameters to another action |
|
|
Render one book as an HTML fragment |
|
|
The catalogue as JSON and as XML |
|
|
A page assembled by a Tiles layout |
|
custom |
A custom |
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.
<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
resultwithout anameis namedsuccess. -
Default type. A
resultwithout atypeuses the result type registered withdefault="true". Instruts-default.xmlthat isdispatcher; 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,plainTextandpostbackit islocation; forredirectActionandchainit isactionName; forstreamit isinputName; forxsltit isstylesheetLocation;httpheaderhas none. So<result type="chain">books</result>and<result type="chain"><param name="actionName">books</param></result>are the same.
| Type name | Class (org.apache.struts2...) |
Default parameter |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
none; every value is set by name |
|
|
|
|
|
|
|
|
|
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.
| Constant | Value | Typical meaning |
|---|---|---|
|
|
The action did its job; show the normal view. This is also the default result name |
|
|
The input was not acceptable; show the form again. The |
|
|
Something failed; show an error view |
|
|
The caller must authenticate first; usually a global result |
|
|
Skip result processing: the action already wrote the response. Returning |
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.
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>
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;
}
}
$ 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.
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.
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.
<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.
Result; no result element is neededpublic 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.
NONEpublic 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.
| Parameter | Default | Effect |
|---|---|---|
|
none |
The target (a template, a URL, an action path). The default parameter of most types |
|
|
Evaluate |
|
|
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:
-
If a JSP
PageContextis available (the result runs inside a page, for example from theactiontag), the page context’sincludeis called. -
Otherwise, if the response is not committed and the request is not already an include,
forwardis called. A forward lets the page set the content type and status, and it recordsstruts.view_uriandstruts.request_urias request attributes. -
In every other case
includeis called.
| Parameter | Default | Effect |
|---|---|---|
|
none |
The resource to dispatch to. Start it with |
|
|
Evaluate |
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.
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.
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.
| Parameter | Default | Effect |
|---|---|---|
|
none |
The target URL. A location that does not start with |
|
none |
The action to redirect to; the URL is built by the |
|
current namespace |
The namespace of the target action |
|
none |
An action method, passed to the mapper (relevant when dynamic method invocation or method-in-URL mapping is in use) |
|
none |
A fragment appended after |
|
|
The status of the redirect, for example |
|
|
Prefix a path-only location with the context path; set |
|
|
Leave out query parameters whose value is empty |
|
|
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.
<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.
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).
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.
| Parameter | Default | Effect |
|---|---|---|
|
|
Name of the action property (a getter returning an |
|
|
The MIME type sent to the browser |
|
none |
Length in bytes; lets the browser show progress. A value that is not a number is logged and the header is left out |
|
|
The |
|
none |
If set, |
|
|
Size of the copy buffer |
|
|
|
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.
<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).
<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.
<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é & 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().
PlainResultpublic 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).
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.
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>
Unknown <script>$ curl -s 'http://localhost:18080/g8res/book-card?id=99'
<article>
<h2>Unknown <script></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.
| Plugin | Package to extend | Result type and class | Notes |
|---|---|---|---|
JSON |
|
|
Serialises an action or part of it to JSON; the JSON and AJAX page of this section covers the interceptor and REST |
Velocity |
|
|
Templates ( |
XSLT |
|
|
Transforms the action, serialised to XML, with a stylesheet |
Tiles |
|
|
Page layouts; see the Tiles notes below |
JasperReports |
|
|
PDF, CSV and other report formats with JasperReports 6.x |
JasperReports 7 |
|
|
JasperReports 7.x, since Struts 7.1.0; independent of the 6.x plugin |
JFreeChart |
|
|
Streams a generated chart image; the plugin’s |
REST |
|
overrides |
No new type |
Source: Apache Struts — Plugins
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.
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.
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>
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.
<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.
label result that writes its location, with the expressions already evaluated, as one line of textpublic 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.
locationpublic 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.
PreResultListener which adds a header carrying the result codepublic 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.
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.
<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:
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
| The response is | Use | Remember |
|---|---|---|
An HTML page rendered from the action’s data |
|
Views are not URLs; the action is the entry point |
The answer to a successful POST |
|
The action instance is gone; pass an id, not an object |
A move of a URL |
|
Browsers cache permanent redirects |
A file, image or CSV |
|
Set |
A status code only |
|
|
A string written from Java |
|
Escape anything from users yourself |
JSON for a script |
|
List |
Another action’s output without a new request |
|
Rarely the right answer |
A page with a shared layout |
|
Tiles is retired; consider the alternatives |
A template instead of JSP |
|
Velocity does not escape by default |
The current parameters sent as a POST elsewhere |
|
Values are URL-encoded in the form |
A report or a chart |
|
Plugin libraries and compiled reports |
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
404 |
The action returned a code with no |
Add the result, return a known code, or add a deliberate |
404 |
The |
Start locations with |
|
The package does not extend |
Add the plugin package to |
Two results with the same name, only the last one runs (observed) |
A duplicated |
One |
A result with a |
Every unknown code lands on the fallback |
Use |
Page reloaded and the form is submitted again |
A POST was answered by a dispatcher result |
Answer with |
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 |
|
|
Leave it |
|
A value pasted into the |
Use separate |
|
|
Leave |
Open redirect to another site (observed) |
A request value became the whole target |
Map a key to a fixed destination (allowlist) |
|
Two actions chain to each other |
Redirect instead of chaining, or break the cycle |
500 |
|
Match |
Download opens in the browser instead of saving |
|
|
Garbled accented characters in a plain-text result (observed) |
No |
Set |
A hyphenated header is missing (observed) |
|
Use |
|
A template written for FreeMarker in Struts 2.3 uses |
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 |
Tiles startup |
|
Add |
|
The interface method is |
Use the name in the Javadoc of the 7.4.0 jar |
Postback target receives |
The result URL-encodes the hidden field values |
Decode in the target, or send only simple values |
Struts 2.x code imports |
The package moved to |
Search and replace as the migration guide says |
What Changed Recently
| Version | Change |
|---|---|
2.5 |
A |
2.5.2 |
The |
6.0.0 |
|
7.0.0 |
|
7.1.0 |
The JasperReports 7 plugin and its |
7.3.0 |
The JSON result serialises Java records and |
7.4.0 |
|
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.
| The book’s era | Struts 7.4 |
|---|---|
Struts 1 |
A result code and a |
Struts 1 forward versus redirect chosen by a |
Two result types: |
Struts 1 runtime parameters and dynamic forwards built in code |
|
Struts 1 |
An |
|
The code is lower case |
Result classes in |
|
A hand-written JSON result or XStream plugin |
The JSON plugin’s |
Dojo-based AJAX results and the |
Removed with the Dojo plugin; plain |
|
Default extension |
The official XSL Result page’s |
Not in the 7.4.0 |
JSP-only views |
FreeMarker is part of the core, Velocity is a plugin, and the JSP stays a good default under |
Source: Apache Struts — Result Types
Source: Manning — Struts in Action
Source: Manning — Struts 2 in Action
The official documentation has gaps and stale spots of its own, found while testing for this page.
| Where | What is stale or missing |
|---|---|
Result Types, Extending |
The |
Result Types, Predefined Result Types |
Refers to |
Result Types, Default Parameters |
The |
Redirect and Redirect Action Result tables |
Omit |
Redirect Action Result, examples |
The generated URL in a comment ends in |
Stream Result |
The sample getters are |
Redirect Result examples |
One example opens its XML comment with |
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.