Business Central

How to Build an Interactive Map Control Add-in in Dynamics 365 Business Central

By Hetal Tank November 5, 2026 25 min read

Introduction

Microsoft Dynamics 365 Business Central ships with a clean, standardized page framework – but every so often a requirement calls for something the standard page controls cannot render on their own: an interactive map, a drag-and-drop planning board, or a signature pad on a tablet. For these scenarios Business Central provides the control add-in: an AL object that lets you embed a custom web component (HTML, CSS, and JavaScript) directly inside a page and exchange data with your AL code.

In this article we build a practical control add-in end to end – an interactive map FactBox that plots a customer’s address and each of its ship-to addresses as pins, and opens the underlying record when a pin is clicked. We use Leaflet with OpenStreetMap tiles, both free and open source, with no map-provider API key required. Along the way we cover how AL and JavaScript talk to each other, and we finish with the practical considerations and constraints you should design around when working with control add-ins.

Prerequisites

  • Access to a Dynamics 365 Business Central instance (SaaS or on-premises; a sandbox is recommended for testing).
  • Basic knowledge of AL (Application Language) code.
  • Visual Studio Code with the AL Language extension installed.
  • Basic familiarity with HTML, CSS, and JavaScript.

Requirement

Business Central already stores the address of every customer and ship-to location, but it does not show those locations on a map inside the client. The standard Show on Map action (configured under Online Map Setup) hands a single address off to an external map service that opens in a new browser tab. We want something richer and embedded: a FactBox on the Customer Card that shows the customer and all of its ship-to addresses as pins on one interactive map, where clicking a pin opens the record.

A control add-in is the supported way to achieve this. Before we build it, let’s understand what a control add-in is.

A control add-in is a custom visual component that runs inside the Business Central web client – rendered in an iframe – and communicates with AL. The communication goes two ways:

  • Procedures (AL → JavaScript): AL calls a JavaScript function to send data or a command to the component (for example, “add this pin to the map”).
  • Events (JavaScript → AL): JavaScript notifies AL that something happened in the component (for example, “the user clicked this pin”).

Microsoft’s documentation lists visualizing data on a map as one of the intended uses of a control add-in, so this is a well-supported scenario. The full source for everything below ships as a per-tenant extension alongside this post.

Solution (Implementation Steps)

A control add-in solution is made up of a few coordinated files: the controladdin object, one or more JavaScript files, a stylesheet, and the AL page(s) that host it. We also add table extensions to store coordinates. Let’s build it up step by step.

Step 1: Store coordinates on the record (table extension)

A map plots coordinates (latitude and longitude), but Business Central stores addresses as text. The most robust design is to make the map coordinate-driven: store latitude/longitude on the record and have the add-in plot exactly what is stored. Populate those fields however suits you – manual entry, a configuration package import, or a geocoding call. First, extend the Customer table (do the same for Ship-to Address so delivery points can be plotted too):

tableextension 50100 “MMAP Customer” extends Customer

{

    fields

    {

        field(50100; “MMAP Latitude”; Decimal)

        {

            Caption = ‘Latitude’;

            DecimalPlaces = 0 : 8;

            MinValue = -90;

            MaxValue = 90;

            DataClassification = CustomerContent;

        }

        field(50101; “MMAP Longitude”; Decimal)

        {

            Caption = ‘Longitude’;

            DecimalPlaces = 0 : 8;

            MinValue = -180;

            MaxValue = 180;

            DataClassification = CustomerContent;

        }

        field(50102; “MMAP Geocoded At”; DateTime)

        {

            Caption = ‘Geocoded At’;

            Editable = false;

            DataClassification = CustomerContent;

        }

    }

}

Step 2: Declare the control add-in object

The controladdin object defines the component: the scripts and stylesheets to load, the startup script, and the interface – the procedures AL can call in JavaScript, and the events JavaScript can raise in AL. Note that the Scripts and StyleSheets properties can reference external HTTPS resources, so we load Leaflet directly from a CDN:

controladdin “MMAP Location Map”

{

    RequestedHeight = 350;

    MinimumHeight = 240;

    VerticalStretch = true;

    HorizontalStretch = true;

    // Scripts/StyleSheets may reference external HTTPS resources, so we can

    // load Leaflet straight from a CDN. (See “Further reading”.)

    Scripts =

        ‘https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js’,

        ‘src/ControlAddIns/Map/controller.js’;

    StyleSheets =

        ‘https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css’,

        ‘src/ControlAddIns/Map/styles.css’;

    StartupScript = ‘src/ControlAddIns/Map/startup.js’;

    // AL -> JS : each maps to a global JS function of the same name

    procedure InitializeMap();

    procedure AddLocation(LocationCode: Text; Name: Text; Latitude: Decimal; Longitude: Decimal; CardHtml: Text);

    procedure FitToLocations();

    procedure ClearLocations();

    procedure RefreshLayout();

    // JS -> AL : raised via Microsoft.Dynamics.NAV.InvokeExtensibilityMethod

    event OnControlReady();

    event OnLocationClicked(LocationCode: Text);

}

The mental model is simple and worth internalizing: every procedure you declare must have a matching global JavaScript function of the same name, and every event you declare is raised from JavaScript using Microsoft.Dynamics.NAV.InvokeExtensibilityMethod.

Step 3: Signal readiness from JavaScript (startup script)

Never assume the component and its libraries load instantly. The StartupScript is the earliest point at which the add-in is available, so a good practice is to have JavaScript raise an OnControlReady event as soon as it loads, and let AL push data only after that:

// Runs once when the host page has loaded. Do NOT build the map here

// (Leaflet + the container may not be ready). Just tell AL the control is

// alive; AL then calls InitializeMap() and pushes the pins.

(function () {

    function signalReady() {

        if (window.Microsoft && Microsoft.Dynamics && Microsoft.Dynamics.NAV) {

            Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(‘OnControlReady’, []);

        }

    }

    if (typeof L === ‘undefined’) {

        window.setTimeout(signalReady, 100);

    } else {

        signalReady();

    }

})();

Step 4: Build the map logic (controller.js)

Now the JavaScript that renders the map and handles the pins. Three things are easy to get wrong.

First, the marker-icon path. Because the add-in runs in a sandboxed iframe, Leaflet cannot reliably detect its icon image path from the stylesheet, so the default marker silently fails to load. Point the icon URLs explicitly at the CDN:

// In BC the add-in runs in a sandboxed iframe, where Leaflet cannot reliably

// detect its icon image path, so the default pin silently 404s. Pin the icon

// URLs explicitly to the same CDN.

MMAP.fixDefaultIcon = function () {

    if (typeof L === ‘undefined’ || !L.Icon || !L.Icon.Default) return;

    var base = ‘https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/’;

    delete L.Icon.Default.prototype._getIconUrl;

    L.Icon.Default.mergeOptions({

        iconRetinaUrl: base + ‘marker-icon-2x.png’,

        iconUrl: base + ‘marker-icon.png’,

        shadowUrl: base + ‘marker-shadow.png’

    });

};

Second, attribution and container sizing. The OpenStreetMap tile usage policy requires attribution, so it is baked into the tile layer. And because a FactBox often renders before it has dimensions, we nudge Leaflet to re-measure with invalidateSize():

function InitializeMap() {

    MMAP.fixDefaultIcon();

    var mapDiv = MMAP.ensureMapDiv();       // creates a sized <div id=”mmapMap”>

    if (MMAP.map) { MMAP.map.remove(); MMAP.map = null; }

    MMAP.map = L.map(mapDiv, { scrollWheelZoom: true }).setView([20, 0], 2);

    // OpenStreetMap tiles – no API key. Attribution is REQUIRED by the OSM

    // tile usage policy.

    L.tileLayer(‘https://tile.openstreetmap.org/{z}/{x}/{y}.png’, {

        maxZoom: 19,

        attribution: ‘&copy; <a href=”https://www.openstreetmap.org/copyright”>OpenStreetMap</a> contributors’

    }).addTo(MMAP.map);

    MMAP.markerLayer = L.layerGroup().addTo(MMAP.map);

    MMAP.markers = {};

    // A FactBox often renders before it has dimensions; nudge Leaflet to

    // re-measure so tiles lay out correctly.

    setTimeout(function () { if (MMAP.map) MMAP.map.invalidateSize(); }, 200);

}

Third, the pin and its click behavior. Each pin’s popup contains an “Open card” button; clicking it raises the OnLocationClicked event back to AL, passing a code (for example CUST|C10000 or SHIP|C10000|BLUE) that identifies the record:

function AddLocation(locationCode, name, latitude, longitude, cardHtml) {

    if (!MMAP.map) InitializeMap();

    var lat = parseFloat(latitude), lng = parseFloat(longitude);

    if (isNaN(lat) || isNaN(lng)) return;

    var marker = L.marker([lat, lng]).addTo(MMAP.markerLayer);

    // Build the popup as a real DOM node so the button keeps a live handler.

    var wrapper = document.createElement(‘div’);

    wrapper.className = ‘mmap-card’;

    wrapper.innerHTML = cardHtml;            // trusted markup, HTML-escaped in AL

    var btn = document.createElement(‘button’);

    btn.type = ‘button’;

    btn.className = ‘mmap-open-btn’;

    btn.textContent = ‘Open card’;

    btn.onclick = function () {

        Microsoft.Dynamics.NAV.InvokeExtensibilityMethod(‘OnLocationClicked’, [locationCode]);

    };

    wrapper.appendChild(btn);

    marker.bindPopup(wrapper, { minWidth: 200 });

    MMAP.markers[locationCode] = marker;

}

Finally, a few small helpers to fit the view to the pins, clear them, and re-measure on resize:

// Zoom/pan so every pin is visible.

function FitToLocations() {

    if (!MMAP.map) return;

    var list = [];

    for (var key in MMAP.markers)

        if (MMAP.markers.hasOwnProperty(key)) list.push(MMAP.markers[key]);

    if (list.length === 0) return;

    if (list.length === 1) { MMAP.map.setView(list[0].getLatLng(), 13); return; }

    MMAP.map.fitBounds(L.featureGroup(list).getBounds().pad(0.2));

}

// Remove all pins (called before reloading when the record changes).

function ClearLocations() {

    if (MMAP.markerLayer) MMAP.markerLayer.clearLayers();

    MMAP.markers = {};

}

// Force Leaflet to re-measure (call after the FactBox is resized).

function RefreshLayout() { if (MMAP.map) MMAP.map.invalidateSize(); }

Step 5: Host the add-in on the Customer Card (FactBox page)

We place the control add-in on a CardPart page bound to Customer. On OnControlReady it initializes the map and loads the pins; on OnAfterGetCurrRecord it reloads when the selected customer changes; and it implements OnLocationClicked to open the clicked record. (The popup HTML is built in a small helper codeunit that HTML-escapes every record value before it crosses into the browser.)

page 50100 “MMAP Customer Map FactBox”

{

    PageType = CardPart;

    SourceTable = Customer;

    Caption = ‘Map’;

    layout

    {

        area(content)

        {

            usercontrol(LocationMap; “MMAP Location Map”)

            {

                ApplicationArea = All;

                trigger OnControlReady()

                begin

                    IsReady := true;

                    CurrPage.LocationMap.InitializeMap();

                    LoadLocations();

                end;

                trigger OnLocationClicked(LocationCode: Text)

                begin

                    OpenRecord(LocationCode);

                end;

            }

        }

    }

    var

        IsReady: Boolean;

    trigger OnAfterGetCurrRecord()

    begin

        if IsReady then

            LoadLocations();

    end;

    local procedure LoadLocations()

    var

        ShipTo: Record “Ship-to Address”;

        CardBuilder: Codeunit “MMAP Map Card Builder”;

    begin

        if not IsReady then

            exit;

        CurrPage.LocationMap.ClearLocations();

        // 1) the customer’s own address

        if CardBuilder.HasCoordinates(Rec.”MMAP Latitude”, Rec.”MMAP Longitude”) then

            CurrPage.LocationMap.AddLocation(‘CUST|’ + Rec.”No.”, Rec.Name,

                Rec.”MMAP Latitude”, Rec.”MMAP Longitude”, CardBuilder.BuildCustomerCard(Rec));

        // 2) each ship-to address

        ShipTo.SetRange(“Customer No.”, Rec.”No.”);

        if ShipTo.FindSet() then

            repeat

                if CardBuilder.HasCoordinates(ShipTo.”MMAP Latitude”, ShipTo.”MMAP Longitude”) then

                    CurrPage.LocationMap.AddLocation(‘SHIP|’ + ShipTo.”Customer No.” + ‘|’ + ShipTo.Code,

                        ShipTo.Name, ShipTo.”MMAP Latitude”, ShipTo.”MMAP Longitude”,

                        CardBuilder.BuildShipToCard(ShipTo));

            until ShipTo.Next() = 0;

        CurrPage.LocationMap.FitToLocations();

    end;

    local procedure OpenRecord(LocationCode: Text)

    var

        Customer: Record Customer;

        ShipTo: Record “Ship-to Address”;

        Parts: List of [Text];

    begin

        Parts := LocationCode.Split(‘|’);

        if Parts.Count() = 0 then

            exit;

        // NB: AL does not short-circuit ‘and’, so guard the index with nested ifs.

        case Parts.Get(1) of

            ‘CUST’:

                if Parts.Count() >= 2 then

                    if Customer.Get(Parts.Get(2)) then

                        Page.Run(Page::”Customer Card”, Customer);

            ‘SHIP’:

                if Parts.Count() >= 3 then

                    if ShipTo.Get(Parts.Get(2), Parts.Get(3)) then

                        Page.Run(Page::”Ship-to Address List”, ShipTo);

        end;

    end;

}

One AL detail worth flagging: AL does not short-circuit and/or, so a guard like if (Parts.Count() >= 2) and Customer.Get(Parts.Get(2)) still evaluates Get(2) when there is no element 2 and throws. Use nested ifs, as above.

Step 6: Surface the FactBox on the page

Finally, add the FactBox (and the coordinate fields) to the Customer Card with a page extension:

pageextension 50100 “MMAP Customer Card” extends “Customer Card”

{

    layout

    {

        // coordinate fields, so you can enter/inspect them on the card

        addafter(General)

        {

            group(“MMAP Map”)

            {

                Caption = ‘Map’;

                field(“MMAP Latitude”; Rec.”MMAP Latitude”)  { ApplicationArea = All; }

                field(“MMAP Longitude”; Rec.”MMAP Longitude”){ ApplicationArea = All; }

                field(“MMAP Geocoded At”; Rec.”MMAP Geocoded At”) { ApplicationArea = All; Editable = false; }

            }

        }

        // the interactive map FactBox

        addlast(factboxes)

        {

            part(“MMAP Map”; “MMAP Customer Map FactBox”)

            {

                ApplicationArea = All;

                Caption = ‘Map’;

                SubPageLink = “No.” = field(“No.”);

            }

        }

    }

}



6.1 To refresh co-ordinates

6.2 To open the Map in full screen




Step 7 (optional): Reverse geocoding – fill the address from coordinates

The map needs coordinates, so the natural companion is forward geocoding (address to coordinates), which the extension exposes as a “Get coordinates” action. The reverse is just as useful: type a latitude/longitude and have Business Central fill in the address. Both directions use the same public OpenStreetMap Nominatim service and the same single-record, user-triggered pattern.

Wire it as an action that calls the geocoder, then refreshes the page so the new address shows:

action(“MMAP Get Address From Coordinates”)

{

    Caption = ‘Get address from coordinates’;

    Image = Map;

    trigger OnAction()

    var

        Geocoder: Codeunit “MMAP Map Geocoder”;

    begin

        Geocoder.ReverseGeocodeCustomer(Rec);

        CurrPage.Update(false);

    end;

}


The reverse endpoint is /reverse?lat=&lon= with format=jsonv2&addressdetails=1. It returns a single object whose address breakdown you map onto the BC fields – house_number + road into Address, city/town/village into City, state into County, postcode into Post Code, and country_code (an ISO 3166-1 alpha-2 code) into Country/Region:

// In the geocoder codeunit: call /reverse and map the result to fields.

local procedure TryReverseGeocode(Latitude: Decimal; Longitude: Decimal;

    var Street: Text; var City: Text; var County: Text;

    var PostCode: Text; var CountryCode: Text): Boolean

var

    Client: HttpClient;

    Response: HttpResponseMessage;

    Body: Text;

    Root: JsonObject;

    AddrTok: JsonToken;

    Addr: JsonObject;

    UrlLbl: Label ‘https://nominatim.openstreetmap.org/reverse?format=jsonv2&addressdetails=1&lat=%1&lon=%2’, Locked = true;

begin

    Client.DefaultRequestHeaders.Add(‘User-Agent’, UserAgentLbl);  // policy: identify your app

    Client.DefaultRequestHeaders.Add(‘Accept-Language’, ‘en’);      // predictable names

    // Format(..,0,9) writes invariant dot-decimal coordinates into the URL.

    if not Client.Get(StrSubstNo(UrlLbl, Format(Latitude, 0, 9), Format(Longitude, 0, 9)), Response) then

        exit(false);

    if not Response.IsSuccessStatusCode then

        exit(false);

    Response.Content.ReadAs(Body);

    Root.ReadFrom(Body);

    if Root.Contains(‘error’) then            // point has no OSM coverage

        exit(false);

    if not Root.Get(‘address’, AddrTok) then

        exit(false);

    Addr := AddrTok.AsObject();

    Street      := (JsonText(Addr, ‘house_number’) + ‘ ‘ + JsonText(Addr, ‘road’)).Trim();

    City        := FirstNonEmpty(Addr, ‘city,town,village,municipality,hamlet’);

    County      := FirstNonEmpty(Addr, ‘state,state_district,county’);

    PostCode    := JsonText(Addr, ‘postcode’);

    CountryCode := UpperCase(JsonText(Addr, ‘country_code’));   // ISO 3166-1 alpha-2

    exit(true);

