Skip to main content

Dashboard Best Practices

Guidelines for creating effective, performant, and user-friendly dashboard portlets.


Design Principles

1. Single Purpose

Each portlet should focus on one specific task or data view.

GoodBad
"Pending Approvals" - Lists items needing approval"Everything Dashboard" - Mix of unrelated data
"Sales This Week" - Clear time-bound metric"Sales and Inventory and Tasks" - Too broad
"Quick Order Entry" - Single form action"Order/Invoice/Payment Form" - Confusing

2. Glanceable Information

Users should understand portlet content in seconds.

GOOD LAYOUT                          BAD LAYOUT
────────────── ──────────────
┌────────────────────┐ ┌────────────────────┐
│ Open Orders: 47 │ │ Order Stats │
│ ▲ 12% from yesterday│ │ Total: 47 │
│ │ │ Yesterday: 42 │
│ [View All] │ │ Last Week: 289 │
└────────────────────┘ │ Avg: 41.28 │
│ Std Dev: 8.2 │
Clear, actionable info │ Median: 39 │
└────────────────────┘
Too much detail

3. Consistent Styling

Match NetSuite's UI patterns and your organization's style guide.

/* Recommended color palette */
.success { color: #27ae60; } /* Green - positive */
.warning { color: #f39c12; } /* Orange - attention */
.danger { color: #e74c3c; } /* Red - critical */
.info { color: #3498db; } /* Blue - informational */
.muted { color: #95a5a6; } /* Gray - secondary */

/* Standard spacing */
.portlet-content {
padding: 15px;
font-size: 13px;
line-height: 1.4;
}

Performance Guidelines

Limit Data Volume

// BAD: Load all records
search.create({
type: 'salesorder',
filters: [['mainline', 'is', 'T']]
}).run().each(result => {
portlet.addRow({ /* ... */ });
return true; // Processes all results
});

// GOOD: Limit to display needs
let count = 0;
const MAX_ROWS = 10;

search.create({
type: 'salesorder',
filters: [['mainline', 'is', 'T']],
columns: [
search.createColumn({ name: 'tranid', sort: search.Sort.DESC })
]
}).run().each(result => {
if (count >= MAX_ROWS) return false;
portlet.addRow({ /* ... */ });
count++;
return true;
});

Use Caching for Expensive Operations

const cache = require('N/cache');

const getExpensiveData = () => {
const portletCache = cache.getCache({
name: 'DASHBOARD_CACHE',
scope: cache.Scope.PUBLIC
});

let data = portletCache.get({ key: 'summary_data' });

if (!data) {
data = JSON.stringify(calculateSummary());
portletCache.put({
key: 'summary_data',
value: data,
ttl: 300 // 5 minutes
});
}

return JSON.parse(data);
};

Minimize Search Columns

// BAD: Select all columns
search.create({
type: 'salesorder',
columns: ['tranid', 'entity', 'trandate', 'status', 'total',
'shipdate', 'shipmethod', 'location', 'department',
'class', 'memo', 'custbody_field1', 'custbody_field2'
/* ... many unused columns */]
});

// GOOD: Only needed columns
search.create({
type: 'salesorder',
columns: ['tranid', 'entity', 'total'] // Display requirements only
});

User Experience

Provide Actions

Always include clear next steps for users.

// Add action links
portlet.addLine({
text: `
<div style="margin-top: 10px; text-align: right;">
<a href="${viewAllUrl}">View All</a> |
<a href="${createUrl}">Create New</a> |
<a href="${exportUrl}">Export</a>
</div>
`
});

Show Loading States for Complex Portlets

portlet.html = `
<div id="portlet-content">
<div class="loading">
<img src="/images/loading.gif" alt="Loading..." />
<p>Loading data...</p>
</div>
</div>
<script>
// Load data async if needed
jQuery(document).ready(function() {
loadPortletData();
});
</script>
`;

Handle Empty States

if (resultCount === 0) {
portlet.html = `
<div style="text-align: center; padding: 30px; color: #666;">
<img src="/images/icons/check.png" alt="Complete" style="width: 48px;" />
<p style="margin-top: 10px;">No pending approvals!</p>
<p style="font-size: 12px;">All items have been processed.</p>
</div>
`;
} else {
// Show data...
}

Accessibility

Keyboard Navigation

// Ensure links are focusable
portlet.html = `
<a href="${url}" tabindex="0">View Record</a>
`;

// Add skip links for complex portlets
portlet.html = `
<a href="#portlet-main" class="sr-only">Skip to main content</a>
<div id="portlet-main">
<!-- Content -->
</div>
`;

Screen Reader Support

// Use semantic HTML
portlet.html = `
<table role="grid" aria-label="Open Orders">
<thead>
<tr>
<th scope="col">Order #</th>
<th scope="col">Customer</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;

// Add alt text for visual elements
portlet.html = `
<img src="chart.png" alt="Sales chart showing 15% increase this month" />
`;

Color Contrast

/* Ensure readable contrast ratios */
.status-text {
/* Bad: #aaa on #fff = 2.9:1 ratio */
/* Good: #666 on #fff = 5.7:1 ratio */
color: #666;
}

/* Don't rely on color alone */
.overdue {
color: #e74c3c;
font-weight: bold; /* Additional visual cue */
}
.overdue::before {
content: "⚠ "; /* Icon indicator */
}

Mobile Considerations

/* Responsive portlet content */
.portlet-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

.portlet-grid > div {
flex: 1 1 200px; /* Min 200px, grow equally */
}

/* Hide less important columns on mobile */
@media (max-width: 768px) {
.hide-mobile { display: none; }
}

/* Stack elements vertically */
@media (max-width: 600px) {
.portlet-row {
display: block;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
}

Security Best Practices

Respect Permissions

const runtime = require('N/runtime');

const render = (params) => {
const user = runtime.getCurrentUser();

// Check permission before showing data
if (!user.getPermission({ name: 'LIST_CUSTINVC' })) {
params.portlet.html = '<p>You do not have permission to view invoices.</p>';
return;
}

// Filter data by user's restrictions
const filters = [['mainline', 'is', 'T']];

if (!user.roleCenter === 'ADMIN') {
filters.push('AND', ['subsidiary', 'anyof', user.subsidiary]);
}

// Continue with search...
};

Sanitize User Input

const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};

// Use when displaying user-provided data
portlet.addRow({
memo: escapeHtml(record.getValue('memo'))
});

Validate Form Submissions

// In submission handler Suitelet
const params = context.request.parameters;

// Validate required fields
if (!params.custpage_customer) {
throw error.create({
name: 'MISSING_REQUIRED',
message: 'Customer is required'
});
}

// Validate numeric input
const qty = parseInt(params.custpage_qty);
if (isNaN(qty) || qty < 1) {
throw error.create({
name: 'INVALID_QUANTITY',
message: 'Quantity must be a positive number'
});
}

Deployment Checklist

ItemCheck
Role restrictions configured
Performance tested with real data volume
Empty state handled
Error handling in place
Logging for debugging
Mobile display tested
Accessibility reviewed
User documentation created

Common Pitfalls

IssueSolution
Portlet times outReduce search scope, use caching
Data appears staleImplement cache refresh, show timestamp
Broken on certain rolesTest with multiple role perspectives
Layout breaksUse relative widths, test resize
Too many portlets slow pageLimit per-page portlets, use lazy loading

See Also