LV2 LiteView2
Try LiteView2
Tutorial · Microsoft Access

Modern HTML Forms for Microsoft Access: View, Edit & Save Your Table Records

Can you use a modern HTML form — instead of a traditional Access form — to view, edit, and save the records in your Access tables? Yes. This tutorial shows you how to build one that reads and writes a live table, then explains exactly how it works underneath: VBA and JavaScript talk to each other through WebView2 host objects (AddHostObjectToScript), pass data as JSON, and the page loads with NavigateToString — the foundation for modern Access UIs and business-automation apps. A complete, working Access project is yours to download.

For Access / VBA developers — no prior JavaScript needed · ~12 min read

What you'll learn

1. The big picture

An HTML form inside Access is just two programs running side by side that talk through a single doorway. If you know Access forms, DAO recordsets, and VBA, you already understand 90% of this — the HTML page is a fancy form, and the JavaScript is the "code behind" that form, the same role your VBA plays behind a native Access form.

Access · VBA

Owns the data

  • m_rs — the DAO recordset
  • GetCurrentRecord()
  • SaveRecord(json)
Browser · HTML + JS

Shows one record

  • the visible form (textboxes)
  • await vba.GetCurrentRecord()
  • vba.SaveRecord(json)
Access owns the data; the web page is a live view of one record at a time. They talk only through the host-object bridge named "vba".
Show the same diagram as plain text
        ACCESS  (your VBA)                        THE BROWSER  (HTML + JavaScript)
   +----------------------------+            +--------------------------------------+
   | frmCustomer                |            | the HTML form (textboxes)            |
   |  m_rs  (the DAO recordset) |            |                                      |
   |  GetCurrentRecord() -------+-- "vba" ---+-->  await vba.GetCurrentRecord()      |
   |  MoveNext()                |   bridge   |        ...fills the textboxes...      |
   |  SaveRecord(json) <--------+------------+--   vba.SaveRecord(json)              |
   |   *** OWNS THE DATA ***    |            |   *** SHOWS ONE RECORD AT A TIME ***  |
   +----------------------------+            +--------------------------------------+

2. The one idea that makes it click: the bridge

WebView2 — the Chromium engine built into Windows that renders these pages — supports host objects: you hand a COM object (your VBA form) to the page under a name, and the page can call its methods. The API that does this is AddHostObjectToScript. In LiteView2's reg-free mode it's one line in your form's ready handler:

pool.AddHostObjectToScript idx, "vba", Me

Read it as: "Publish this form (Me) to the web page under the name vba." After that line, every Public Function on your form is callable from JavaScript. On the page side, you reach your form like this:

window.chrome.webview.hostObjects.vba.MoveNext()

That long prefix window.chrome.webview.hostObjects.vba is just "the form you published as vba." The template shortens it to a variable called bridge, so the code reads simply vba.MoveNext().

So this JavaScript line…

var rec = await vba.MoveNext();

…is doing the same thing as this VBA line:

rec = Me.MoveNext()

It calls your VBA function and gets its return value back. That is the whole trick — everything else is plumbing around that one idea.

What's await? Because the two sides are separate, a call across the bridge isn't instant the way a normal VBA call is — JavaScript gets an "I'll get back to you" ticket (a promise). The word await means "wait right here until the ticket is filled, then give me the real value." Just read await vba.Foo() as "the result of Foo."

3. JavaScript, translated to VBA

Here is every JavaScript construct used in the template, next to the VBA you already know. That's the entire vocabulary — there is nothing else to learn.

JavaScript (in the template)What it means in VBA terms
// comment' comment
var x = 5;Dim x: x = 5
function loadCurrent() { ... }Sub LoadCurrent() ... End Sub
if (a === b) { ... }If a = b Then ... End If
FIELDS.forEach(function(f){ ... })For Each f In FIELDS ... Next
try { ... } catch (e) { ... }On Error GoTo ErrH ... ErrH:
document.getElementById('fldCity').valueMe.txtCity.Value (read a control)
el.value = "London"Me.txtCity.Value = "London" (set a control)
await vba.MoveNext()Me.MoveNext() (call a function, get its result)
JSON.stringify(obj)turn a record into a text string
JSON.parse(text)turn a text string back into a record

If you can read the VBA column, you can read the JavaScript. Notice the web side only ever reads a textbox, sets a textbox, or calls one of your VBA functions.

Two cosmetic JS quirks so they don't trip you up: === is just = for comparison (read it as =); and function(e){ ... } written inside another call is an inline callback — a chunk of code handed to something to run later, like an event procedure ("here's the code to run when X happens").

