How to Create Your Own Template Component in Oracle APEX

Create a template component plug-in with Partial and Report templates, map its attributes, add CSS, and use it as a region and as a report column.

A template component is a plug-in made of an HTML template and a few attributes. Once you create it, it appears in Page Designer next to Cards, Content Row and Badge. You can use it as a region that lists many rows, or as a column type inside a report, without copying HTML from page to page.

This guide builds a Status Badge template component step by step. It uses template directives ({case}, {if}) to pick a color for each status, a Report template for the region, a Partial template for the report column, and a CSS file stored inside the plug-in.

The guide uses support tickets as the example; the same steps work for any table with a status or category column.

The overview article Oracle APEX Cards, Content Rows, and Template Components explains the built-in components, and Building Your First Oracle APEX Plug-in builds a single meter partial. This guide adds directives, the Multiple (Report) template, attribute settings, a plug-in CSS file and an escaping test.

Step 1: Create the Tables

The example needs two tables. TICKET_STATUSES holds the status codes with a display name, an optional Font APEX icon and a sort order. SUPPORT_TICKETS holds the tickets, and its STATUS_CODE column is a foreign key to TICKET_STATUSES.

Create the tables, the index and the sample data:

Example:

create table ticket_statuses (
  status_code  varchar2(20)  not null,
  status_name  varchar2(40)  not null,
  icon_css     varchar2(40),
  sort_seq     number(3)     not null,
  constraint ticket_statuses_pk primary key (status_code),
  constraint ticket_statuses_name_uk unique (status_name)
);

create table support_tickets (
  ticket_id    number generated by default on null as identity,
  title        varchar2(200) not null,
  requester    varchar2(60)  not null,
  status_code  varchar2(20)  default on null 'OPEN' not null,
  priority     varchar2(10)  default on null 'NORMAL' not null,
  created_on   date          default on null sysdate not null,
  constraint support_tickets_pk primary key (ticket_id),
  constraint support_tickets_status_fk foreign key (status_code)
    references ticket_statuses (status_code),
  constraint support_tickets_priority_ck check (priority in ('LOW', 'NORMAL', 'HIGH'))
);

create index support_tickets_status_ix on support_tickets (status_code);

insert into ticket_statuses values ('OPEN', 'Open', 'fa-circle-o', 10);
insert into ticket_statuses values ('IN_PROGRESS', 'In Progress', 'fa-spinner', 20);
insert into ticket_statuses values ('ON_HOLD', 'On Hold', 'fa-pause-circle-o', 30);
insert into ticket_statuses values ('CLOSED', 'Closed', null, 40);

insert into support_tickets (title, requester, status_code, priority, created_on)
values ('Printer on floor 2 is offline', 'Lena Fischer', 'OPEN', 'HIGH', date '2026-10-12');
insert into support_tickets (title, requester, status_code, priority, created_on)
values ('New laptop for onboarding', 'Raj Patel', 'IN_PROGRESS', 'NORMAL', date '2026-10-10');
insert into support_tickets (title, requester, status_code, priority, created_on)
values ('VPN drops every hour', 'Maria Lopez', 'IN_PROGRESS', 'HIGH', date '2026-10-09');
insert into support_tickets (title, requester, status_code, priority, created_on)
values ('Licence renewal for design tool', 'Tom Becker', 'ON_HOLD', 'LOW', date '2026-10-05');
insert into support_tickets (title, requester, status_code, priority, created_on)
values ('Password reset for shared mailbox', 'Aiko Tanaka', 'CLOSED', 'NORMAL', date '2026-10-02');
insert into support_tickets (title, requester, priority, created_on)
values ('Second monitor request', 'Lena Fischer', 'LOW', date '2026-10-13');
commit;

select t.ticket_id, t.title, t.status_code, s.status_name, s.icon_css
from   support_tickets t join ticket_statuses s on s.status_code = t.status_code
order  by t.ticket_id;

Output:

Table TICKET_STATUSES created.

Table SUPPORT_TICKETS created.

Index SUPPORT_TICKETS_STATUS_IX created.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

   TICKET_ID TITLE                                STATUS_CODE    STATUS_NAME    ICON_CSS
____________ ____________________________________ ______________ ______________ ____________________
           1 Printer on floor 2 is offline        OPEN           Open           fa-circle-o
           2 New laptop for onboarding            IN_PROGRESS    In Progress    fa-spinner
           3 VPN drops every hour                 IN_PROGRESS    In Progress    fa-spinner
           4 Licence renewal for design tool      ON_HOLD        On Hold        fa-pause-circle-o
           5 Password reset for shared mailbox    CLOSED         Closed
           6 Second monitor request               OPEN           Open           fa-circle-o

