Things I wish someone had told me before I learned most of this the hard way, one blank error message at a time.
I’ve built enough NetSuite PDF and HTML templates at this point that I’ve stopped trusting the official FreeMarker docs to tell me the whole story. They’re not wrong, exactly; they’re just incomplete in the specific ways that cost you an afternoon.
This post is the list I wish I’d had: the stuff you only learn by breaking a template in production, staring at a blank error message, and eventually finding the answer in a five-year-old forum thread with two replies.
None of this is exotic. It’s the boring, load-bearing knowledge that separates “the template renders” from “the template renders correctly, every time, for every customer.” Let’s get into it.
The “!” Operator vs. the “?” Operator
FreeMarker’s built-ins look simple until NetSuite’s implementation of them starts behaving differently depending on context. ?string, ?number, and ?has_content work fine most of the time, but throw a nested sublist field or an odd transaction field type at them, and instead of throwing a clean error, they just quietly fail. You end up staring at a blank cell wondering what went wrong, because nothing in the logs tells you.
The distinction that actually matters:
${record.custbody_myfield?string} // type coercion
${(record.custbody_myfield)!''} // safe null fallback
My rule of thumb after getting burned a few times: “?” is for coercing a type (number, string), “!” is for handling something that might not be there. Don’t lean on “?” alone and expect it to save you from a missing field; it won’t.
“?then” for Inline Conditionals
The docs mention “?then” almost in passing, which is an injustice, because it’s one of the more genuinely useful operators once you start writing conditional labels, CSS classes, or short inline text. It takes a boolean and returns one of two values, right there in the expression, no need to break out a full <#if> block for something this small.
${(record.quantity > 0)?then('In Stock', 'Out of Stock')}
A few places this shows up constantly in real templates:
<td class="${(record.amount < 0)?then('negative', 'positive')}">
${(record.custbody_approved == 'T')?then('Approved', 'Pending')}</td>
<td>${(record.shipmethod??)?then(record.shipmethod, 'N/A')}</td>
One catch: ?then only works on boolean expressions, and NetSuite is not forgiving about it. Wrap your condition in parentheses, especially anything more complex than a single comparison, or it’ll throw an error that doesn’t point anywhere useful.
Use gt and lt Instead of > and <
This is the one that gets almost everyone at least once. Inside an XML or HTML-flavored template, the > and < characters conflict with actual markup tags, so comparisons written the “normal” way tend to break silently, or with an error that gives you no clue it’s a markup collision and not a logic bug.
What breaks:
<#if record.amount > 0> <!-- this breaks the parser -->
What works:
<#if record.amount gt 0>
<#if record.amount lt 100>
<#if record.amount gte 50>
<#if record.amount lte 200>
| Symbol | Word form |
| > | gt |
| < | lt |
| >= | gte |
| <= | lte |
Null Coalescing and ?has_content
NetSuite fields have three possible states: null, missing entirely, or present but empty, and it’s easy to write a check that only accounts for one of them. ?has_content is the one built-in that actually covers all three: it returns false for null, missing, empty string, empty sequence, and empty hash alike. If I only remember one defensive check, it’s this one.
<#if record.custbody_notes?has_content>
${record.custbody_notes}
</#if>
For a default value instead of a conditional block:
${record.custbody_notes!'No notes provided'}
And for nested fields, chain it the same way:
${(record.entity.companyname)!'Unknown Company'}
Wrapping the nested access in parentheses before the “!” matters more than it looks like it should; without it, a null parent object throws instead of falling through to your default.
Looping and Nesting Loops
Basic loops are the easy part. Nesting them is where things get messy, mostly because it’s surprisingly easy to let an inner loop’s variable name shadow the outer one without noticing until the data comes out wrong. Beyond that, ?index gives you a zero-based position, ?counter gives you one-based, and ?is_first / ?is_last are what you want for styling the edges of a table.
A plain sublist loop:
<#list record.item as line>
<tr>
<td>${line.item}</td>
<td>${line.quantity}</td>
</tr>
</#list>
Zebra striping with first/last handling:
<tr class="${line?is_first?then('first-row', '')} ${line?is_last?then('last-row', '')}">
<td>${line?counter}. ${line.item}</td>
</tr>
Variable Mutation and Building New Collections
You can’t reassign or mutate the variable you’re currently iterating over, and you can’t modify a record’s fields directly mid-template either. Accumulating values with #assign works fine; it’s direct mutation that’s off the table.
Don’t try this:
<#assign record.item = record.item + [newitem]> <!-- errors or gets silently ignored -->
Build a fresh variable instead:
<#assign filteredItems = []>
<#list record.item as line>
<#if line.quantity gt 0>
<#assign filteredItems = filteredItems + [line]>
</#if>
</#list>
Date Fields Are Already Date Objects
This one surprised me the first time I ran into it: NetSuite hands date fields to FreeMarker already as date objects, not strings. There’s no parsing step needed; you can format straight from “?string” with a pattern.
${record.trandate?string['MM/dd/yyyy']}
${record.trandate?string['MMMM dd, yyyy']}
${record.trandate?string['dd-MMM-yyyy']}
You can also compare two dates directly, no conversion required:
<#if record.trandate gt record.duedate>
<span style="color:red;">Overdue</span>
</#if>
Skip the instinct to stringify first; it’s an extra step that buys you nothing and makes the template harder to read.
Expression Evaluation Inside ${ } Blocks
Every ${ } block gets evaluated at render time, which means a partial expression referencing an undefined variable won’t just fail quietly in that one spot; it can take down the whole template render. Wrap complex expressions fully and don’t assume NetSuite will forgive a loose end.
Arithmetic directly in output:
${(record.amount * 1.1)?string['0.00']}
Arithmetic in an assignment:
<#assign tax = record.amount * 0.17>
<#assign grandTotal = record.amount + tax>
${grandTotal?string['0.00']}
And inline conditional math, if you want to be compact about it:
${(record.quantity gt 0)?then(record.quantity * record.rate, 0)?string['0.00']}
PDF vs. HTML Template Differences
Here’s something that’s cost me more debugging time than it should have: NetSuite handles nested and complex objects differently depending on whether you’re in a PDF or an HTML template. HTML templates are generally forgiving about traversing nested sublists. PDF templates, less so, some objects need to be explicitly unwrapped before they’ll render sensibly.
Address and entity fields are a good example:
${record.entity?string} <!-- may just render an ID -->
${(record.entity.displayname)!''} <!-- this is what you actually want -->
Test PDF and HTML versions of a template separately, even when they’re meant to produce “the same” output. I’ve seen identical fields behave differently across the two more than once.
Custom Fonts in PDF Templates
Custom fonts are officially supported, but there are enough undocumented ways for this to fail that it’s worth walking through carefully. The short version: upload your .ttf or .otf file to the File Cabinet, then reference it from your template’s style block.
The failure points that actually come up in practice:
- URL format matters. Use the direct media URL from the File Cabinet; the PDF renderer won’t follow redirects.
- Some commercial fonts are subset-locked. If characters render as boxes, that’s usually embedding permissions, not a bug in your template.
- Bold and italic aren’t synthesized. The renderer won’t fake them from the regular weight; you need to upload and declare each variant as its own file.
- Latin character sets are the only thing reliably supported out of the box. For Arabic, Urdu, or other RTL scripts, you’ll need a font that actually covers those Unicode ranges.
The MICR Font and the BFO Paragraph-Wrapping Quirk
This is the one that ate three days of my life, hence the subtitle of this post. NetSuite’s BFO renderer silently wraps <td> content in an implicit <p> tag at render time. Any font styling applied directly to a <table> or <td> just never reaches the text, because by the time it renders, the text is sitting inside a paragraph tag you never wrote and can’t see. You have to target that implicit paragraph directly:
.micr-line p {
font-family: MICR;
font-size: 24pt;
letter-spacing: 1px;
}
Register the font via a <link> tag in the PDF header. Escape every ampersand in the src URL as &, and set subset=”false”; MICR fonts depend on non-standard glyph mappings that get stripped out otherwise:
<link name="MICR"
type="font"
subtype="opentype"
src="https://[ACCOUNTID].app.netsuite.com/core/media/media.nl
?id=FILEID&c=ACCOUNTID&h=HASH&_xt=.ttf"
src-bold="https://[ACCOUNTID].app.netsuite.com/core/media/media.nl
?id=FILEID&c=ACCOUNTID&h=HASH&_xt=.ttf"
bytes="2"
subset="false" />
Adding Watermarks
Not documented anywhere I could find, but it works: absolute positioning with a lightly colored div.
<div style="position: absolute; top: 40%; left: 15%;
font-size: 80pt; color: #E0E0E0;
font-weight: bold;">
DRAFT
</div>
Opacity and CSS transforms are only partially supported in the BFO PDF renderer. If opacity doesn’t take, use a light hex color instead; it gets you the same visual effect more reliably. For a watermark that repeats across every page, put the div in the header-footer wrapper rather than the body.
Checking Stock Alignment with a Background Macro
If you’ve ever built a check-printing template, you know pixel-perfect alignment is basically the whole job. The most reliable way I’ve found to get there: upload a photo of your actual check stock to the File Cabinet, register it as a background macro, and reference it in the body tag. Your absolutely positioned fields then render right on top of the image, so you can eyeball alignment instead of guessing.
<macrolist>
<macro id="nlbackgroundmacro">
<img src="https://[ACCOUNTID].app.netsuite.com/core/media/media.nl
?id=FILEID&c=ACCOUNTID&h=HASH" />
</macro>
</macrolist>
<body padding="0.5in 0.5in 0.5in 0.5in" size="Letter" background-macro="nlbackgroundmacro">
Once alignment checks out, pull the background macro before running against real, pre-printed stock; every field stays positioned with absolute, inline-styled tables regardless.
Dynamic URLs and PDF Metatags
Clickable links work fine in HTML email templates. In PDFs, whether a link is actually clickable depends entirely on the viewer someone opens it in, so don’t assume it’ll always work.
Building a dynamic URL:
<#assign baseUrl = 'https://system.netsuite.com/app/accounting/transactions/'>
<a href="${baseUrl}transaction.nl?id=${record.internalid}">Open Record</a>
For Advanced PDF Metatags, document-level metadata inside the PDF wrapper:
<pdf>
<meta name="title" content="Invoice #${record.tranid}"/>
<meta name="subject" content="Invoice from ${record.companyname}"/>
<meta name="author" content="My Company"/>
<body>...</body>
</pdf>
Escaping Ampersands in URLs: Write & Not &
NetSuite treats the whole template as XML when it’s inside the <pdf> wrapper, so a raw & anywhere, font src, image src, href, doesn’t matter and will break the parser without telling you why.
This breaks:
src="media.nl?id=123&c=456&h=789"
This doesn’t:
src="media.nl?id=123&c=456&h=789"
The same rule applies to font registration and background macro images:
<link name="MICR" type="font" subtype="opentype"
src="https://[ACCOUNTID].app.netsuite.com/core/media/media.nl
?id=FILEID&c=ACCOUNTID&h=HASH&_xt=.ttf"
subset="false" />
No error message points at the URL as the problem, which makes this one of the more frustrating things to debug blind. If a template fails silently, check every & first.
Barcodes: Strip Hyphens Before Rendering
NetSuite renders barcodes fine via the barcode tag, but plenty of formats, Code 39 especially, don’t support hyphens in the value string. A transaction ID like INV-00123 will either fail to render or come out garbled.
The fix is a one-liner:
<#assign barcodeValue = record.tranid?replace('-', '')>
<barcode type="code128" value="${barcodeValue}" width="200" height="50"/>
| Type | Supports hyphens | Alphanumeric | Best for |
| code128 | Yes | Yes | General purpose |
| code39 | No | Yes (limited) | Simple serial numbers |
| qr | Yes | Yes | URLs and long strings |
| ean13 | No | Numeric only | Retail products |
If there’s any chance your value contains special characters, default to code128 or qr and save yourself the headache.
Macros for Reusable Template Blocks
FreeMarker macros are about as close as you get to functions inside a NetSuite template, reusable blocks of markup that accept parameters. Once a template grows past a page or two, macros are what keep it maintainable instead of a wall of repeated markup.
Defining one:
<#macro lineRow line showTax=false>
<tr>
<td>${line.item}</td>
<td>${line.description!''}</td>
<td>${line.quantity}</td>
<#if showTax><td>${(line.tax)!'0.00'}</td></#if>
</tr>
</#macro>
Calling it:
<#list record.item as line>
<@lineRow line=line showTax=true />
</#list>
Injecting Custom Data into PDF Templates
For anything genuinely complex, I’ve had the best luck moving the logic out of the template entirely. Compute and package the data server-side in a Before Load user event script on the Print action, serialize it as JSON, and stash it in a custpage field. That field only exists for the page’s lifecycle, but it’s fully available in the template context, which is exactly what you want.
In the Before Load script:
const checkData = {
micr: 'a021000021a 123456789c 0042',
routingNumber: '021000021',
accountNumber: '123456789',
checkNumber: '0042'
};
record.setValue({
fieldId: 'custpage_check_data',
value: JSON.stringify(checkData)
});
Notice the plain letters sitting inside the micr value instead of actual transit/on-us glyphs. This script runs before the template even exists, so there’s no font context to render a special character correctly yet. Real MICR fonts solve this by remapping ordinary letters to the E-13B symbols at render time: a becomes the transit symbol and c becomes the on-us symbol once the .micr-line font from Tip #10b is applied to this string. Type the letters here; let the font do the transformation in the PDF.
Then parse and use it in the template in one line:
<#assign checkData = record.custpage_check_data?eval_json>
${checkData.micr}
${checkData.routingNumber}
This keeps the template itself free of business logic, moves all the fiddly data prep into code you can actually unit test, and sidesteps a lot of what NetSuite doesn’t expose natively in the template context. It’s especially handy for MICR line construction, where the routing number, account number, and check number all have to line up in an exact format.
Template Includes and Shared Snippet Libraries
Beyond macros within a single template, NetSuite also lets you pull in shared content from another file. If you’ve got header or footer logic you’re repeating across templates, break it out and include it instead:
<#include "/path/to/shared_macros.ftl">
That path is relative to the File Cabinet. Keeping a shared macros library in one place means every template that references it benefits when you fix or improve something, currency formatters, page-break helpers, and address blocks are the patterns I reach for most.
A Few Quick Reference Bits
Force a page break:
<div style="page-break-before: always;"></div>
Zebra striping without the ?is_first/?is_last dance:
background-color: ${(line?index % 2 == 0)?then('#f9f9f9', '#ffffff')}
A running total across rows:
<#assign runningTotal = 0>
<#list record.item as line>
<#assign runningTotal = runningTotal + line.amount>
<td>${runningTotal?string['0.00']}</td>
</#list>
Boolean custom fields at the body level, real Booleans, no ‘T’/’F’ comparison needed (see Tip #22 for why line-level fields are different):
${(record.custbody_flag)?then('Yes', 'No')}
Safe number formatting that won’t crash on a blank field:
${(record.amount?number)!0?string['#,##0.00']}
Line count:
Total Lines: ${record.item?size}
Detecting the print language a record is being rendered in:
<#if .locale == 'zh_CN'>
${chineseLabel}
<#else>
${englishLabel}
</#if>
Checking which FreeMarker version your account is running, since NetSuite doesn’t publish it anywhere:
${.version}
Defaulting Non-String Values When “!” Falls Short
Tip #4 leans on the “!” operator for null-safe fallbacks, and most of the time that’s the right call. Where it gets unreliable is when the expression on the left isn’t a guaranteed simple string, a dynamically built expression, a value pulled from a sublist, or a field that might resolve to something other than a string. Instead of falling back cleanly, “!” can fail quietly or throw an error that doesn’t explain what went wrong.
When that happens, skip “!” and build the fallback with a string operation instead:
<#assign variable = unsafe_expr?has_content?string(unsafe_expr, default_expr) />
This isn’t a wholesale replacement for “!”, keep using it for the simple cases in Tip #4. Reach for this pattern specifically when the value you’re defaulting to isn’t guaranteed to behave like a plain string.
Grouping List Items into Hashes and Sequences
Tip #6 covered building a filtered Sequence with #assign instead of mutating one directly. The same restriction applies to Hashes; you can’t add a key to one in place either, which matters the moment you need to group sublist items by a field instead of just filtering them. A common case: bucketing transaction lines into named groups, with anything that doesn’t match a group falling into a catch-all.
Build both the Hash and the catch-all Sequence before the loop starts, then reassign the Hash on every line that needs a new or updated group:
<#assign solutions = {} />
<#assign aLaCarteItems = [] />
<#list transaction.item as listItem>
<#if listItem.custcol_skiponinvoice != 'T'>
<#assign solutionName = (listItem.custcol_f3_marketing_bundle?has_content)?string(listItem.custcol_f3_marketing_bundle, 'A La Carte') />
<#if solutionName == 'A La Carte'>
<#assign aLaCarteItems += [listItem] />
<#else>
<#if solutions[solutionName]?has_content>
<#assign solution = solutions[solutionName] />
<#else>
<#assign solution = [] />
</#if>
<#assign solution += [listItem] />
<#assign solutions += {solutionName: solution} />
</#if>
</#if>
</#list>
The += shorthand for both the Hash and the Sequence is doing the same thing as tip #6’s reassignment, just less to type. Referencing a key on solutions before it exists throws a missing-key error instead of returning something empty, so check ?has_content first, the same defensive habit as everywhere else on this list.
Boolean Fields: Body vs. Line-Level Data Types
Same underlying field type, different behavior depending on where it lives. A body-level checkbox field comes into the template as an actual Boolean, but the same checkbox on a sublist line comes through as a String, ‘T’ or ‘F’. Write the comparison for the wrong level, and you won’t get an error; the condition just silently evaluates to false every time.
<#if record.custbody_chkbx>
<#if record.item[0].custcol_chkbx == 'T'>
This is one of the sneakier bugs on this list because nothing fails loudly. If a checkbox condition is mysteriously always false, check whether you’re comparing a line-level value like it’s a body-level Boolean, or the other way around, before you assume the underlying data is wrong.
Date Arithmetic: Adding Days to a Date Field
Tip #7 covers formatting and comparing date fields directly, since NetSuite hands them to FreeMarker as date objects already. Doing arithmetic on them, adding a week to check against an upcoming ship-date cutoff, for example, takes one extra step: drop down to the underlying epoch value with “?long”, do the math in plain milliseconds, then convert back with ?number_to_date.
<#assign oneWeekLater = (.now?long + 604800000)?number_to_date>
<#if remainingQty gt 0 && item.expectedshipdate lte oneWeekLater>
604,800,000 is 7 days in milliseconds (7 × 24 × 60 × 60 × 1000), so double-check the constant if you copy this for a different window. Swap .now for any other date field to run the same math starting from a date that isn’t today.
Conditional Watermarks via Background Macro
Tip #11 covers an always-on DRAFT watermark using a positioned div. If you only want the watermark to show up under certain conditions, say, only while a transaction is still Pending Approval or has been Rejected, register it as a macro instead and swap it in and out of the body tag based on the record’s state.
Add the macro to the macro list:
<macro id="nlWatermark">
<table>
<tr>
<td><img src="DRAFT_WATERMARK_URL" /></td>
</tr>
</table>
</macro>
Then decide whether to apply it based on approval status, and pass the result straight into background-macro:
<#assign draftWatermark = '' />
<#if record.approvalstatus == 'Pending Approval' || record.approvalstatus == 'Rejected'>
<#assign draftWatermark = 'nlWatermark' />
</#if>
<body background-macro="${draftWatermark}">
The trick is that background-macro is happy to receive an empty string, so you don’t need a separate conditional wrapped around the whole <body> tag just to turn the watermark off for approved transactions.
Merging Multiple PDFs with <pdfset>
None of the tips above cover combining separate PDFs into a single output file, but it comes up more than you’d expect, printing an invoice and its packing slip as one download, for example. The <pdfset> tag handles it: point it at existing file URLs, or nest a full <body> for content you want generated inline, and render.xmlToPdf() merges everything into one file.
var xml = `
<?xml version="1.0"?>
<!DOCTYPE pdf PUBLIC "-//big.faceless.org//report" "report-1.1.dtd">
<pdfset>
<pdf src="${pdf1Url}" />
<pdf src="${pdf2Url}" />
<pdf>
<body>
<img src="${pngUrl}" />
</body>
</pdf>
</pdfset>
`;
var pdf = render.xmlToPdf({ xmlString: xml });
Each <pdf src=”…”> entry pulls in an already-rendered file as-is. The nested <pdf><body>…</body></pdf> block is for content generated on the fly, so you can mix pre-existing PDFs with dynamically built pages in the same merged file.
Rendering Email HTML from a PDF Template
render.mergeEmail() covers most email-templating needs, but it doesn’t accept a custom data source, which is a problem if the email body depends on data you’ve assembled server-side rather than pulled straight from a record. The workaround: build the email as an Advanced PDF Template instead, just with email markup inside the <pdf> wrapper, then render it manually so you can attach a custom data source.
<pdf>
Email HTML goes here...
</pdf>
Render it with render.create(), attach the data, and pull the string back out:
const renderer = render.create();
renderer.setTemplateByScriptId({
scriptId: APPROVAL_EMAIL_TEMPLATE.scriptId,
});
renderer.addCustomDataSource({
format: render.DataSource.OBJECT,
alias: 'JSON',
data: poDetailsWithApprovalHierarchy,
});
emailResult.body = renderer.renderAsString().replace(/pdf/g, 'html');
That .replace(/pdf/g, ‘html’) at the end isn’t cosmetic; the rendered output still carries pdf-flavored markup from the template type, and email clients won’t render it cleanly until that’s cleaned up.
Closing Thoughts
NetSuite’s FreeMarker engine is genuinely powerful, but it’s poorly documented right at the edges — which, unfortunately, is exactly where most real templates live. Almost everything above I found the hard way: trial, error, and a lot of old forum threads. A short list of the things worth actually remembering:
- Reach for “!” over “?” when you’re guarding against something that might not be there.
- Use gt and lt instead of > and < in any comparison, always.
- Treat date fields as date objects from the start. Don’t stringify them before you need to.
- Macros pay for themselves the moment a template gets past a page or two.
- Test PDF and HTML versions separately; they don’t always agree.
- Barcode values need to be clean. Strip hyphens and anything else non-standard before you pass them in.
Bookmark this one for the next time you’re three hours into a PDF template that refuses to cooperate. You’re not the first person this has happened to, and you won’t be the last.
Related Reads
If this scratched the surface for you, the posts below go deeper on connecting NetSuite to AI tools and automating the workflows around it, so you can spend less time on repetitive setup and more time on the problems that are actually interesting.
Getting Started
- A Complete Setup Guide for NetSuite AI Connector
- A Setup Guide for NetSuite AI Connector with Postman: API Integration Tutorial
- Getting Started with NetSuite SuiteTalk REST API in Postman
- NetSuite MCP OAuth 2.0 Token Generator Tool
- SuiteScript Essentials: A Developer’s Getting Started Guide
- Open Source for Dummies: A Beginner’s Open Source Journey
MCP + Client Integrations
- IDE Integration Guide for NetSuite MCP Tools in Cursor & VS Code
- Connecting MCP with ChatGPT: A Complete Guide
- Connecting MCP Tools with Qwen
- Connecting ChatGPT Business with NetSuite via MCP: The Future of Enterprise AI Integration
- OAuth 2.0 in NetSuite: Complete Setup Guide (Client Credentials Flow)
Advanced & Architecture
- A Development Guide to Build Custom Tools for NetSuite AI Connector
- Dual API Integration: Using NetSuite MCP Tools with OpenAI and Anthropic
- NetSuite MCP Challenge: Implementation Case Study & Results
- MCP Input Formats Compared: Token Usage Analysis for NetSuite MCP Tools
- Integrating NetSuite MCP Tools with AI-Powered CLI Tools