4. What is JSON? (the only data format crossing the bridge)

The two sides can't hand each other a DAO record or a VBA object — they can only pass text. JSON is simply a text format for one record. This…

{ "CustomerID": 6, "CustomerName": "Acme Ltd", "City": "London" }

…is just a record written as text — the same information as one row of your table, or one VBA Scripting.Dictionary. Curly braces wrap the record; each "Field": value pair is one column.

You never write JSON by hand. LiteView2's native engine does it both ways for you:

That's all JSON is here: a transport format, built and read by one function call each.

5. Walkthrough A — showing a record (VBA → page)

This happens on startup and after every navigation. Follow the numbers.

① The page asks (JavaScript, loadCurrent)

var rec = parseRecord(await vba.GetCurrentRecord());   // call VBA, get JSON, turn into a record

② Your VBA answers (GetCurrentRecord in frmCustomer)

Public Function GetCurrentRecord() As String
    Dim d As Object, f As DAO.Field
    Set d = CreateObject("Scripting.Dictionary")
    For Each f In m_rs.Fields          ' every column of the current row
        d(f.Name) = f.Value
    Next f
    d("__key") = KEY_FIELD             ' tell the page which column is the primary key
    d("RecordIndex") = m_rs.AbsolutePosition + 1
    d("RecordCount") = m_rs.RecordCount
    GetCurrentRecord = pool.BuildJson(idx, d)   ' hand back the record as JSON text
End Function

This is pure DAO — read the current row's fields into a dictionary, return it as text. The __key line is what keeps the page table-agnostic: it never hardcodes CustomerID, it just reads whichever column __key names.

③ The page fills the textboxes (JavaScript, applyRecord)

FIELDS.forEach(function(f) {
    setValue('fld' + f, rec[f] != null ? rec[f] : '');   // put each value into its textbox
});

For the field "City", this sets the textbox with id="fldCity" to rec.City — the web equivalent of Me.txtCity.Value = rs!City.

The round-trip: page asks → your VBA reads the recordset → returns JSON → page drops each value into its matching textbox. You wrote the data half (step ②); the JS just distributes it.

6. Walkthrough B — clicking "Next" (page → VBA → page)

The user clicks the Next › button. In the HTML that button says:

<button id="btnNext" onclick="navigate('next')">Next &rsaquo;</button>

onclick="navigate('next')" is exactly like setting a button's On Click to a VBA sub — "when clicked, run navigate('next')."

① The click handler (JavaScript, navigate)

case 'next':  raw = await vba.MoveNext();  break;   // ask VBA to move the recordset
var rec = parseRecord(raw);
if (rec) applyRecord(rec);                            // show whatever VBA returned

② Your VBA moves the real recordset (MoveNextNavMove)

Case "next":  m_rs.MoveNext: If m_rs.EOF Then m_rs.MoveLast: Exit Function
' ...
NavMove = GetCurrentRecord()    ' return the new current row as JSON

It moves m_rs — your actual DAO recordset — then returns the new row using the same GetCurrentRecord from Walkthrough A.

So a navigation button is just: JS asks your VBA to move the recordset, your VBA moves it and returns the new record, JS shows it. The recordset position lives in VBA the whole time, exactly as it would behind a native Access form.

7. Walkthrough C — saving (page → VBA)

The user edits some textboxes and clicks Save (onclick="saveRecord()").

① The page gathers the textboxes and sends them (JavaScript)

function collectFields() {
    var payload = {};
    var keyName = currentRecord && currentRecord.__key;          // VBA told the page the key column
    if (keyName) payload[keyName] = currentRecord[keyName];      // send the key under its real name
    FIELDS.forEach(function(f) {
        payload[f] = document.getElementById('fld' + f).value;   // read each textbox
    });
    return payload;
}
// ...
var result = await vba.SaveRecord(JSON.stringify(collectFields()));   // send as JSON text

collectFields reads every textbox (like reading Me.txtCity.Value for each control), adds the primary-key value (under whatever the key column is actually called — the __key trick that keeps the page table-agnostic), JSON.stringify turns it into text, and vba.SaveRecord(...) hands it to your VBA.

② Your VBA writes it to the table (SaveRecord)

If isNew Then m_rs.AddNew Else m_rs.Edit
For Each f In m_rs.Fields
    If f.Name <> KEY_FIELD Then
        If pool.JsonExists(idx, json, f.Name) Then
            f.Value = NzNull(pool.JsonGetValue(idx, json, f.Name))   ' read JSON, write field
        End If
    End If
Next f
m_rs.Update
SaveRecord = "OK"    ' tell the page it worked