end;

Then write the values back. Two things matter here: assign to the fields directly (validating Post Code would trigger BC’s own postcode lookup and could overwrite the City/County you just set), and only set the country when a matching Country/Region record actually exists – Nominatim returns ISO codes, but a given environment’s Country/Region codes are not guaranteed to be ISO:

// Write the values back (truncated to field length). Assign directly – do NOT

// Validate “Post Code”, or BC’s postcode lookup may overwrite the City/County

// you just set. Only set the country if a matching Country/Region exists here;

// Nominatim returns ISO codes, but an environment’s codes are not guaranteed ISO.

if Street <> ” then Customer.Address := CopyStr(Street, 1, MaxStrLen(Customer.Address));

if City <> ” then Customer.City := CopyStr(City, 1, MaxStrLen(Customer.City));

if County <> ” then Customer.County := CopyStr(County, 1, MaxStrLen(Customer.County));

if PostCode <> ” then Customer.”Post Code” := CopyStr(PostCode, 1, MaxStrLen(Customer.”Post Code”));

if CountryCode <> ” then

    if CountryRegion.Get(CountryCode) then

        Customer.”Country/Region Code” := CountryRegion.Code;

Customer.Modify(true);


Two caveats.
First, reverse geocoding is approximate: Nominatim returns the address of the closest suitable OSM object, not an exact computation for the point, so treat the filled values as a suggestion to review. Second, the same Nominatim usage policy applies as for forward geocoding – at most one request per second, a valid User-Agent identifying your app, cache the result, show OpenStreetMap attribution, and no bulk or automated use. That is why this is a single-record action behind a confirmation prompt, not a batch routine; for bulk reverse geocoding use a commercial provider or a self-hosted Nominatim. The extension wires the action on both the Customer Card and the Ship-to Address card.

Step 8: Build, publish, and test

Press Ctrl+Shift+B to build, publish the extension to a sandbox, set coordinates on a customer (via the Map group on the card), and open the Customer Card. You will see the map with a pin for the customer and each ship-to address; clicking a pin’s Open card button navigates to the record. Because the pin and popup behavior is pure front-end, you can also iterate on the JavaScript in a plain browser before publishing.

Considerations and Constraints of Control Add-ins

Control add-ins are powerful, but they have characteristics you should design around. Keeping these in mind will save you debugging time:

  • Communication is asynchronous. All calls between AL (running on the server) and JavaScript (running in the browser) are asynchronous. In practice this means interface methods must be of type void – they cannot return a value directly – and you should not use property methods. To send a result back, you raise an event (JavaScript → AL) or call a procedure (AL → JavaScript). Our OnLocationClicked event is exactly that pattern.
  • The client throttles chatty add-ins. Since Business Central v20, if the client detects a slow or high-volume add-in it warns the user, and if the communication volume stays high it throttles and can start rejecting calls – at which point part or all of the add-in stops working. Keep the number of AL↔JavaScript calls low: for large data sets, send one batched payload (for example, a JSON array of pins) rather than looping many individual calls.
  • The component is isolated. The add-in renders in its own iframe, separate from the rest of the client, and communicates only through the procedures and events you declare – not by reaching into the Business Central page directly. The marker-icon fix in Step 4 is a direct consequence of this isolation.
  • State is not automatically persisted. The add-in can be recreated or refreshed during a session (Business Central provides RecreateScript and RefreshScript properties for these moments), and any state held only in JavaScript is lost when that happens. Drive the component from AL and reload it on the ready event, as we did, rather than relying on state living in the browser.
  • Design for all display targets. The same add-in can run on the browser, tablet, and phone clients, and behavior can differ between them. Test on the clients your users actually use.
  • Prefer robust resource loading for production. Referencing Leaflet from a CDN keeps the example short, but for offline-tolerant or AppSource scenarios, package the library files inside the extension so the component does not depend on an external CDN being reachable (and ship the marker images if you do).

Conclusion

Control add-ins let you extend the Business Central client with modern web components while keeping your business logic in AL. As this map FactBox shows, you can deliver a genuinely richer experience – an embedded, multi-pin, interactive map – without a single API key or any change to base objects. Keep the visual layer coordinate-driven, communicate through a small and deliberate interface, and design around the asynchronous, throttled, isolated nature of the component. Do that, and control add-ins become a reliable way to turn the data you already have into something your users can see and act on.

Further reading

Leave a Comment

Your email address will not be published. Required fields are marked *

Your email will not be published.

Chat on WhatsApp Call Us Now