Whenever the same content has to go out to different recipients, Word's mail merge is the least painful way to do it: the parts that vary (the recipient, the fax number, the date) stay in the template as merge fields, the rest of the layout is fixed, and one set of data is filled in at run time. Doing this in the browser is worth the trouble because the template stays on the server, the data never has to be handed to Office, and the user gets the filled-in document with a single click.

Spire.Doc for JavaScript parses merge fields as a class of document element of their own, and the merge entry point is MailMerge.Execute(fieldNameArray, fieldValueArray): the two arrays correspond by index, and a field that exists in the template but is not supplied in the arrays is merged as empty.

This article covers two core features:

For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.


Execute a Mail Merge

Prepare the Template

The template is an ordinary customer contact letter, with the four places that change with the data turned into merge fields:

To: «Contact Name»
From: Customer Service
Subject: Requirement Confirmation
Fax.: «Fax»          Date: «Date»

Message:
Dear «Contact Name»,
Thank you for getting back to us. We will contact you within two working days to confirm the exact delivery arrangements.
Sincerely,
Customer Service

The «Contact Name», «Fax» and «Date» in the template are how a merge field's placeholder is written in Word (press Ctrl + F9 at the field position, then type MERGEFIELD <name>). Note that Contact Name contains a space, so Word writes it in quoted form in the field code, MERGEFIELD "Contact Name"; when the name is passed to Execute in the code below the quotes are dropped, and only the name itself, "Contact Name", is written.

Code

function App() {
  const MailMerge = async () => {
    // Get the Spire.Doc WASM module
    const docModule = window.wasmModule?.spiredoc;

    // Check if the module is ready
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    // Load the sample template into the virtual file system (VFS)
    let inputFileName = "MailMerge.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // Create a document and load the template
    const doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // Field names and field values correspond by index
    let fieldNames = ["Contact Name", "Fax", "Date"];
    let fieldValues = ["John Smith", "+1 (69) 123456", docModule.DateTime.get_Now().Date.ToString()];

    // Execute the mail merge
    doc.MailMerge.Execute(fieldNames, fieldValues);

    // Define the output file name
    const outputFileName = "MailMerge-result.docx";

    // Save the document
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
    doc.Dispose()

    // Read the saved file and convert it to a Blob
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Merge data into a Word document</h1>
      <button onClick={MailMerge}>Generate</button>
    </div>
  );
}
export default App;

Only one call here is the merge logic itself:

document.MailMerge.Execute(fieldNames, fieldValues);

DateTime.get_Now().Date.ToString() takes the current date and returns a string, which can be passed straight in as a field value — the run of blank space between the Fax value and the Date: label afterwards is decided by the template, and the merge does not touch it.

Result

All four fields are replaced and the rest of the template is kept exactly as it was:

To: John Smith
From: Customer Service
Subject: Requirement Confirmation
Fax.: +1 (69) 123456          Date: 10/10/2026 12:00:00 AM

Message:
Dear John Smith,
Thank you for getting back to us. We will contact you within two working days to confirm the exact delivery arrangements.
Sincerely,
Customer Service

Contact Name appears twice and both occurrences take the same value — fields with the same name share one value, so it does not have to be listed twice in the arrays.

Word output after the contact letter has been merged with the data

Word output after the contact letter has been merged with the data


Format Values by Culture

The output format of that Date.ToString() call is decided by the culture settings of the runtime environment. The same code gives 10/10/2026 12:00:00 AM in an English environment and 2026/10/10 上午12:00:00 on a Chinese Windows machine. If the recipient is in Germany, the correct spelling is 10.10.2026, 10:50:45 — day first, month in the middle, separated by dots.

The most direct way to control that format is to format the value before it ever reaches Execute: the merge itself only puts a string of text where the field is, and how the date is laid out is a matter for the data preparation stage.

Code

This shares the same MailMerge.docx template as the previous example and only swaps the date for a string formatted for the de-DE culture:

