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.
What you'll learn
- Host Objects
AddHostObjectToScript- JavaScript → VBA
- VBA → JavaScript
- JSON communication
NavigateToString- HTML stored inside Access tables
- Building modern Access forms
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.
Owns the data
m_rs— the DAO recordsetGetCurrentRecord()SaveRecord(json)
Shows one record
- the visible form (textboxes)
await vba.GetCurrentRecord()vba.SaveRecord(json)
"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 *** |
+----------------------------+ +--------------------------------------+
- Access still owns the data. The DAO recordset
m_rslives in VBA, exactly like always. The web page never holds a copy of your table — it asks for one record at a time. - The HTML is the layout — the textboxes, buttons, and labels you'd otherwise drag onto an Access form.
- The JavaScript is the code-behind — it runs when a button is clicked, the same way your VBA
cmdNext_Clickruns. - The bridge is the doorway between them. It has a name —
"vba"— and that name is the only "magic" you need to learn.
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.
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').value | Me.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.
=== 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:
- Building (VBA → page):
pool.BuildJson(idx, d)turns aScripting.Dictionaryinto that text. InGetCurrentRecord, the template loops your recordset's fields into a dictionary and callsBuildJsononce — so it works for any table automatically. - Reading (page → VBA):
pool.JsonGetValue(idx, json, "City")pulls one value out of the text — likers!City, but the source is a JSON string instead of a recordset.
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 ›</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 (MoveNext → NavMove)
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
| Direction | How it works | Where you write it |
|---|---|---|
| Page → VBA | await vba.SomeFunction(args) runs your Public Function SomeFunction | You write the VBA function |
| VBA → Page | Your function returns a JSON string → JS uses it | You build the JSON (one BuildJson call) |
| VBA → Page (push) | Optional: pool.ExecuteScriptAsync idx, "loadRecord(...)" to send a record unprompted | Rarely 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:
- VBA — set four constants at the top of
frmCustomer:TBL,KEY_FIELD,SORT_FIELD, and eitherPAGE_URL(file mode) orHTML_TABLE(table mode — see §10). The save/load loops adapt to your columns automatically, so there are no per-field VBA edits. - HTML — replace the
<input>textboxes in<div id="formArea">with your columns. Each one needsid="fld<ColumnName>"(exact column name), and add those same names to theFIELDSlist in the<script>. - 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.
.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
- HTML = the form's controls. JavaScript = the form's code-behind. Your VBA = the data layer (DAO), unchanged from how you already work.
- The bridge =
AddHostObjectToScript idx, "vba", Me. After it, JS can call yourPublic Functions asvba.TheName(...). await vba.Foo()in JS ≈Me.Foo()in VBA — call a function, get its result.- JSON = one record written as text.
BuildJsonwrites it (VBA→page);JsonGetValuereads it (page→VBA). You never type JSON by hand. document.getElementById('fldCity').value≈Me.txtCity.Value— read/set one box.- To adapt: edit 4 VBA consts + the HTML textboxes + the
FIELDSlist. Leave the JS logic alone.
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.
- Working
.accdb— open and run immediately - HTML templates — dark + light, self-contained
- VBA source module — the full
frmCustomer - README — setup & how it fits together
- Registration-free example —
modLV2Poolbootstrap, no admin rights - The exact code from this page — compare side by side
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.