Suitelets are the last big conversion in this series, and they are the one where people underestimate the work. User Events and scheduled scripts are self-contained. RESTlets have a clear contract. A Suitelet is different, because “Suitelet” is not really one thing. It is a script type that got used for everything nobody had a better home for, and in most accounts that means a folder full of scripts that have almost nothing in common with each other.
So before you write a line of 2.1 code, sort them.
Two piles, not one
Open every Suitelet in your account and put it in one of two piles.
Pile one is a real form. It builds a page with fields, the user fills it in, hits Submit, something happens. These convert cleanly to N/ui/serverWidget and the work is mechanical.
Pile two is an HTTP endpoint wearing a form costume. It returns JSON to a client script. It spits out a CSV. It renders a chunk of HTML for a dashboard portlet. It takes a query string, does something, and redirects. There is no nlapiCreateForm anywhere in it.
The second pile is where the interesting decisions are, and I will come back to it. Start with pile one, because it is the bulk of them and it teaches you the module.
Converting a real form
Here is a 1.0 Suitelet that a hundred accounts have some version of: a small form that takes a customer and a memo, and on submit creates a case.
function quickCaseSuitelet(request, response)
{
if (request.getMethod() == 'GET')
{
var form = nlapiCreateForm('Quick Case');
var custField = form.addField('custpage_customer', 'select', 'Customer', 'customer');
custField.setMandatory(true);
form.addField('custpage_memo', 'textarea', 'Details');
form.addSubmitButton('Create Case');
response.writePage(form);
}
else
{
var customer = request.getParameter('custpage_customer');
var memo = request.getParameter('custpage_memo');
var supportCase = nlapiCreateRecord('supportcase');
supportCase.setFieldValue('company', customer);
supportCase.setFieldValue('title', 'Quick case');
supportCase.setFieldValue('incomingmessage', memo);
var caseId = nlapiSubmitRecord(supportCase);
nlapiSetRedirectURL('RECORD', 'supportcase', caseId);
}
}
And the 2.1 version. Same behaviour, different plumbing.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
*/
define(['N/ui/serverWidget', 'N/record', 'N/redirect'], (serverWidget, record, redirect) => {
const onRequest = (context) => {
if (context.request.method === 'GET') {
renderForm(context.response);
} else {
handleSubmit(context.request, context.response);
}
};
const renderForm = (response) => {
const form = serverWidget.createForm({ title: 'Quick Case' });
const custField = form.addField({
id: 'custpage_customer',
type: serverWidget.FieldType.SELECT,
label: 'Customer',
source: 'customer'
});
custField.isMandatory = true;
form.addField({
id: 'custpage_memo',
type: serverWidget.FieldType.TEXTAREA,
label: 'Details'
});
form.addSubmitButton({ label: 'Create Case' });
response.writePage(form);
};
const handleSubmit = (request, response) => {
const customer = request.parameters.custpage_customer;
const memo = request.parameters.custpage_memo;
const supportCase = record.create({ type: record.Type.SUPPORT_CASE });
supportCase.setValue({ fieldId: 'company', value: customer });
supportCase.setValue({ fieldId: 'title', value: 'Quick case' });
supportCase.setValue({ fieldId: 'incomingmessage', value: memo });
const caseId = supportCase.save();
redirect.toRecord({ type: record.Type.SUPPORT_CASE, id: caseId });
};
return { onRequest };
});
Four things changed that are worth naming, because they are the ones you will keep hitting.
The two arguments became one. In 1.0 you got (request, response). In 2.x you get a single context and reach into context.request and context.response. The entry point is always called onRequest, and that name is not optional.
getMethod() became request.method, a property rather than a call. It still returns the string ‘GET’ or ‘POST’.
request.getParameter('x') became request.parameters.x. Also a plain property. This one catches people because the old form does not throw a helpful error, it just gives you a function-not-found further down.
Setters became properties on the field object. setMandatory(true) is now field.isMandatory = true. Same for isDisplay, defaultValue, padding, and most of the others. There is no setDefaultValue in 2.x. If your muscle memory reaches for one, that is why nothing happens.
The field ID rule that breaks the first deploy
In 1.0 you could get away with mixed case in a field ID. In 2.x you cannot. Internal IDs are lowercase, full stop, and if you write custpage_customerName you get an SSS_INVALID_FORM_ELEMENT or a field that silently never receives a value. Use underscores: custpage_customer_name.
The custpage_ prefix itself is worth being precise about. It is genuinely required when you are adding a field to an existing NetSuite page from a User Event beforeLoad. On a Suitelet form you build from scratch, NetSuite will accept an unprefixed ID. Use the prefix anyway. It keeps your field IDs from ever colliding with a real record field, and it means every developer who opens the script knows immediately that the field is script-generated and not a customization someone made in the UI.
Sublists, and the one that will waste your afternoon
Sublists convert the same way as fields, with one behavioural difference that is not obvious from the docs and costs people real time.
const sublist = form.addSublist({
id: 'custpage_results',
type: serverWidget.SublistType.LIST,
label: 'Open Invoices'
});
sublist.addField({
id: 'custpage_invoice_number',
type: serverWidget.FieldType.TEXT,
label: 'Invoice'
});
sublist.setSublistValue({
id: 'custpage_invoice_number',
line: i,
value: invoiceNumber
});
Here is the part that bites. setSublistValue does not accept an empty value. Pass it null, undefined, or an empty string and it throws, which means one blank field on row 340 of an otherwise fine result set kills the whole page render. In 1.0 the equivalent call shrugged and moved on.
So guard it. Every time.
const setIfPresent = (sublist, id, line, value) => {
if (value !== null && value !== undefined && value !== '') {
sublist.setSublistValue({ id: id, line: line, value: value });
}
};
It looks like defensive noise until the first time a customer record has no phone number and your whole Suitelet returns a stack trace instead of a page.
Now the second pile
Back to the Suitelets that were never forms. These need a decision, not a translation.
If it returns JSON to a client script, the conversion is trivial and you should take a minute to ask whether it should stay a Suitelet at all. The 2.1 shape is:
const onRequest = (context) => {
const data = search.lookupFields({
type: search.Type.CUSTOMER,
id: context.request.parameters.id,
columns: ['companyname', 'creditlimit']
});
context.response.write({ output: JSON.stringify(data) });
};
Keep it a Suitelet if the caller is a client script inside NetSuite, because the session handles authentication and there is nothing to configure. Move it to a RESTlet if the caller is anything outside NetSuite, because then you get token-based authentication, proper HTTP verb routing, and you are not relying on a Suitelet deployment being reachable. If you have an external system calling a Suitelet today with Available Without Login checked, that is not an integration, that is an open endpoint, and this migration is a good moment to fix it.
If it returns HTML, use response.write the same way, and set the content type explicitly rather than trusting the default:
context.response.setHeader({ name: 'Content-Type', value: 'text/html' });
context.response.write({ output: myHtmlString });
If it returns a file, use response.writeFile with an N/file object. Do not hand-roll the headers.
Attaching a client script
If your 1.0 Suitelet had a client script bolted on with form.setScript(), that is gone. In 2.1 you have two options on the form object:
form.clientScriptModulePath = './myClientScript.js';
// or
form.clientScriptFileId = 12345;
The module path is relative to the Suitelet file in the File Cabinet, which makes it the better choice, because it survives being moved between accounts. The file ID does not, and you will forget to update it when you deploy to production. I have watched that happen more than once.
Testing, and the thing sandbox will not tell you
Suitelets are the easiest script type to test, because you just open the URL. Deploy the 2.1 version under a new Script and Deployment ID, open both, and click through them side by side.
Two things sandbox will not catch on its own, so check them deliberately.
First, roles. A Suitelet runs with the permissions of whoever opened it, and the audience on the new deployment is a fresh record with fresh defaults. Test as an actual end user in the role that uses it, not as Administrator. “It works for me” is Administrator talking.
Second, anything that links to it. Suitelet URLs contain the script and deployment ID, so a new deployment means a new URL, and every dashboard link, saved-search-driven button, email template, and bookmark that pointed at the old one is now pointing at the old one still. Grep your account for the old script ID before you undeploy anything. Custom center tabs and portlet links are the usual stragglers.
Then the same rollback discipline as the rest of this series: leave the 1.0 version deployed but with no audience for a month before you delete it.
That is the whole conversion
Across this series we have covered User Events, RESTlets, scheduled scripts, and now Suitelets. Between them that is the overwhelming majority of what is sitting in your account on SuiteScript 1.0, and every one of them has to be off it before 2027.1 removes the runtime.
If you have not done the audit yet, that is still step one, and it is still the step that turns a vague sense of dread into a finite list. Everything after it is just work.
The NetSuite Pro takes on SuiteScript 1.0 to 2.1 migrations as fixed-fee engagements, from the audit through to the production cutover. Get in touch.
Discover more from The NetSuite Pro
Subscribe to get the latest posts sent to your email.
Leave a Reply