function App() {
  const ChangeLocale = async () => {
    // Get the Spire.Doc WASM module
    const docModule = window.wasmModule?.spiredoc;

    // Check if the module is ready
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    // Load the sample template into the virtual file system (VFS)
    let inputFileName = "MailMerge.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // Create a document and load the template
    const doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // Prepare the date string in the German format (day.month.year)
    const now = new Date();
    const datestr = new Intl.DateTimeFormat("de-DE",
      {
        year: 'numeric', month: '2-digit', day: '2-digit',
        hour: "2-digit", minute: '2-digit', second: "2-digit"
      }).format(now);

    // Field names and field values correspond by index
    let fieldNames = ["Contact Name", "Fax", "Date"];
    let fieldValues = ["John Smith", "+1 (69) 123456", datestr];

    // Execute the mail merge
    doc.MailMerge.Execute(fieldNames, fieldValues);

    // Define the output file name
    const outputFileName = "ChangeLocale-result.docx";

    // Save the document
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
    doc.Dispose()

    // Read the saved file and convert it to a Blob
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Merge a de-DE formatted date into a Word document</h1>
      <button onClick={ChangeLocale}>Generate</button>
    </div>
  );
}
export default App;

The template is exactly the same and the merge call is exactly the same; the only thing that changes is where the date string in fieldValues comes from:

const datestr = new Intl.DateTimeFormat("de-DE", { /* … */ }).format(now);

Intl.DateTimeFormat is the browser's built-in formatting tool: the first argument is the locale code, the second the format options. Besides de-DE, the usual ones include en-US (10/10/2026, 10:50:45 AM), fr-FR, ja-JP and so on — switching the locale code switches the whole date convention.

Result

Same template, same set of data, only the way the Date field is written changes:

Source of the date field Output
DateTime.get_Now().Date.ToString() Date: 10/10/2026 12:00:00 AM
Intl.DateTimeFormat("de-DE", …).format(now) Date: 10.10.2026, 10:50:45

The merged document:

To: John Smith
From: Customer Service
Subject: Requirement Confirmation
Fax.: +1 (69) 123456          Date: 10.10.2026, 10:50:45

Message:
Dear John Smith,
…

Word output after the date has been formatted for the de-DE culture

Word output after the date has been formatted for the de-DE culture


FAQ

The merged document still contains the «Contact Name» angle brackets

Cause: A merge field's «name» placeholder is the field's result text (what sits between the MERGEFIELD field code and the end marker). A merge only replaces the field's result area — if the « and » in the template were typed as separate text outside the field, they are not part of that field and are naturally left untouched.

Solution: When editing the template in Word, press Ctrl + F9 at the field position to create the field, fill in the field code, and then press F9 to let Word generate the result text; do not type the angle brackets by hand outside the field. When the template is generated from code, the « and » have to be written between the field's separate and end markers, not as a separate run outside the field.

A field name in the array does not match the template and that position is left unfilled

Execute pairs its two arrays by index, and field names are matched case-sensitively. "Contact Name" and "contact name" are treated as two different fields.

A field that appears in the template but is not listed in fieldNames is merged as empty (its result area becomes an empty string rather than keeping the original placeholder). That is exactly how "leave it blank when there is no data" is implemented, but if the intent is to keep the original text, the field name has to be added to the array.

The field name contains a space — how should the field code in the template be written

In a Word field code, a field name containing a space or any other special character has to be wrapped in double quotes:

{ MERGEFIELD "Contact Name" \* MERGEFORMAT }

In JavaScript, what is passed to Execute is the name itself, without quotes:

let fieldNames = ["Contact Name"];

The date format comes out differently on different machines

DateTime.get_Now().Date.ToString() uses the current culture of the runtime environment, so systems with different regional settings and different browsers produce different date strings. For stable output, do what the second feature in this article does: pin the locale code with Intl.DateTimeFormat and pass the result into Execute. Alternatively, take the Date object from Spire's DateTime API and build the string yourself from the standard year, month, day, hour, minute and second properties.

The downloaded docx will not open and is reported as corrupted

Cause: The Blob's MIME type is wrong, or the file is downloaded without being read back from the VFS after it is saved.

Solution: A docx needs the full MIME type declared, and the bytes that are downloaded have to be the ones read from the VFS after SaveToFile:

const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const modifiedFile = new Blob([modifiedFileArray], {
  type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});

Get a Free License

Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.

Published in Mail Merge

Merge fields have two ways of changing the output without changing the replacement step, and both are easy to overlook.

One is the field switch: codes such as \b, \f and \* Upper that follow the field name inside the field code, and make the same value land in the document in a different shape — with a prefix, a suffix, or converted to upper case. A switch is part of the template, so editing the template edits the behaviour.

The other is the conditional field: the document hard-codes no sentence at all, but carries an IF field that lets Word decide, when the document is saved, which sentence the current data calls for. It is assembled by code on the spot.

Spire.Doc for JavaScript builds both entirely in the browser via WebAssembly, using a virtual file system (VFS) to manage the template document — no backend server required.

This article covers two core features:

For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.


Use Field Switches in a Merge Field

Prepare the Template

To lay the output of every switch out side by side for comparison, the template makes the same field, XX_Name, appear nine times, each occurrence carrying a different switch:

Contact

Default: «XX_Name»
\b "Mr ": «XX_Name»
\b Mr: «XX_Name»
\f " (Author)": «XX_Name»
\* Upper: «XX_Name»
\* Lower: «XX_Name»
\* FirstCap: «XX_Name»
\* Caps: «XX_Name»
\* MERGEFORMAT: «XX_Name»

With the field codes shown, each line in Word looks roughly like this ({ } are Word's field braces, which you type with Ctrl + F9):

{ MERGEFIELD  XX_Name  \b "Mr "  \* MERGEFORMAT }
{ MERGEFIELD  XX_Name  \b Mr  \* MERGEFORMAT }
{ MERGEFIELD  XX_Name  \f " (Author)"  \* MERGEFORMAT }
{ MERGEFIELD  XX_Name  \* Upper  \* MERGEFORMAT }
…

Spaces separate the field name from the switch and the switches from one another. If the text argument of a switch itself contains a space, wrap it in double quotes (\b "Mr ") — in that case the space inside the quotes is part of the argument.

The Code

function App() {
  const MailMergeSwitches = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    // Load the sample template into the virtual file system (VFS)
    let inputFileName = "MailMergeSwitches.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // Create a document and load the template
    const doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // Provide a value for a single field; all nine occurrences use it
    let fieldNames = ["XX_Name"];
    let fieldValues = ["jack tang"];

    // Execute the mail merge
    doc.MailMerge.Execute(fieldNames, fieldValues);

    // Define the output file name
    const outputFileName = "MailMergeSwitches-result.docx";

    // Save the document
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

    doc.Dispose()

    // Read the saved file and convert it to a Blob
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Merge data into a Word template that carries field switches</h1>
      <button onClick={MailMergeSwitches}>Generate</button>
    </div>
  );
}
export default App;

There is no switch-related API on the code side — every switch lives in the template's field codes, and Execute only has to hand XX_Name a value. That is exactly what a field switch is for: the data stays the same and the template decides the shape.

The value here is deliberately written in lower case, as "jack tang". Passing "Jack Tang" would make the output of the case-related switches look exactly like the default line, so the difference would not be visible.

The Result

What the template says Output
«XX_Name» jack tang
«XX_Name \b "Mr "» Mr jack tang
«XX_Name \b Mr» Mrjack tang
«XX_Name \f " (Author)"» jack tang (Author)
«XX_Name \* Upper» JACK TANG
«XX_Name \* Lower» jack tang
«XX_Name \* FirstCap» Jack tang
«XX_Name \* Caps» Jack Tang
«XX_Name \* MERGEFORMAT» jack tang

A few points worth noting:

  • The difference between \b "Mr " and \b Mr is that pair of quotes. The argument of the first one is Mr with a trailing space, which outputs Mr jack tang; the argument of the second is Mr with no space, so it sits flush against the value and becomes Mrjack tang. To leave a gap between the added text and the working text, either put the space inside the quotes or write the argument as \b "Mr ".
  • \f is a suffix and \b is a prefix, and both can appear in the same field.
  • \* FirstCap capitalises only the first character (Jack tang), while \* Caps capitalises the first letter of every word (Jack Tang). The two switches have very similar names but different effects.
  • \* MERGEFORMAT does not affect the text. It preserves the field's formatting, Word adds it by default when it creates a merge field, and leaving it in does no harm.

Build and Execute a Conditional Field

The Approach

The structure of a conditional field (an IF field) is "one field, holding a nested merge field as the test, followed by two result texts":

{ IF { MERGEFIELD Count } > "1" "Greater than one" "Less than one" }

Taken apart, this stretch contains four kinds of element:

  1. an IfField, whose field type is set to FieldType.FieldIf;
  2. a merge field node (FieldType.FieldMergeField) — the value being compared in the condition;
  3. the operator and the two branch texts, appended as ordinary text fragments;
  4. a FieldMark of type FieldMarkType.FieldEnd, which closes the field off — it also has to be assigned back to IfField.End for the field to be complete.

The Code

This document has no input template at all; both IF fields are built from scratch:

function App() {
  const ExecuteConditionalField = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }

    // Load the ARIALUNI.TTF font into the virtual file system (VFS)
    await window.spire.FetchFileToVFS('ARIALUNI.TTF','/Library/Fonts/',`${process.env.PUBLIC_URL}/static/font/`);

    // Create a document and add one section with two paragraphs
    let doc = new docModule.Document();
    let section = doc.AddSection();

    let paragraph = section.AddParagraph();
    let ifField1 = new docModule.IfField(doc);
    ifField1.Type = docModule.FieldType.FieldIf;
    ifField1.Code = "IF ";
    paragraph.Items.Add(ifField1);
    paragraph.AppendField("Count", docModule.FieldType.FieldMergeField);
    paragraph.AppendText(" > ");
    paragraph.AppendText("\"1\" ");
    paragraph.AppendText("\"Greater than one\" ");
    paragraph.AppendText("\"Less than one\"");
    let end1 = doc.CreateParagraphItem(docModule.ParagraphItemType.FieldMark);
    end1.Type = docModule.FieldMarkType.FieldEnd;
    paragraph.Items.Add(end1);
    ifField1.End = end1;

    paragraph = section.AddParagraph();
    let ifField2 = new docModule.IfField(doc);
    ifField2.Type = docModule.FieldType.FieldIf;
    ifField2.Code = "IF ";
    paragraph.Items.Add(ifField2);
    paragraph.AppendField("Age", docModule.FieldType.FieldMergeField);
    paragraph.AppendText(" > ");
    paragraph.AppendText("\"50\" ");
    paragraph.AppendText("\"The old man\" ");
    paragraph.AppendText("\"The young man\"");
    let end2 = doc.CreateParagraphItem(docModule.ParagraphItemType.FieldMark);
    end2.Type = docModule.FieldMarkType.FieldEnd;
    paragraph.Items.Add(end2);
    ifField2.End = end2;

    // Provide values for the merge fields inside the conditional fields
    let fieldNames = ["Count", "Age"];
    let fieldValues = ["2", "30"];
    doc.MailMerge.Execute(fieldNames, fieldValues);

    // Make the fields evaluate when the document is saved
    doc.IsUpdateFields = true;

    // Define the output file name
    const outputFileName = "ExecuteConditionalField-result.docx";

    // Save the document
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

    doc.Dispose()

    // Read the saved file and convert it to a Blob
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Create and execute a conditional field</h1>
      <button onClick={ExecuteConditionalField}>Generate</button>
    </div>
  );
}
export default App;

A few details that have to happen in this order:

  1. ifField.Code = "IF " only marks the field type. What actually assembles the body of the field is the AppendField and the AppendText calls that follow — they are laid out into this field one after another.
  2. The operator and the two branch texts are all appended with AppendText, and the quotes among them have to be written into the string itself ("\"1\" "). An IF field requires both result texts to be quoted, and these quote characters are what does it.
  3. The order is fixed: compared value → operator → comparison basis → true branch → false branch. The false branch needs no extra space after its closing quote.
  4. FieldEnd is the field's closing mark. It has to be created with document.CreateParagraphItem(wasmModule.ParagraphItemType.FieldMark), assigned to end.Type, appended to the paragraph, and then given to ifField.End. Skip any one of those steps and the field is incomplete.
  5. document.IsUpdateFields = true decides whether the field is evaluated when the document is saved — see below.

The Result

With Count = "2" and Age = "30", the two conditional fields take the true branch and the false branch respectively:

Greater than one
The young man

Swapping the values flips the result, without touching either the template or the code:

Field values First paragraph (IF Count > "1") Second paragraph (IF Age > "50")
Count = "2", Age = "30" Greater than one The young man
Count = "0", Age = "70" Less than one The old man

FAQ

The conditional field saves as two blank paragraphs

Cause: document.IsUpdateFields = true was not set. A field in the document is only an instruction waiting to be evaluated; without that switch turned on it is not executed when the document is saved, and neither branch's text is output — even the text of the false branch disappears along with it, which is why the result looks empty.

Solution: Between Execute and SaveToFile, add:

document.MailMerge.Execute(fieldNames, fieldValues);
document.IsUpdateFields = true;   // Make the fields evaluate when the document is saved
document.SaveToFile({ fileName: outputFileName, fileFormat: wasmModule.FileFormat.Docx2013 });

The prefix added by \b is stuck to the name

Cause: the text argument of a switch carries no space of its own. The argument of \b Mr is the three characters Mr, and the spacing around it in the output is decided entirely by the argument itself.

Solution: to leave a gap between the name and the prefix, write the space into the argument and wrap it in quotes:

{ MERGEFIELD  XX_Name  \b "Mr "  \* MERGEFORMAT }

\f works the same way. The reverse also works — leave the space outside the field by having it followed by a space in Word — but that is paragraph text, a different thing from a switch.

What is the difference between \* FirstCap and \* Caps

\* FirstCap converts only the first character to upper case and leaves the rest alone: jack tang → Jack tang.

\* Caps converts the first letter of every word to upper case: jack tang → Jack Tang.

To see the difference between these two switches, the value behind the field name has to be lower case or mixed case; if the value is already Jack Tang, both lines come out exactly like the default line.

Some switches have no effect at all

Not every Word field switch takes part in the text replacement of a mail merge. \* MERGEFORMAT preserves the field's formatting and never changes the text to begin with; switches such as \m, \v and \# 0.00 (a number format) have no visible effect either when used on a merge field with a plain text value like the one here. In practice, it is best to stick to the ones verified in this article: \b, \f, \* Upper, \* Lower, \* FirstCap and \* Caps.

How are the spaces between the field name, switches and arguments counted

  • Between the field name and the first switch: at least one space;
  • Between switches: at least one space;
  • For a switch's text argument: a space is needed between it and the switch letter too (\b and Mr), and once the argument itself contains a space or a special character, wrap it in double quotes.

Extra spaces do not affect parsing, but leaving one out lets the argument be swallowed by the previous token — write \* Upper as \*Upper, for example, and on some parsing paths the switch stops working.

Changing the values of Count and Age does not change the output

Cause: the result text of a conditional field is cached in the document and is only evaluated again while IsUpdateFields is on. At the same time, check that the field names handed to Execute match the names written in AppendField (the comparison is case-sensitive).

Solution: confirm that fieldNames matches the first argument of AppendField exactly, and keep IsUpdateFields = true.


Get a Free License

Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.

Published in Mail Merge

Templates usually carry a few lines that only make sense when there is data behind them — a supplementary note, a remark, an optional cc. With no data, the result you want is for that line and the space it takes up to disappear, rather than leaving a blank stretch that pushes the layout apart.

The related question is: when someone hands you a template, how do you find out which fields it holds and how they are grouped into regions? Expanding the field codes one by one to count them is not an option.

Spire.Doc for JavaScript does both — it hides what the merge has emptied and reports the structure — entirely in the browser via WebAssembly, using a virtual file system (VFS) to manage the template document, with no backend server required.

This article covers two core features:

For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.


Hide Empty Paragraphs and Regions After the Merge

Prepare the Template

On top of an ordinary contact letter, the template adds three constructs that may come out empty:

To: «Contact Name»
From: Customer Service
Subject: Requirement Confirmation
Fax.: «Fax»          Date: «Date»

«Optional Note»

«GroupStart:OptionalBlock»
«Optional Line 1»
«Optional Line 2»
«GroupEnd:OptionalBlock»

Note: «Optional Remark»

Message:
Dear «Contact Name»,
Thank you for getting back to us. We will contact you within two working days to confirm the exact delivery arrangements.
Sincerely,
Customer Service

The three are written differently, and between them they cover three cases:

  1. «Optional Note» occupies a paragraph of its own — after the merge not a single character is left inside it;
  2. the region OptionalBlock — fenced by the two fields «GroupStart:OptionalBlock» and «GroupEnd:OptionalBlock», with both paragraphs in between holding nothing but a single field. A region marker is a MERGEFIELD field in Word just like any other; only the field name is written as GroupStart:region name / GroupEnd:region name, and without quotation marks;
  3. Note: «Optional Remark» — the field and the label share one paragraph, so the line still holds the label Note: after the merge.

The Code

function App() {
  const HideEmptyRegions = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    // Load the sample template into the virtual file system (VFS)
    let inputFileName = "HideEmptyRegions.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // Create a document and load the template
    let doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // Provide values for three fields only; the optional fields in the
    // template are left empty
    let fieldNames = ["Contact Name", "Fax", "Date"];
    let fieldValues = ["John Smith", "+1 (69) 123456", docModule.DateTime.get_Now().Date.ToString()];

    // Hide the paragraphs and the region that come out empty
    doc.MailMerge.HideEmptyParagraphs = true;
    doc.MailMerge.HideEmptyGroup = true;

    // Execute the mail merge
    doc.MailMerge.Execute(fieldNames, fieldValues);

    // Define the output file name
    const outputFileName = "HideEmptyRegions-result.docx";

    // Save the document
    doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });

    doc.Dispose()

    // Read the saved file and convert it to a Blob
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Merge data and hide empty regions</h1>
      <button onClick={HideEmptyRegions}>Generate</button>
    </div>
  );
}
export default App;

Everything hangs on the two lines before Execute:

document.MailMerge.HideEmptyParagraphs = true;
document.MailMerge.HideEmptyGroup = true;

Both are boolean properties on MailMerge, both default to false, and both have to be turned on explicitly. Note that they must be set before Execute — the hiding happens during the merge, so changing them afterwards makes no difference.

fieldNames lists only three "main" fields; Optional Note, Optional Line 1/2 and Optional Remark are given no values at all, and therefore all come out empty after the merge.

The Result

The output with both switches off (the control) set against the output with both on, compared paragraph by paragraph:

What the template says Both switches off Both switches on
«Optional Note» (a paragraph of its own) kept as a blank line the whole paragraph disappears
the region OptionalBlock (marker + two field paragraphs + closing marker) kept as 4 blank lines all four paragraphs disappear
Note: «Optional Remark» Note: Note: (kept)

With the switches on, only these paragraphs are left in the body:

To: John Smith
From: Customer Service
Subject: Requirement Confirmation
Fax.: +1 (69) 123456          Date: 10/12/2026 12:00:00 AM
Note: 
Message:
Dear John Smith,
Thank you for getting back to us. We will contact you within two working days to confirm the exact delivery arrangements.
Sincerely,
Customer Service

Two rules worth remembering:

  • The test is whether the paragraph still holds text after the merge, not whether the field received a value. In the third construct Optional Remark is just as empty, but because the same paragraph still holds the label Note:, the line is kept — only the field's position is left blank. To keep the label itself conditionally, the label has to be turned into a field value of its own.
  • HideEmptyParagraphs is the property that actually deletes. Setting HideEmptyGroup to true on its own while leaving HideEmptyParagraphs = false produces output identical to leaving both switches off — the region still leaves its four blank lines behind. Only with both turned on does the region vanish together with the paragraphs inside it. So if all you want is for the blank lines to go away and you are not using regions, turning on HideEmptyParagraphs is enough.

Word output after the paragraphs and the region that merged to nothing have been hidden

Word output after the paragraphs and the region that merged to nothing have been hidden


Read the Region Names and the Merge Field Name List

The Approach

MailMerge has three methods for reading, each at a different granularity:

Method What it returns
GetMergeGroupNames() every region name in the document
GetMergeFieldNames({ groupName: "…" }) the merge field names inside the named region
GetMergeFieldNames() every merge field name in the document

The second method takes the region name inside an object, as a named argument. That is how the WebAssembly build handles an overloaded method — methods sharing a name are dispatched by argument count and argument name, so the call has to be written as { groupName: "Products" } rather than by passing a plain string.

Prepare the Template

The template defines two regions, each holding several fields:

«GroupStart:Products»
Name: «Contact Name»
Number: «Number»
Type: «Type»
Price: «Price»
Date: «Date»
Vendor: «Vendor»
«GroupEnd:Products»

«GroupStart:TeamRanking»
Name: «Name»
Score: «Score»
Ranking: «Ranking»
Award: «Award»
«GroupEnd:TeamRanking»

Every field gets a paragraph of its own, and the region markers are paragraphs of their own too.

The Code

This sample reads the structure of a document and produces text, so the result goes into a TXT file:

function App() {
  const IdentifyMergeFieldName = async () => {
    const docModule = window.wasmModule?.spiredoc;
    if (!docModule) {
      alert('Spire.Doc is not ready yet');
      return;
    }
    // Load the sample template into the virtual file system (VFS)
    let inputFileName = "IdentifyMergeFieldNames.docx";
    await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);

    // Create a document and load the template
    let doc = new docModule.Document();
    doc.LoadFromFile(inputFileName);

    // Read all region names
    let GroupNames = doc.MailMerge.GetMergeGroupNames();

    // Read the merge field names inside one region
    let MergeFieldNamesWithinRegion = doc.MailMerge.GetMergeFieldNames({ groupName: "Products" });

    // Read every merge field name in the document
    let MergeFieldNames = doc.MailMerge.GetMergeFieldNames();

    // Concatenate the results into text
    let content = [];
    content.push("----------------Group Names-----------------------------------------\n");
    for (let i = 0; i < GroupNames.length; i++) {
      content.push(GroupNames[i] + "\n");
    }
    content.push("----------------Merge field names within a specific group-----------\n");
    for (let j = 0; j < MergeFieldNamesWithinRegion.length; j++) {
      content.push(MergeFieldNamesWithinRegion[j] + "\n");
    }
    content.push("----------------All of the merge field names------------------------\n");
    for (let k = 0; k < MergeFieldNames.length; k++) {
      content.push(MergeFieldNames[k] + "\n");
    }

    // Write the TXT file
    const outputFileName = "IdentifyMergeFieldName-result.txt";
    window.dotnetRuntime.Module.FS.writeFile(outputFileName, content.join("\n"));

    // Close the document
    doc.Close();

    doc.Dispose()

    // Read the saved file and convert it to a Blob
    const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
    const blob = new Blob([modifiedFileArray], {type: "text/plain"});
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = outputFileName;
    a.click();
    URL.revokeObjectURL(url);
  };

  return (
    <div style={{ textAlign: 'center', height: '300px' }}>
      <h1>Read the region and merge field names from a template</h1>
      <button onClick={IdentifyMergeFieldName}>Generate</button>
    </div>
  );
}
export default App;

Two things differ from the earlier samples:

  • There is no call to Execute. This only reads the template's structure without merging any data, so the three lists can be read straight after loading.
  • The output goes through window.dotnetRuntime.Module.FS.writeFile(fileName, text), writing the string directly into the virtual file system before a text/plain Blob triggers the download. The sample finishes with document.Close(), because it never changes the document's content.

All three methods return a collection (GroupNames.length, indexed access), walked in the same way as Sections and Paragraphs.

The Result

The output file is laid out like this (the ---…--- lines are separators):

----------------Group Names-----------------------------------------

Products

TeamRanking

----------------Merge field names within a specific group-----------

Contact Name

Number

Type

Price

Date

Vendor

----------------All of the merge field names------------------------

Products

Contact Name

Number

Type

Price

Date

Vendor

Products

TeamRanking

Name

Score

Ranking

Award

TeamRanking

The three lists come out as:

Region names (2)
  Products, TeamRanking

Field names inside the Products region (6)
  Contact Name, Number, Type, Price, Date, Vendor

All field names (14 entries)
  Products, Contact Name, Number, Type, Price, Date, Vendor, Products,
  TeamRanking, Name, Score, Ranking, Award, TeamRanking

Two points are worth noting:

  • GetMergeFieldNames() with no argument counts the region markers themselves as field names. GroupStart:Products is a MERGEFIELD field in the document, so every region name turns up twice — once for the opening marker and once for the closing one. Among the 14 entries above, Products and TeamRanking each appear twice. To get the real field names, either read region by region with the groupName version, or filter the region names out of the full list.
  • The groupName version returns only the real field names inside that region — none of the six results for Products contains a GroupStart: or GroupEnd: marker.

Why is there an extra blank line between the entries? While the string is being assembled, content.push(name + "\n") already puts a newline at the end of every entry, and content.join("\n") adds another one between the elements, so two newlines end up in a row. For a compact list, drop the "\n" from the push call:

content.push(GroupNames[i]);

Word output after the region and the merge field names have been read from the template

Word output after the region and the merge field names have been read from the template


FAQ

The hiding switches are on and a blank line is still there

Cause: most likely that line still holds other text. The test for hiding is whether the paragraph is empty after the merge — as long as a single character is left in it (a fixed label such as Note:, for example), the whole paragraph is kept.

Solution: Check that the line you want hidden holds nothing but the field. If the label really does have to disappear together with the field, turn the label into a field value and pass it in as well:

let fieldNames = ["Contact Name", "Remark Label", "Remark"];
let fieldValues = ["John Smith", "Note: ", ""];

Setting HideEmptyGroup = true on its own has no effect

Cause: HideEmptyParagraphs is what actually deletes paragraphs. A region disappears as a whole because the paragraphs inside it are deleted one by one; the markers themselves trigger no extra deletion.

Solution: Turn both properties on. That is exactly why the sample writes both lines:

document.MailMerge.HideEmptyParagraphs = true;
document.MailMerge.HideEmptyGroup = true;

Should the hiding switches be set before or after Execute

Before Execute. The hiding of empty paragraphs and regions happens during the merge, so changing the properties once it has finished has no effect on the result that has already been produced.

GetMergeFieldNames errors out or returns nothing when a string is passed

Cause: It is an overloaded method. The WebAssembly build dispatches by argument name, so the region name has to be passed in an object with a key.

Solution: Write it as an object:

let fields = document.MailMerge.GetMergeFieldNames({ groupName: "Products" });

The region name must match the name after GroupStart: in the template exactly, case included.

Region names turn up in the result of GetMergeFieldNames()

Cause: Both the opening and the closing marker of a region are merge fields themselves, so they are counted into the full list and every region name appears twice.

Solution: To filter them out, collect the result of GetMergeGroupNames() into a set and drop those names:

let groups = document.MailMerge.GetMergeGroupNames();
let groupSet = {};
for (let i = 0; i < groups.length; i++) groupSet[groups[i]] = true;

let names = document.MailMerge.GetMergeFieldNames();
let realFields = [];
for (let i = 0; i < names.length; i++) {
  if (!groupSet[names[i]]) realFields.push(names[i]);
}

If you only need the fields of one particular region, the groupName version is less trouble — its result never contains markers.

The document defines regions but GetMergeGroupNames() returns 0

Cause: A region marker has to be written as MERGEFIELD GroupStart:region name, with the field name left unquoted. Written as MERGEFIELD "GroupStart:Products" (with quotes) it is treated as an ordinary merge field name and does not form a region.

Solution: Check the field codes of the two markers in the template:

{ MERGEFIELD  GroupStart:Products  \* MERGEFORMAT }
{ MERGEFIELD  GroupEnd:Products  \* MERGEFORMAT }

Also, GroupStart and GroupEnd have to come as a pair, with the region name written out in full and in matching case; a marker without its counterpart is not recognised as a region.

The TXT output opens as a single line in some editors

Cause: This sample writes plain text, and the line separator matters if the file is later saved as .txt and opened in Windows Notepad; the text Spire writes uses \n.

Solution: Open it in an editor such as VS Code or Notepad++, or replace "\n" with "\r\n" while the string is being assembled.


Get a Free License

Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.

Published in Mail Merge