← All posts
SuiteScriptMigrationSuiteScript 1.0SuiteScript 2.1

SuiteScript 1.0 to 2.1 Migration: What Actually Changes

Ryzoa··8 min read

SuiteScript 1.0 to 2.1 Migration: What Actually Changes

The most common piece of advice about SuiteScript 1.0-to-2.1 migrations is "read the documentation." That advice is not wrong, but it misses the practical problem: the documentation describes what the 2.1 API looks like, not how to get from your specific 1.0 code to a working 2.1 equivalent without breaking things.

This post is the translation guide I wish existed when I first started doing these migrations. Not philosophy. Actual diffs.

The Module System Change Is Not Optional

In SuiteScript 1.0, there is no module system. Your script is a JavaScript file. NetSuite injects the nlapi global namespace and your functions are available by name. For a User Event script, you just define function beforeLoad(type, form, request) and NetSuite finds it.

In SuiteScript 2.1, every file must use AMD module syntax. All N/ modules must be explicitly declared as dependencies. The file must include annotation headers. Without these, the file is not a valid 2.1 script; NetSuite will refuse to save it as a script record.

The minimum valid 2.1 User Event script structure looks like this:

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 * @NModuleScope SameAccount
 */
define(['N/record', 'N/log'], (record, log) => {
  const beforeLoad = (context) => {
    // your logic here
  };
 
  const beforeSubmit = (context) => {
    // your logic here
  };
 
  const afterSubmit = (context) => {
    // your logic here
  };
 
  return { beforeLoad, beforeSubmit, afterSubmit };
});

Three things to note about the annotations:

  • Use @NApiVersion 2.1, not 2.0. It adds support for modern JavaScript syntax (template literals, destructuring, const/let, arrow functions) and the N/query module.
  • @NScriptType must exactly match the script type as NetSuite recognises it: UserEventScript, ClientScript, ScheduledScript, Restlet, MapReduceScript, Suitelet, MassUpdateScript, PortletScript, WorkflowActionScript.
  • Use @NModuleScope SameAccount for all custom scripts. Public and Provisioned are for SuiteApp development.

Function Signatures Change for Every Script Type

In SuiteScript 1.0, event handler functions received positional parameters:

// 1.0 User Event
function beforeSubmit(type) {
  // type is a string: 'create', 'edit', 'delete', etc.
  var entity = nlapiGetFieldValue('entity');
}

In 2.1, event handlers receive a single context object. The record is accessed through context.newRecord (for the new state) and context.oldRecord (for the pre-save state, in beforeSubmit and afterSubmit):

// 2.1 User Event
const beforeSubmit = (context) => {
  const type = context.type; // context.UserEventType.EDIT, etc.
  const entity = context.newRecord.getValue({ fieldId: 'entity' });
};

For Client Scripts, the signature change is similar but the event names differ:

// 1.0 Client Script
function pageInit(type) {
  nlapiSetFieldValue('custbody_status', 'pending');
}
 
function fieldChanged(type, name) {
  if (name === 'entity') {
    // do something
  }
}
// 2.1 Client Script
const pageInit = (context) => {
  context.currentRecord.setValue({ fieldId: 'custbody_status', value: 'pending' });
};
 
const fieldChanged = (context) => {
  if (context.fieldId === 'entity') {
    // do something
  }
};

The context.currentRecord object in 2.1 client scripts is a live reference to the form record. It is not the same as a server-side record.load(): it is a thin wrapper around the form fields that are currently rendered. This is an important distinction: you cannot call context.currentRecord.save() from a Client Script event. You work with the record in memory and NetSuite handles the save.

The Core API Translation Table

Here are the most commonly used 1.0 functions and their 2.1 equivalents.

Loading and Saving Records

1.0:

var rec = nlapiLoadRecord('salesorder', 1234);
nlapiSubmitRecord(rec);

2.1:

const rec = record.load({ type: record.Type.SALES_ORDER, id: 1234 });
rec.save();

Note: record.Type.SALES_ORDER is a constant defined by the N/record module. You can also pass the string 'salesorder' directly, but using the constants makes refactoring easier and avoids typos.

Getting and Setting Field Values

1.0:

var val = nlapiGetFieldValue('custbody_approval_status');
nlapiSetFieldValue('custbody_approval_status', 'approved');

2.1 (server-side, on a loaded record):

const val = rec.getValue({ fieldId: 'custbody_approval_status' });
rec.setValue({ fieldId: 'custbody_approval_status', value: 'approved' });

2.1 (client-side, in a client script event):

const val = context.currentRecord.getValue({ fieldId: 'custbody_approval_status' });
context.currentRecord.setValue({ fieldId: 'custbody_approval_status', value: 'approved' });

Submitting Field Updates Without Loading the Full Record

1.0:

nlapiSubmitField('salesorder', 1234, 'custbody_processed', 'T');

2.1:

record.submitFields({
  type: record.Type.SALES_ORDER,
  id: 1234,
  values: { custbody_processed: true }
});

record.submitFields() costs 10 governance units on a transaction record, half the cost of record.load() plus rec.save() at 10 units each. Use it when you only need to update a small number of fields and you do not need the full record object.

Searching

1.0:

var filters = [new nlobjSearchFilter('status', null, 'anyof', ['A', 'B'])];
var cols = [new nlobjSearchColumn('internalid'), new nlobjSearchColumn('tranid')];
var results = nlapiSearchRecord('salesorder', null, filters, cols);
if (results) {
  for (var i = 0; i < results.length; i++) {
    var id = results[i].getValue('internalid');
  }
}

2.1:

const results = search.create({
  type: search.Type.SALES_ORDER,
  filters: [['status', search.Operator.ANYOF, ['A', 'B']]],
  columns: [
    search.createColumn({ name: 'internalid' }),
    search.createColumn({ name: 'tranid' })
  ]
}).run().getRange({ start: 0, end: 1000 });
 
results.forEach(result => {
  const id = result.getValue({ name: 'internalid' });
});

The important difference: nlapiSearchRecord() returned an array of nlobjSearchResult objects and returned null (not an empty array) when there were no results. search.create().run().getRange() always returns an array, which is empty when there are no results. Any 1.0 code that checks if (results) before iterating needs to be updated, but at least the 2.1 version won't throw a null reference error on empty results.

Outbound HTTP Requests

1.0:

var response = nlapiRequestURL(
  'https://api.example.com/webhook',
  JSON.stringify({ orderId: 1234 }),
  { 'Content-Type': 'application/json' },
  'POST'
);
var body = response.getBody();

2.1:

const response = https.post({
  url: 'https://api.example.com/webhook',
  body: JSON.stringify({ orderId: 1234 }),
  headers: { 'Content-Type': 'application/json' }
});
const body = response.body;

The https module returns a plain object with a body property (a string), a code property (integer HTTP status), and a headers property (object). The 1.0 nlapiRequestURL() returned an nlobjResponse object with methods like getBody() and getCode(). This is a type change that will cause a runtime error if you forget to update the property access.

Looking Up a Single Field Without Loading the Record

This pattern does not have a direct 1.0 equivalent: most 1.0 code just loaded the full record. In 2.1, search.lookupFields() is the efficient alternative:

// 1 governance unit vs 10 for record.load() on a transaction
const fields = search.lookupFields({
  type: search.Type.CUSTOMER,
  id: customerId,
  columns: ['companyname', 'custentity_credit_tier']
});
const name = fields.companyname;

If you are migrating 1.0 code that loads a record just to read one or two field values, replace it with search.lookupFields().

What Cannot Be Lifted and Shifted

Some 1.0 patterns require more than a function translation. They require redesign.

Scripts that rely on nlapiGetContext() to check the current user, role, or execution context. In 2.1, this is runtime.getCurrentUser(), runtime.getCurrentScript(), and runtime.executionContext. The API equivalents exist, but scripts that make logic decisions based on execution context often have subtle assumptions about which contexts are possible. These need to be reviewed, not just translated.

Client scripts that use pageInit to set field defaults. As covered in my post on upgrade breakages, pageInit in today's asynchronous UI can run before all form fields are ready. Scripts that read a field value in pageInit to conditionally set another field are fragile in both 1.0 and 2.1. The migration is an opportunity to restructure the event logic: move it to fieldChanged on the trigger field, or to postSourcing if the value comes from a sourced field.

Scheduled Scripts that use nlapiYieldScript() to checkpoint progress. This 1.0 pattern allowed a long-running script to save its state and re-queue itself. In 2.1, the equivalent architecture is Map/Reduce: separate phases with their own governance budgets, built-in parallelism, and automatic retry on failure. A 1.0 Scheduled Script using nlapiYieldScript() should be redesigned as a Map/Reduce script, not translated.

Scripts that use nlapiCreateError() to throw custom errors. The 2.1 equivalent is the N/error module:

// 1.0
throw nlapiCreateError('MY_ERROR', 'Something went wrong', true);
 
// 2.1
const error = require('N/error');
throw error.create({ name: 'MY_ERROR', message: 'Something went wrong', notifyOff: false });

The notifyOff: false parameter controls whether the error triggers a system notification. The 1.0 third parameter (true) mapped to "notify"; the 2.1 parameter is named notifyOff, so the boolean sense is inverted. This is a subtle gotcha that produces incorrect notification behaviour if you translate the parameter value literally.

Running Both Versions in Parallel

During a migration, you cannot run a 1.0 and a 2.1 version of the same script on the same deployment at the same time. The script record is either 1.0 or 2.1; the version is set at the script level, not the deployment level.

The practical approach is to deploy the 2.1 version to a separate deployment record, initially in "Testing" status (so it only runs for administrators), while the 1.0 version remains live. Test the 2.1 version thoroughly in sandbox against real data. When confident, flip the 1.0 deployment to inactive and the 2.1 deployment to released.

Keep the 1.0 file on the file cabinet for a reasonable rollback window (say 30 days), then delete it. Leaving dead script files in the file cabinet indefinitely creates confusion about which version is current.


If you have a body of 1.0 scripts that need migrating and you want it done as a genuine modernisation rather than a literal translation, that is one of the core services I offer.

Have a specific problem in mind?

A 30-minute technical review call to understand what's in your codebase and whether this is the right fit.

Book a technical review