Again pure DAO — AddNew/Edit, set each field from the JSON, Update. The only new part is JsonGetValue, which reads a value out of the JSON the page sent (the mirror of BuildJson). Returning "OK" lets the page show a green "Saved" toast; returning an error string shows it in red.

The round-trip: JS reads the textboxes → sends JSON → your VBA writes the recordset → returns "OK" → JS confirms. You wrote the half that touches the database.

8. The two directions, summarized

DirectionHow it worksWhere you write it
Page → VBAawait vba.SomeFunction(args) runs your Public Function SomeFunctionYou write the VBA function
VBA → PageYour function returns a JSON string → JS uses itYou build the JSON (one BuildJson call)
VBA → Page (push)Optional: pool.ExecuteScriptAsync idx, "loadRecord(...)" to send a record unpromptedRarely needed

Almost everything is the first two rows: the page calls your function, your function returns a record. It is request/response, just like calling any function — the bridge only adds the await on the JS side and the "vba" name.

9. Adapting it to your own table

This is the reassuring part. To use the template for a different table, you touch VBA constants and HTML textboxes — almost never the JavaScript logic:

  1. VBA — set four constants at the top of frmCustomer: TBL, KEY_FIELD, SORT_FIELD, and either PAGE_URL (file mode) or HTML_TABLE (table mode — see §10). The save/load loops adapt to your columns automatically, so there are no per-field VBA edits.
  2. HTML — replace the <input> textboxes in <div id="formArea"> with your columns. Each one needs id="fld<ColumnName>" (exact column name), and add those same names to the FIELDS list in the <script>.
  3. Cosmetic — rename the title, headings, and labels however you like.

The JavaScript functions (navigate, saveRecord, applyRecord, …) are generic — they loop over FIELDS and call your VBA. You can adopt the whole template without changing a single line of JS logic.

10. Shipping with zero external files — author as a file, ship from a table

A finished app should be one .accdb and nothing else — no loose .html, .css, or .js files to copy alongside it, lose, or have a user accidentally edit. That is possible because the template is fully self-contained: all CSS is inline, all icons are inline <svg>, and the fonts use the built-in Windows stack — there is no <link> or <script src> pointing at an external file. That enables a two-phase workflow.

Phase 1 — author as a file (while building)

' OnBrowserReady — FILE mode (development)
pool.SetLocalContentRoot idx, _
     CurrentProject.Path & "\Demos"
pool.SetAreHostObjectsAllowed idx, True
pool.AddHostObjectToScript idx, "vba", Me
OpenData
pool.Navigate idx, PAGE_URL   ' loads the file by URL

Phase 2 — ship from a table (when done)

' OnBrowserReady — TABLE mode (no external files)
pool.SetAreHostObjectsAllowed idx, True
pool.AddHostObjectToScript idx, "vba", Me
OpenData

Dim html As String
html = Nz(DLookup("Form_HTML", "tblHTMLForms", _
        "FormName='" & Me.Name & "'"), "")
pool.NavigateToString idx, html   ' load stored HTML

Store the finished HTML as a row in a table (e.g. tblHTMLForms with columns FormName and Form_HTML as Long Text), switch Navigate (loads a URL) for NavigateToString (loads HTML text directly), and delete the .html file. The page now lives inside the database — a single distributable .accdb.

Keep the copy in sync. The table row is a copy of the file. If you later edit the .html during development, re-paste it into Form_HTML — otherwise the form keeps loading the old page. A stale row is the most common "I changed the page but nothing changed" (and "my save stopped working") gotcha.

Why the swap is so small

Navigate and NavigateToString are interchangeable — the only difference is where the HTML comes from (a file on disk vs. a string in memory). The bridge, the host object, and every line of your VBA and JavaScript are identical in both modes.

The one rule: keep the page self-contained

NavigateToString has no folder to resolve relative links against, so a table-loaded page cannot pull in external files. Inline everything — CSS in a <style> block, icons as inline <svg>, and a native font stack like "Segoe UI". The moment you add a <link> or <script src>, you've re-introduced an external file and broken the single-.accdb promise.

11. One-page cheat-sheet

You are still writing an Access app. The browser is just a nicer-looking form, and the "vba" bridge is the only new word in the dictionary.


Download the working Access project

Everything in this tutorial is a real, runnable database. Grab the demo, open it in Access, and watch the same GetCurrentRecord / SaveRecord round-trips run against a live table — then reuse it for your own.

The code samples above stay in sync with this project — the logic and behavior always match the database, even if a comment or line wrap differs.

Next steps