6 rows selected.
  • SUPPORT_TICKETS_STATUS_FK allows only known status codes. SUPPORT_TICKETS_PRIORITY_CK limits the priority to three values.
  • STATUS_CODE, PRIORITY and CREATED_ON use DEFAULT ON NULL, so a new ticket gets OPEN, NORMAL and the current date without a trigger. Ticket 6 shows this: its insert has no status.
  • The Closed status has no icon. The template shows the icon only when one is set.

Step 2: Create the Template Component Plug-in

In App Builder, open the application, click Shared Components, and then click Plug-ins. Click Create:

Plug-ins page in Shared Components with the Create button marked with a red box
The Create button on the Plug-ins page.

Keep Create Plug-in set to From Scratch and click Next:

Create Plug-in wizard Method step with From Scratch selected and marked with a red box
A new plug-in from scratch.

The next screen is the plug-in form. Enter Name Status Badge. APEX fills APEXlang Name statusBadge and Static ID STATUS_BADGE from it. Set Type to Template Component, and under Available as check Single (Partial) and Multiple (Report):

Plug-in form with Name Status Badge, APEXlang Name, Static ID, Type Template Component and Available as Single and Multiple marked with red boxes
The name, the type, and where the component can be used.

Single (Partial) renders one record. It is what a report column uses. Multiple (Report) renders a list of records, which is what a region uses. Checking both gives one plug-in for both places.

Replace the sample code in Partial with this template:

Example:

<span class="tc-badge
  {case STATUS/}
    {when OPEN/}tc-badge--open
    {when IN_PROGRESS/}tc-badge--progress
    {when ON_HOLD/}tc-badge--hold
    {when CLOSED/}tc-badge--closed
    {otherwise/}tc-badge--other
  {endcase/}">
  {if ICON/}<span class="tc-badge-icon fa #ICON#" aria-hidden="true"></span>{endif/}
  <span class="tc-badge-label">#LABEL#</span>
</span>
Partial template editor with the status badge template marked with a red box
The Partial template of one badge.

The template has three placeholders: STATUS, ICON and LABEL. Each becomes an attribute that a developer maps to a column later:

  • {case STATUS/} compares the status value with each {when VALUE/} and outputs the matching CSS class. {otherwise/} covers any value that is not listed, so an unknown status gets a grey badge.
  • {if ICON/} outputs the icon only when ICON has a value.
  • #LABEL# is the visible text.

Because Multiple (Report) is checked, the form also shows the report templates. Set Report Row and Report Body to these values:

Example:

<li class="tc-badge-item" #APEX$ROW_IDENTIFICATION#>#APEX$PARTIAL#</li>

Example:

<ul class="tc-badge-list">#APEX$ROWS#</ul>
Report Row and Report Body template editors marked with a red box
The row and body templates for the region.

APEX replaces #APEX$PARTIAL# with the Partial template of the row and #APEX$ROWS# with all rendered rows. #APEX$ROW_IDENTIFICATION# adds data attributes that identify each row. Report Group and Report Container can stay empty.

Keep Default Escape Mode on HTML:

Default Escape Mode set to HTML marked with a red box
HTML escaping for all placeholders by default.

Leave the other settings as they are and click Create Plug-in.

Step 3: Check the Custom Attributes

After the plug-in is created, APEX shows "Attributes synchronized". It read the placeholders of the templates and created one custom attribute for each: Icon, Label and Status. All three have the type Session State Value, which means they are mapped to query columns.

Click the Status attribute in the Custom Attributes list. Turn on Required and click Apply Changes:

Edit Plug-in Attribute page for Status with Required turned on and marked with a red box
A badge always needs a status.

Do the same for Label:

Edit Plug-in Attribute page for Label with Required turned on and marked with a red box
A badge always needs a text.

Now open Icon. Keep Required off, set Escape Mode to HTML Attribute and click Apply Changes. The icon value goes inside a class attribute, so it is escaped for that position:

Edit Plug-in Attribute page for Icon with Escape Mode HTML Attribute marked with a red box
Attribute escaping for the icon class.

The Custom Attributes list now shows Label and Status as required:

Custom Attributes list with Icon, Label and Status, their type Session State Value and Required values marked with a red box
The three attributes of the component.

If you change the templates later, click Synchronize from Templates to add attributes for new placeholders.

Step 4: Add the CSS File to the Plug-in

The CSS classes in the template need styles. Keep them inside the plug-in, so the styles go wherever the plug-in goes. On the plug-in page, scroll to Files and click Create File:

Files section of the plug-in with the Create File button marked with a red box
Plug-in files are stored with the plug-in.

Enter File Name status-badge.css, leave Content empty and click Create:

Create File page with File Name status-badge.css marked with a red box
A new, empty CSS file.

APEX opens the file editor. Enter this CSS and click Save Changes:

Example:

.tc-badge-list {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.tc-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  line-height: 20px;
  white-space: nowrap;
}
.tc-badge--open     { background: #e0ecff; color: #0b4fae; }
.tc-badge--progress { background: #fff1d6; color: #8a5300; }
.tc-badge--hold     { background: #f1e4ff; color: #6a2fa8; }
.tc-badge--closed   { background: #dcf3e3; color: #1d6b3a; }
.tc-badge--other    { background: #ececec; color: #4a4a4a; }
File editor for status-badge.css with the CSS code marked with a red box and the Reference #PLUGIN_FILES#status-badge#MIN#.css
The badge styles and the file reference.

When the file was saved, APEX also created status-badge.min.css next to it. The Reference shown in the editor is #PLUGIN_FILES#status-badge#MIN#.css. At runtime, the page in this example loaded status-badge.min.css through this reference.

Back on the plug-in page, under File URLs to Load, enter this reference in Cascading Style Sheet and click Apply Changes:

Example:

#PLUGIN_FILES#status-badge#MIN#.css
Files list with status-badge.css and status-badge.min.css, and File URLs to Load Cascading Style Sheet marked with a red box
APEX loads the CSS on every page that uses the component.

Step 5: Create the Page

Click Create Page and choose Blank Page on the Component tab:

Create a Page dialog with the Blank Page page type marked with a red box
The Blank Page page type.

Enter Page Number 360 and Name Support Tickets. Turn off Use Breadcrumb, keep Use Navigation on, enter Icon fa-tags and click Create Page:

Create Blank Page dialog with Page Number 360, Name Support Tickets, Use Breadcrumb off, Use Navigation on and Icon fa-tags marked with red boxes
An empty page with a menu entry.

Step 6: Show the Status Counts as a Badge Region

In Page Designer, right-click Body and choose Create Region. Set Name and Title to Tickets by Status. Open the Type list: Status Badge is listed under Application Components. Select it:

Page Designer with the new region, Name and Title Tickets by Status and Type Status Badge marked with a red box
The plug-in as a region type.

Under Source, set Type to SQL Query and enter this query. It counts the tickets per status and adds an All Tickets row:

Example:

select s.status_code,
       s.status_name || ' (' || count(t.ticket_id) || ')' as status_label,
       s.icon_css,
       s.sort_seq
from   ticket_statuses s
left   join support_tickets t on t.status_code = s.status_code
group  by s.status_code, s.status_name, s.icon_css, s.sort_seq
union all
select 'ALL', 'All Tickets (' || count(*) || ')', 'fa-ticket', 0
from   support_tickets
Source group of the region with Type SQL Query and the SQL Query marked with red boxes
One row per badge.

The ALL row has a status code that the template does not list, so it uses the {otherwise/} branch and gets the grey style.

Set the order of the badges with the region's Order By setting. Set Order By Type to Static Value and Order By Clause to sort_seq. Then set Appearance, Template to Standard, so the region shows its title in a box:

Order By Type Static Value, Order By Clause sort_seq and Template Standard marked with red boxes
Badges in the order of SORT_SEQ.

Open the Attributes tab. Display is already Multiple (Report). Under Settings, set Icon to ICON_CSS, Label to STATUS_LABEL and Status to STATUS_CODE:

Attributes tab with Display Multiple (Report) and the Settings Icon ICON_CSS, Label STATUS_LABEL and Status STATUS_CODE marked with red boxes
The custom attributes mapped to query columns.

These are the three custom attributes from Step 3. Label and Status were marked as required, and they showed an error until they had a column.

Step 7: Use the Badge as a Report Column

Right-click Body again and choose Create Region. Set Name and Title to Tickets and Type to Interactive Report. Under Source, set Type to SQL Query and enter:

Example:

select t.ticket_id,
       t.title,
       t.requester,
       t.priority,
       t.created_on,
       t.status_code,
       s.status_name,
       s.icon_css
from   support_tickets t
join   ticket_statuses s on s.status_code = t.status_code
Page Designer with the Tickets region, Name, Title, Type Interactive Report and the SQL Query marked with red boxes
An interactive report on the tickets.

Expand the Columns of the Tickets region and select STATUS_CODE. Set Type to Status Badge and Heading to Status. Under Settings, set Icon to ICON_CSS, Label to STATUS_NAME and Status to STATUS_CODE:

STATUS_CODE column with Type Status Badge, Heading Status and the Settings Icon, Label and Status marked with red boxes
The plug-in as a report column type.

A report column uses the Partial template, one badge per row. Now select STATUS_NAME and set its Type to Hidden. Do the same for ICON_CSS. The badge still reads both columns:

STATUS_NAME column with Type Hidden marked with a red box
Helper columns stay hidden.

Click Save.

Step 8: Test the Badges

Run page 360. The region shows one badge per status with the ticket count, and the report shows the status of each ticket as a badge. Each status has its own color, and Closed has no icon because its ICON_CSS is empty:

Support Tickets page with the Tickets by Status badges marked with a red box and the Tickets report with a Status badge column
The same component as a region and as a column.

In the page source, the Open badge of the region was rendered like this. The {case} directive produced the class tc-badge--open, and the Report Row template wrapped it in a list item:

Output:

<li class="tc-badge-item" data-id="1" data-rownum="2"><span class="tc-badge tc-badge--open "> <span class="tc-badge-icon fa fa-circle-o" aria-hidden="true"></span> <span class="tc-badge-label">Open (2)</span> </span></li>

A browser check of all eleven badges on the page confirmed the colors: every Open badge had the light blue background, In Progress light orange, On Hold light purple, Closed light green and All Tickets grey. The page loaded status-badge.min.css from the plug-in files.

Next, change data. Move ticket 1 to In Progress and close ticket 4:

Example:

update support_tickets set status_code = 'IN_PROGRESS' where ticket_id = 1;
update support_tickets set status_code = 'CLOSED' where ticket_id = 4;
commit;

select status_code, count(*) as tickets
from   support_tickets
group  by status_code
order  by status_code;

Output:

1 row updated.

1 row updated.

Commit complete.

STATUS_CODE       TICKETS
______________ __________
CLOSED                  2
IN_PROGRESS             3
OPEN                    1

Reload the page. The counts and the badges follow the data: Open (1), In Progress (3), On Hold (0) and Closed (2), and tickets 1 and 4 show their new badges:

Support Tickets page after the update with the new counts in the badge region marked with a red box
New counts after the update.

Step 9: Test the Escaping

A template component outputs data inside HTML, so test it with data that tries to inject markup. This insert adds a status whose name contains HTML and whose icon tries to add an onmouseover attribute, and a ticket that uses it:

Example:

insert into ticket_statuses (status_code, status_name, icon_css, sort_seq)
values ('ESCALATED', '<b>Escalated</b>', 'fa-warning" onmouseover="alert(1)', 50);

insert into support_tickets (title, requester, status_code, priority)
values ('Test ticket for escaping', 'Test User', 'ESCALATED', 'HIGH');
commit;

select status_code, status_name, icon_css
from   ticket_statuses
where  status_code = 'ESCALATED';

Output:

1 row inserted.

1 row inserted.

Commit complete.

STATUS_CODE    STATUS_NAME         ICON_CSS
______________ ___________________ ____________________________________
ESCALATED      <b>Escalated</b>    fa-warning" onmouseover="alert(1)

Reload page 360. The new status shows the tags as plain text, and the badge is grey because ESCALATED is not in the {case} list:

Support Tickets page with the escaped Escalated badges in the region and the report marked with red boxes
HTML in the data is shown, not executed.

The HTML of the report badge shows what APEX did. The label was HTML-escaped, and the quotes in the icon value were escaped, so the value stayed inside the class attribute:

Output:

<span class="tc-badge tc-badge--other "> <span class="tc-badge-icon fa fa-warning&quot; onmouseover=&quot;alert(1)" aria-hidden="true"></span> <span class="tc-badge-label">&lt;b&gt;Escalated&lt;/b&gt;</span> </span>

Hovering over the badges did not open an alert, and the page had no element with an onmouseover attribute. The only visible effect is that the broken icon class shows no icon. Remove the test rows afterwards:

Example:

delete from support_tickets where status_code = 'ESCALATED';
delete from ticket_statuses where status_code = 'ESCALATED';
commit;

Output:

1 row deleted.

1 row deleted.

Commit complete.

Things to Know

  • Template directives work on the values of the attributes. {case} compares the exact value, so OPEN and Open are different. Keep codes in one case, as the check constraint and foreign key do here.
  • APEX creates the custom attributes from the placeholders when the plug-in is created. After you add a placeholder later, click Synchronize from Templates.
  • Saving the CSS file in the plug-in's file editor created a .min copy automatically, which the #MIN# reference uses.
  • The badge order comes from the Order By property of the region, which this example sets to SORT_SEQ.
  • Hidden report columns can still feed a template component column, so the report shows one badge column instead of three text columns.
  • Leave Default Escape Mode on HTML. Use Raw only for an attribute whose value you build safely yourself.
  • The plug-in is part of the application export, including its files. To use it in another app, copy it under Shared Components, Plug-ins.

Related Guides

Conclusion

A custom template component turns a piece of HTML into a reusable building block. Write a Partial template with placeholders and directives, add a Report template for lists, let APEX create the attributes, and store the CSS in the plug-in files.

The Status Badge from this guide works as a region and as a report column, and its colors come from the data through {case}. The tests showed that the badges follow data changes and that HTML in the data is escaped rather than run.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted