🔹 What is a Suitelet?
A Suitelet is a server-side script that renders custom UI inside NetSuite: forms, lists, tabs, field groups, buttons—even mini apps. You control:
- What fields appear (and when)
- How options are populated (dynamic data from searches/APIs)
- How submissions are processed (save to records, call integrations)
Typical flow:
- GET request → render form
- POST request → read submitted values, do work (save records, call APIs), then show a success page or re-render the form
🔹 Key APIs you’ll use
N/ui/serverWidget
→ build forms, fields, sublists, tabs, helpN/record
,N/search
→ load/save data, populate optionsN/runtime
,N/url
→ parameters, self-redirect for dynamic filtering- (optional)
form.clientScriptModulePath
→ attach a Client Script for richer UX
🔹 Example 1 — Minimal Suitelet (GET/POST) with Dynamic Select
Goal:
- GET: Render a form with a dynamic select of active customers (loaded via search).
- POST: Read values and show a confirmation message.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
*/
define(['N/ui/serverWidget', 'N/search'], (serverWidget, search) => {
const onRequest = (context) => {
try {
if (context.request.method === 'GET') {
// 1) Build the form
const form = serverWidget.createForm({
title: 'Demo: Suitelet Dynamic Form'
});
// 2) Add a field group to keep things tidy
form.addFieldGroup({
id: 'grp_main',
label: 'Selection'
});
// 3) Add a dynamic Select field for Customer
const fldCustomer = form.addField({
id: 'custpage_customer',
type: serverWidget.FieldType.SELECT,
label: 'Customer',
container: 'grp_main'
});
fldCustomer.isMandatory = true;
// 4) Populate select options from a search (active customers)
fldCustomer.addSelectOption({ value: '', text: '-- Select --' });
const custSearch = search.create({
type: search.Type.CUSTOMER,
filters: [['isinactive', 'is', 'F']],
columns: ['entityid']
});
custSearch.run().each((r) => {
fldCustomer.addSelectOption({
value: r.id,
text: r.getValue('entityid')
});
return true;
});
// 5) Add another text field
const fldNote = form.addField({
id: 'custpage_note',
type: serverWidget.FieldType.TEXT,
label: 'Note',
container: 'grp_main'
});
fldNote.maxLength = 200;
fldNote.helpText = 'Optional description stored only for this demo.';
// 6) Add Submit button
form.addSubmitButton({ label: 'Submit' });
// 7) Render the form
context.response.writePage(form);
return;
}
// ---------------- POST: read submitted values ----------------
const req = context.request;
const customerId = req.parameters.custpage_customer;
const note = req.parameters.custpage_note || '';
// In a real app, you could now:
// - create/update a record
// - call an external API
// - enqueue a task
// Show a simple confirmation page
const form = serverWidget.createForm({ title: 'Submitted!' });
form.addField({
id: 'custpage_info',
type: serverWidget.FieldType.INLINEHTML,
label: 'Info'
}).defaultValue = `
<div style="padding:12px 0;">
<b>Success.</b><br/>
Customer ID: ${customerId || '(none)'}<br/>
Note: ${note ? note.replace(/</g,'<') : '(empty)'}
</div>
`;
form.addButton({ id: 'custpage_back', label: 'Go Back', functionName: '' });
context.response.writePage(form);
} catch (e) {
// Basic error page
const errForm = serverWidget.createForm({ title: 'Error' });
errForm.addField({
id: 'custpage_err',
type: serverWidget.FieldType.INLINEHTML,
label: 'Error'
}).defaultValue = `<pre style="color:#b00020">${(e && e.message) || e}</pre>`;
context.response.writePage(errForm);
}
};
return { onRequest };
});
What’s happening (high level):
- GET builds the form, adds fields, and populates select options from a search.
- POST reads
request.parameters
and shows a simple confirmation page. - We use Inline HTML for clean, readable feedback.
🔹 Dynamic Refresh Pattern (server-side filtering)
To change field options based on another field’s value (e.g., subsidiary → customers), you can:
- Submit the form to itself (POST), or
- Use a redirect to GET with query parameters (often cleaner UX).
Below we’ll show a redirect pattern in Example 2.
🔹 Example 2 — Dependent Dropdown (Subsidiary → Customers) via Self-Redirect
Goal:
- Choose Subsidiary → re-render the form filtering customers by subsidiary.
- Keep user inputs when reloading.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
*/
define(['N/ui/serverWidget', 'N/search', 'N/url'], (serverWidget, search, url) => {
const onRequest = (context) => {
try {
const req = context.request;
const selSubs = (req.parameters.sel_subsidiary || '').trim();
const selCustomer = (req.parameters.sel_customer || '').trim();
if (req.method === 'POST') {
// When "Filter" is clicked, redirect to GET with query params (to rebuild form server-side)
const slUrl = url.resolveScript({
scriptId: context.runtime.getCurrentScript().id, // same Suitelet
deploymentId: context.runtime.getCurrentScript().deploymentId,
returnExternalUrl: false
});
const target = `${slUrl}&sel_subsidiary=${encodeURIComponent(selSubs)}&sel_customer=${encodeURIComponent(selCustomer)}`;
context.response.sendRedirect({ type: url.RedirectType.SUITELET, url: target });
return;
}
// ------------------ GET: Render dynamic form ------------------
const form = serverWidget.createForm({ title: 'Dependent Dropdowns' });
form.addFieldGroup({ id: 'grp1', label: 'Filters' });
// 1) Subsidiary select
const fldSubs = form.addField({
id: 'custpage_subs',
type: serverWidget.FieldType.SELECT,
label: 'Subsidiary',
container: 'grp1'
});
fldSubs.addSelectOption({ value: '', text: '-- All --' });
// Populate subsidiaries
search.create({
type: 'subsidiary',
filters: [['isinactive', 'is', 'F']],
columns: ['name']
}).run().each(r => {
fldSubs.addSelectOption({ value: r.id, text: r.getValue('name') });
return true;
});
if (selSubs) fldSubs.defaultValue = selSubs;
// 2) Customer select filtered by subsidiary (if chosen)
const fldCust = form.addField({
id: 'custpage_customer',
type: serverWidget.FieldType.SELECT,
label: 'Customer',
container: 'grp1'
});
fldCust.addSelectOption({ value: '', text: '-- Select --' });
const filters = [['isinactive', 'is', 'F']];
if (selSubs) filters.push('AND', ['subsidiary', 'anyof', selSubs]);
const custSearch = search.create({
type: search.Type.CUSTOMER,
filters,
columns: ['entityid']
});
custSearch.run().each(r => {
fldCust.addSelectOption({ value: r.id, text: r.getValue('entityid') });
return true;
});
if (selCustomer) fldCust.defaultValue = selCustomer;
// 3) Buttons: Filter (POST) and Submit (final submit)
form.addSubmitButton({ label: 'Filter' }); // posts & redirects back with params
form.addButton({ id: 'custpage_submit', label: 'Submit Selection', functionName: '' });
// (In a fuller example, you’d handle Submit Selection with POST business logic)
context.response.writePage(form);
} catch (e) {
const errForm = serverWidget.createForm({ title: 'Error' });
errForm.addField({
id: 'custpage_err',
type: serverWidget.FieldType.INLINEHTML,
label: 'Error'
}).defaultValue = `<pre style="color:#b00020">${(e && e.message) || e}</pre>`;
context.response.writePage(errForm);
}
};
return { onRequest };
});
Why this pattern?
- The server rebuilds filtered options consistently (no client-side logic required).
- Works well even without a Client Script.
- Maintains state via query params.
🔹 Example 3 — Dynamic Sublist Input + Save to a Custom Record
Goal:
- GET: Render a form with a static body fields + editable sublist for line items.
- POST: Read submitted sublist values and create child custom records.
Assumptions:
- You have a custom record type:
customrecord_demo_parent
- And a child type:
customrecord_demo_child
with fieldscustrecord_demo_parent_ref
,custrecord_demo_line_text
,custrecord_demo_line_qty
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
*/
define(['N/ui/serverWidget', 'N/record'], (serverWidget, record) => {
const onRequest = (context) => {
try {
if (context.request.method === 'GET') {
// ---------- Build form ----------
const form = serverWidget.createForm({ title: 'Dynamic Sublist Entry' });
form.addFieldGroup({ id: 'grp_head', label: 'Header' });
const fldTitle = form.addField({
id: 'custpage_title',
type: serverWidget.FieldType.TEXT,
label: 'Title',
container: 'grp_head'
});
fldTitle.isMandatory = true;
// Create an editable sublist
const sub = form.addSublist({
id: 'custpage_lines',
label: 'Lines',
type: serverWidget.SublistType.INLINEEDITOR
});
// Add columns (fields) to the sublist
sub.addField({
id: 'custpage_line_text',
type: serverWidget.FieldType.TEXT,
label: 'Description'
}).isMandatory = true;
sub.addField({
id: 'custpage_line_qty',
type: serverWidget.FieldType.INTEGER,
label: 'Qty'
}).isMandatory = true;
// (Optionally pre-seed a few empty lines for UX)
// Not necessary with INLINEEDITOR; user can add lines directly.
form.addSubmitButton({ label: 'Save' });
context.response.writePage(form);
return;
}
// ---------- POST: Save data ----------
const req = context.request;
const title = req.parameters.custpage_title;
// 1) Create parent custom record
const parentId = record.create({
type: 'customrecord_demo_parent',
isDynamic: true
}).setValue({ fieldId: 'name', value: title }).save();
// 2) Read sublist line count from submitted form
const lineCount = req.getLineCount({ group: 'custpage_lines' });
for (let i = 0; i < lineCount; i++) {
try {
// 3) Read each line’s values
const lineText = req.getSublistValue({
group: 'custpage_lines',
name: 'custpage_line_text',
line: i
});
const lineQty = parseInt(req.getSublistValue({
group: 'custpage_lines',
name: 'custpage_line_qty',
line: i
}) || '0', 10);
if (!lineText || !lineQty) continue;
// 4) Create child record for each line
const child = record.create({
type: 'customrecord_demo_child',
isDynamic: true
});
child.setValue({ fieldId: 'custrecord_demo_parent_ref', value: parentId });
child.setValue({ fieldId: 'custrecord_demo_line_text', value: lineText });
child.setValue({ fieldId: 'custrecord_demo_line_qty', value: lineQty });
child.save();
} catch (lineErr) {
log.error('Line Save Error', lineErr.message);
}
}
// 5) Confirmation page
const form = serverWidget.createForm({ title: 'Saved' });
form.addField({
id: 'custpage_msg',
type: serverWidget.FieldType.INLINEHTML,
label: 'Msg'
}).defaultValue = `<div style="padding:12px 0;">Saved parent #${parentId} and ${lineCount} line(s).</div>`;
context.response.writePage(form);
} catch (e) {
const errForm = serverWidget.createForm({ title: 'Error' });
errForm.addField({
id: 'custpage_err',
type: serverWidget.FieldType.INLINEHTML,
label: 'Error'
}).defaultValue = `<pre style="color:#b00020">${(e && e.message) || e}</pre>`;
context.response.writePage(errForm);
}
};
return { onRequest };
});
What to notice:
- Sublist type
INLINEEDITOR
= user can add lines directly on the Suitelet page. - On POST, use
request.getLineCount()
andrequest.getSublistValue()
to read submitted lines. - Save parent first, then child lines referencing the parent.
🔹 Making it feel “rich”: attach a Client Script
- Set
form.clientScriptModulePath = 'SuiteScripts/your_cs_module.js'
- Handle field dependencies live (no redirect), validate rows before submit, add keyboard shortcuts, etc.
🔹 Best Practices
- GET fast, POST smart: keep rendering light; do heavy work on POST (or queue tasks).
- Validate inputs on POST (and client-side if you attach a Client Script).
- Escape user input when echoing back (avoid HTML injection).
- Use Field Groups / Tabs to organize complex pages.
- For very large lists, consider Sublist = LIST (read-only) plus paging, or build to CSV/Excel download.
- Log errors with enough context (user, params, line index).
âś… Key Takeaway
Suitelets let you build custom mini-apps inside NetSuite. With dynamic fields, dependent selects, editable sublists, and clean GET/POST handling, you can create powerful data entry and review tools tailored to your workflows.