Portlets are small, embeddable widgets that users can add to their NetSuite dashboards. They give you a way to surface real-time data, quick-action forms, and custom visualizations right where users start their day. If you have ever wanted to put a KPI tracker, a recent orders table, or a shortcut panel directly on someone's home screen, portlets are how you do it.
What Are Portlets?
A Portlet script generates a self-contained component that renders inside the NetSuite dashboard. Unlike Suitelets, which are standalone pages, portlets live alongside other dashboard content -- key performance indicators, reminders, shortcuts, and saved searches.
NetSuite supports four portlet types, each suited to a different presentation style:
| Type | Constant | Best For |
|---|---|---|
| FORM | serverWidget.PortletType.FORM | Input fields, buttons, and quick-action forms |
| HTML | serverWidget.PortletType.HTML | Custom markup, charts, and rich visualizations |
| LIST | serverWidget.PortletType.LIST | Tabular data with columns and rows |
| LINKS | serverWidget.PortletType.LINKS | Navigation links and shortcut menus |
You choose the type based on what you need to display. FORM portlets behave like miniature Suitelets. HTML portlets give you full control over the markup. LIST portlets handle structured data automatically. LINKS portlets are simple navigation aids.
Basic Portlet Structure
Every portlet script uses the render entry point. NetSuite calls this function each time the dashboard loads and passes in a params object containing the portlet reference:
/**
* @NApiVersion 2.1
* @NScriptType Portlet
*/
define(['N/ui/serverWidget'], (serverWidget) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'My Custom Portlet';
// Build the portlet content based on type...
};
return { render };
});The params object gives you:
params.portlet-- The portlet object you configure (add fields, columns, HTML, or links)params.column-- Which dashboard column the portlet is placed in (1, 2, or 3)params.entityId-- For portlets on entity dashboards, the internal ID of the record
There is no GET/POST handling like Suitelets. The render function fires on every dashboard load, so keep it fast.
Form-Based Portlets
FORM portlets let you add fields and buttons, making them ideal for quick-action widgets. Users can enter data and submit it without leaving the dashboard.
/**
* @NApiVersion 2.1
* @NScriptType Portlet
*/
define(['N/ui/serverWidget', 'N/url'], (serverWidget, url) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'Quick Customer Lookup';
// Add a text field for search input
portlet.addField({
id: 'custpage_search_term',
type: serverWidget.FieldType.TEXT,
label: 'Customer Name or ID'
});
// Add a select field for search type
const searchType = portlet.addField({
id: 'custpage_search_type',
type: serverWidget.FieldType.SELECT,
label: 'Search By'
});
searchType.addSelectOption({ value: 'name', text: 'Company Name' });
searchType.addSelectOption({ value: 'email', text: 'Email Address' });
searchType.addSelectOption({ value: 'phone', text: 'Phone Number' });
// Build the Suitelet URL for form submission
const suiteletUrl = url.resolveScript({
scriptId: 'customscript_customer_search',
deploymentId: 'customdeploy_customer_search'
});
// Add a submit button that redirects to the Suitelet
portlet.setSubmitButton({
url: suiteletUrl,
label: 'Search',
target: '_blank'
});
};
return { render };
});The setSubmitButton method sends the field values as URL parameters to a Suitelet or any other endpoint. This pattern keeps the portlet lightweight -- it collects input and hands off processing to a separate script.
HTML Portlets
HTML portlets accept raw markup, which means you can build anything from styled status cards to embedded charts. Use params.portlet.html to set the content:
/**
* @NApiVersion 2.1
* @NScriptType Portlet
*/
define(['N/search'], (search) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'Sales Overview';
// Fetch data (covered in detail below)
const metrics = getSalesMetrics();
portlet.html = `
<style>
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 8px;
}
.kpi-card {
background: #f8f9fa;
border-radius: 6px;
padding: 16px;
text-align: center;
border-left: 4px solid #607D8B;
}
.kpi-card.positive { border-left-color: #4CAF50; }
.kpi-card.warning { border-left-color: #FF9800; }
.kpi-card.negative { border-left-color: #f44336; }
.kpi-value {
font-size: 24px;
font-weight: bold;
color: #333;
margin: 4px 0;
}
.kpi-label {
font-size: 12px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
</style>
<div class="kpi-grid">
<div class="kpi-card positive">
<div class="kpi-label">Today's Revenue</div>
<div class="kpi-value">$${metrics.todayRevenue.toLocaleString()}</div>
</div>
<div class="kpi-card ${metrics.openOrders > 50 ? 'warning' : 'positive'}">
<div class="kpi-label">Open Orders</div>
<div class="kpi-value">${metrics.openOrders}</div>
</div>
<div class="kpi-card ${metrics.overdueInvoices > 0 ? 'negative' : 'positive'}">
<div class="kpi-label">Overdue Invoices</div>
<div class="kpi-value">${metrics.overdueInvoices}</div>
</div>
</div>
`;
};
/**
* Fetch sales metrics using saved searches
*/
const getSalesMetrics = () => {
// Implementation in the "Querying Data" section below
return { todayRevenue: 0, openOrders: 0, overdueInvoices: 0 };
};
return { render };
});HTML portlets give you maximum flexibility, but with that comes responsibility. Keep the markup lean -- heavy DOM structures slow down the dashboard for everyone.
List Portlets
LIST portlets generate a table automatically. You define columns and add rows, and NetSuite handles the rendering. This is the cleanest approach for displaying record data:
/**
* @NApiVersion 2.1
* @NScriptType Portlet
*/
define(['N/search', 'N/url'], (search, url) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'Recent Sales Orders';
// Define columns
portlet.addColumn({
id: 'custpage_order_num',
type: serverWidget.FieldType.TEXT,
label: 'Order #',
align: serverWidget.LayoutJustification.LEFT
});
portlet.addColumn({
id: 'custpage_customer',
type: serverWidget.FieldType.TEXT,
label: 'Customer',
align: serverWidget.LayoutJustification.LEFT
});
portlet.addColumn({
id: 'custpage_amount',
type: serverWidget.FieldType.CURRENCY,
label: 'Amount',
align: serverWidget.LayoutJustification.RIGHT
});
portlet.addColumn({
id: 'custpage_status',
type: serverWidget.FieldType.TEXT,
label: 'Status',
align: serverWidget.LayoutJustification.CENTER
});
// Run a search to populate the list
const salesOrders = search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['datecreated', 'within', 'lastsevendays']
],
columns: [
search.createColumn({ name: 'tranid', sort: search.Sort.DESC }),
search.createColumn({ name: 'entity' }),
search.createColumn({ name: 'amount' }),
search.createColumn({ name: 'statusref' })
]
});
let lineNum = 0;
salesOrders.run().each((result) => {
if (lineNum >= 10) return false; // Limit rows displayed
const recordUrl = url.resolveRecord({
recordType: 'salesorder',
recordId: result.id,
isEditMode: false
});
portlet.addRow({
custpage_order_num: `<a href="${recordUrl}">${result.getValue('tranid')}</a>`,
custpage_customer: result.getText('entity'),
custpage_amount: result.getValue('amount'),
custpage_status: result.getText('statusref')
});
lineNum++;
return true;
});
};
return { render };
});The addRow method accepts an object with keys matching your column IDs. You can embed HTML in text columns to create clickable links, as shown with the order number above.
Querying Data with N/search
Most portlets need live data. The N/search module is your primary tool for fetching it. Here are patterns that work well inside portlets.
Aggregated Metrics
Use summary columns to calculate totals, counts, and averages without loading individual records:
const getSalesMetrics = () => {
const metrics = {
todayRevenue: 0,
openOrders: 0,
overdueInvoices: 0
};
// Today's revenue
search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['trandate', 'on', 'today'],
'AND',
['status', 'anyof', 'SalesOrd:F'] // Billed
],
columns: [
search.createColumn({
name: 'amount',
summary: search.Summary.SUM
})
]
}).run().each((result) => {
metrics.todayRevenue = parseFloat(result.getValue({
name: 'amount',
summary: search.Summary.SUM
})) || 0;
return false;
});
// Open orders count
search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['status', 'anyof', 'SalesOrd:A', 'SalesOrd:B', 'SalesOrd:D']
],
columns: [
search.createColumn({
name: 'internalid',
summary: search.Summary.COUNT
})
]
}).run().each((result) => {
metrics.openOrders = parseInt(result.getValue({
name: 'internalid',
summary: search.Summary.COUNT
})) || 0;
return false;
});
// Overdue invoices
search.create({
type: search.Type.INVOICE,
filters: [
['mainline', 'is', 'T'],
'AND',
['status', 'anyof', 'CustInvc:A'], // Open
'AND',
['duedate', 'before', 'today']
],
columns: [
search.createColumn({
name: 'internalid',
summary: search.Summary.COUNT
})
]
}).run().each((result) => {
metrics.overdueInvoices = parseInt(result.getValue({
name: 'internalid',
summary: search.Summary.COUNT
})) || 0;
return false;
});
return metrics;
};Using Saved Searches
If you already have saved searches built in the UI, reference them by internal ID instead of recreating filters in code:
const loadSavedSearch = (savedSearchId, maxResults) => {
const results = [];
search.load({ id: savedSearchId }).run().each((result) => {
if (results.length >= maxResults) return false;
results.push(result);
return true;
});
return results;
};This approach is easier to maintain. Business users can update the saved search criteria without touching code.
Practical Example: KPI Dashboard Widget
Here is a complete, production-ready KPI portlet that shows real-time business metrics with color-coded status indicators:
/**
* @NApiVersion 2.1
* @NScriptType Portlet
* @NModuleScope SameAccount
*
* KPI Dashboard Portlet
* Shows revenue, order count, and fulfillment rate
*/
define(['N/search', 'N/format'], (search, format) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'Business KPIs - This Month';
const kpis = calculateKPIs();
portlet.html = buildKPIHtml(kpis);
};
const calculateKPIs = () => {
const today = new Date();
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
let monthlyRevenue = 0;
let orderCount = 0;
let fulfilledCount = 0;
let totalOrders = 0;
// Monthly revenue and order count
search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['trandate', 'onorafter', format.format({
value: firstOfMonth,
type: format.Type.DATE
})],
'AND',
['status', 'noneof', 'SalesOrd:C'] // Exclude cancelled
],
columns: [
search.createColumn({ name: 'amount', summary: search.Summary.SUM }),
search.createColumn({ name: 'internalid', summary: search.Summary.COUNT })
]
}).run().each((result) => {
monthlyRevenue = parseFloat(result.getValue({
name: 'amount', summary: search.Summary.SUM
})) || 0;
orderCount = parseInt(result.getValue({
name: 'internalid', summary: search.Summary.COUNT
})) || 0;
return false;
});
// Fulfillment rate
search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['trandate', 'onorafter', format.format({
value: firstOfMonth,
type: format.Type.DATE
})]
],
columns: [
search.createColumn({ name: 'statusref', summary: search.Summary.GROUP }),
search.createColumn({ name: 'internalid', summary: search.Summary.COUNT })
]
}).run().each((result) => {
const status = result.getValue({
name: 'statusref', summary: search.Summary.GROUP
});
const count = parseInt(result.getValue({
name: 'internalid', summary: search.Summary.COUNT
})) || 0;
totalOrders += count;
if (status === 'fullyBilled' || status === 'closed') {
fulfilledCount += count;
}
return true;
});
const fulfillmentRate = totalOrders > 0
? Math.round((fulfilledCount / totalOrders) * 100)
: 0;
return { monthlyRevenue, orderCount, fulfillmentRate };
};
const buildKPIHtml = (kpis) => {
const revenueClass = kpis.monthlyRevenue > 50000 ? 'good' : 'neutral';
const fulfillClass = kpis.fulfillmentRate >= 90 ? 'good'
: kpis.fulfillmentRate >= 70 ? 'neutral' : 'bad';
return `
<style>
.kpi-container { display: flex; gap: 16px; padding: 8px 4px; }
.kpi-item { flex: 1; padding: 14px; border-radius: 6px; text-align: center; }
.kpi-item.good { background: #e8f5e9; border: 1px solid #c8e6c9; }
.kpi-item.neutral { background: #fff3e0; border: 1px solid #ffe0b2; }
.kpi-item.bad { background: #fce4ec; border: 1px solid #f8bbd0; }
.kpi-number { font-size: 28px; font-weight: 700; color: #212121; }
.kpi-title { font-size: 11px; text-transform: uppercase; color: #757575;
letter-spacing: 0.5px; margin-top: 4px; }
</style>
<div class="kpi-container">
<div class="kpi-item ${revenueClass}">
<div class="kpi-number">$${kpis.monthlyRevenue.toLocaleString()}</div>
<div class="kpi-title">Monthly Revenue</div>
</div>
<div class="kpi-item neutral">
<div class="kpi-number">${kpis.orderCount}</div>
<div class="kpi-title">Orders This Month</div>
</div>
<div class="kpi-item ${fulfillClass}">
<div class="kpi-number">${kpis.fulfillmentRate}%</div>
<div class="kpi-title">Fulfillment Rate</div>
</div>
</div>
`;
};
return { render };
});Practical Example: Recent Orders List Portlet
A LIST portlet that shows the latest sales orders with links to the records:
/**
* @NApiVersion 2.1
* @NScriptType Portlet
* @NModuleScope SameAccount
*
* Recent Sales Orders Portlet
*/
define(['N/search', 'N/url', 'N/ui/serverWidget'], (search, url, serverWidget) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'Recent Sales Orders';
// Define the table columns
portlet.addColumn({
id: 'order_link',
type: serverWidget.FieldType.TEXT,
label: 'Order #',
align: serverWidget.LayoutJustification.LEFT
});
portlet.addColumn({
id: 'date',
type: serverWidget.FieldType.TEXT,
label: 'Date',
align: serverWidget.LayoutJustification.CENTER
});
portlet.addColumn({
id: 'customer_name',
type: serverWidget.FieldType.TEXT,
label: 'Customer',
align: serverWidget.LayoutJustification.LEFT
});
portlet.addColumn({
id: 'total',
type: serverWidget.FieldType.CURRENCY,
label: 'Total',
align: serverWidget.LayoutJustification.RIGHT
});
portlet.addColumn({
id: 'order_status',
type: serverWidget.FieldType.TEXT,
label: 'Status',
align: serverWidget.LayoutJustification.CENTER
});
// Fetch and display the 15 most recent orders
search.create({
type: search.Type.SALES_ORDER,
filters: [
['mainline', 'is', 'T'],
'AND',
['trandate', 'within', 'lastthirtydays']
],
columns: [
search.createColumn({ name: 'tranid', sort: search.Sort.DESC }),
search.createColumn({ name: 'trandate' }),
search.createColumn({ name: 'entity' }),
search.createColumn({ name: 'amount' }),
search.createColumn({ name: 'statusref' })
]
}).run().each((result) => {
const recordLink = url.resolveRecord({
recordType: 'salesorder',
recordId: result.id,
isEditMode: false
});
portlet.addRow({
order_link: `<a href="${recordLink}">${result.getValue('tranid')}</a>`,
date: result.getValue('trandate'),
customer_name: result.getText('entity'),
total: result.getValue('amount'),
order_status: formatStatus(result.getText('statusref'))
});
return true;
});
};
/**
* Add color-coded HTML badges for order statuses
*/
const formatStatus = (statusText) => {
const colors = {
'Pending Fulfillment': '#FF9800',
'Partially Fulfilled': '#2196F3',
'Pending Billing': '#9C27B0',
'Fully Billed': '#4CAF50',
'Closed': '#607D8B'
};
const color = colors[statusText] || '#757575';
return `<span style="background:${color}; color:#fff; padding:2px 8px;
border-radius:3px; font-size:11px;">${statusText}</span>`;
};
return { render };
});Practical Example: Quick Action Form Portlet
A FORM portlet that lets users create tasks directly from the dashboard:
/**
* @NApiVersion 2.1
* @NScriptType Portlet
* @NModuleScope SameAccount
*
* Quick Task Creator Portlet
*/
define(['N/ui/serverWidget', 'N/url', 'N/runtime'], (serverWidget, url, runtime) => {
const render = (params) => {
const portlet = params.portlet;
portlet.title = 'Quick Task';
// Task title
const titleField = portlet.addField({
id: 'custpage_task_title',
type: serverWidget.FieldType.TEXT,
label: 'Task Title'
});
titleField.isMandatory = true;
// Assign to
portlet.addField({
id: 'custpage_assigned_to',
type: serverWidget.FieldType.SELECT,
label: 'Assign To',
source: 'employee'
}).defaultValue = runtime.getCurrentUser().id;
// Priority
const priority = portlet.addField({
id: 'custpage_priority',
type: serverWidget.FieldType.SELECT,
label: 'Priority'
});
priority.addSelectOption({ value: 'HIGH', text: 'High' });
priority.addSelectOption({ value: 'MEDIUM', text: 'Medium' });
priority.addSelectOption({ value: 'LOW', text: 'Low' });
priority.defaultValue = 'MEDIUM';
// Due date
portlet.addField({
id: 'custpage_due_date',
type: serverWidget.FieldType.DATE,
label: 'Due Date'
});
// Notes
portlet.addField({
id: 'custpage_notes',
type: serverWidget.FieldType.TEXTAREA,
label: 'Notes'
});
// Submit to a Suitelet that creates the task record
const processorUrl = url.resolveScript({
scriptId: 'customscript_task_processor',
deploymentId: 'customdeploy_task_processor'
});
portlet.setSubmitButton({
url: processorUrl,
label: 'Create Task'
});
};
return { render };
});The companion Suitelet (customscript_task_processor) would read the parameters and create the task record. This separation keeps the portlet script focused on the UI while offloading the write operation.
Styling and Layout Tips
Portlets render inside the NetSuite dashboard frame, which limits your styling options. Here are things to keep in mind:
Use inline styles or scoped classes. NetSuite's dashboard CSS can override generic class names. Prefix your CSS classes with something unique (like brk- or your company initials) to avoid conflicts:
<style>
.brk-portlet-card { /* your styles */ }
.brk-portlet-badge { /* your styles */ }
</style>Respect the column width. Portlets can be placed in narrow or wide columns. Use relative units (%, flex) instead of fixed pixel widths so the layout adapts:
.brk-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.brk-grid-item {
flex: 1 1 120px;
min-width: 100px;
}Keep font sizes readable. The dashboard already has a lot of visual density. Stick to 12-14px for body text and reserve larger sizes for key numbers.
Avoid external resources. Do not load fonts, images, or scripts from CDNs. Everything should come from the NetSuite File Cabinet or be inline. External requests add latency and may be blocked by browser security policies.
Performance Considerations
Portlets share the dashboard page load. Every search, every record lookup, every line of HTML you add affects how quickly the entire dashboard renders. This is the single most important constraint for portlet development.
Limit search results. Never return unbounded result sets. Cap your searches at 10-20 rows for list portlets. Use the return false pattern to exit early:
let count = 0;
searchObj.run().each((result) => {
if (count >= 10) return false;
// process result
count++;
return true;
});Use summary searches for metrics. If you only need a count or a sum, use search.Summary.COUNT or search.Summary.SUM. Aggregation on the server is far cheaper than loading hundreds of records and counting them in script.
Cache where possible. If the data does not change frequently, consider writing results to a custom record or script parameter and refreshing it on a schedule via a Scheduled Script. The portlet then reads the cached value instead of running expensive searches on every page load.
Monitor governance usage. Portlet scripts have a 1,000 unit governance limit. Each search.create and search.load costs 5 units. Each record.load costs 5-10 units. Plan your data fetching accordingly. If you find yourself nearing the limit, move the heavy lifting to a Scheduled Script that writes to a custom record.
Avoid record.load in loops. If you need data from related records, use search joins or formula columns instead of loading each record individually:
// Bad: loading records in a loop
results.forEach(r => {
const rec = record.load({ type: 'customer', id: r.getValue('entity') }); // 10 units each
});
// Good: use a joined search column
search.createColumn({ name: 'email', join: 'customer' }); // no extra costDeployment to Roles and Dashboards
Deploying a portlet involves creating a Script record and a Deployment, similar to other SuiteScript types. The key difference is how users access it.
Step 1: Upload the Script File
Upload your .js file to the SuiteScript folder in the File Cabinet (typically SuiteScripts/Portlets/).
Step 2: Create the Script Record
- Navigate to Customization > Scripting > Scripts > New
- Select your uploaded file
- NetSuite detects it as a Portlet script type
- Set the script name and ID (e.g.,
customscript_kpi_dashboard) - Save
Step 3: Create the Script Deployment
- On the Script record, click Deploy Script
- Set Status to Released
- Set Audience -- choose which roles and users can add this portlet to their dashboards
- Give it a clear Title -- this is what users see in the portlet selection menu
- Save
Step 4: Users Add It to Their Dashboard
Users can then:
- Go to their dashboard (Home)
- Click Personalize (or Set Up in the dashboard menu)
- Find your portlet in the Custom Content section
- Drag it to the desired column
- Save the dashboard layout
Publishing to All Users
If you want a portlet to appear on every user's dashboard by default, use the Administrator Dashboard Setup:
- Go to Home > Set Preferences > Home Dashboard
- Use Publish Dashboard to push the layout to specific roles
- Users can still personalize after publication
Putting It All Together
A typical portlet development workflow looks like this:
- Identify the data users need on their dashboard
- Choose the right portlet type (FORM, HTML, LIST, or LINKS)
- Write the search logic and test it in a Suitelet first (easier to debug)
- Move the logic into a Portlet script with the
renderentry point - Add styling and formatting
- Test performance -- check that the dashboard loads in under 3 seconds
- Deploy and publish to the appropriate roles
The best portlets are focused. They show one thing clearly rather than trying to cram an entire report into a small widget. If you find yourself building something complex, consider whether a Suitelet with a link from a LINKS portlet would serve users better.
Next Steps
Now that you can build custom portlets, explore these related tutorials:
- Creating Custom Suitelets for full custom pages when portlets are too small
- Saved Search Formulas for powering portlet data with advanced calculations
- User Event Scripts for automating record operations triggered by portlet actions
Want custom dashboards for your NetSuite users? Contact our development team to discuss your requirements.