Converting Access VBA to a Web Application: What Translates, What Doesn't, and What Gets Better
Microsoft Access VBA does not move to the web line by line, but nearly all of it survives the move. Form event procedures become handlers in the web application. Recordset loops become single SQL statements. Domain functions such as DLookup become joins in the query behind the page. A large share of VBA exists only to manage Access itself, and that code is deleted rather than translated.
The usual declaration first: rebuilding Microsoft Access databases as web applications is what I do for a living, so I am the interested party here. This is still the technical post. Developers should find that it matches what they know of the VBA object model. Owners should find that their VBA is neither a mystery cost nor thrown away.
What VBA actually is inside an Access database
VBA in Access is not one program. It is a scatter of small procedures in three places: event procedures behind forms and reports, standard modules holding shared functions, and macros half converted to code. Access runs them in the same process as the interface, against its own object model, on the PC that has the front-end file open.
That is what makes migration feel harder than it is. The code is not separated from the interface, so it looks inseparable. In practice it is three things wearing one coat: business rules, data operations, and instructions to Access about how to behave. The first two translate almost mechanically. The third disappears.
This is a different kind of question from the ones I usually get asked. Access 2021's end of support in October 2026 is a date question. Getting at an Access database from outside the office is a network question. VBA is the one people actually fear, because nobody remembers writing half of it.
Construct by construct: what each piece of VBA becomes
The table below is the map, in rough order of how often each construct appears.
| VBA construct | Web equivalent | What changes in practice |
|---|---|---|
| DoCmd.OpenForm with a WhereCondition | A route and a page, such as /orders/1042 | The record identity moves into the URL, so a screen can be bookmarked and shared |
| DoCmd.OpenReport | A report page rendered by the server, plus a PDF of the same data | Printing no longer depends on one PC and its drivers |
| DAO or ADO recordset loop, Do Until rs.EOF with rs.MoveNext | One SQL statement executed by the database server | Thousands of row-by-row trips become one instruction, run next to the data |
| Form_Current | The code that loads a record when its page or row is opened | It runs once per record shown, not again on every refresh and requery |
| Form_BeforeUpdate with Cancel = True | A validation rule checked on the server before the record is written | The message appears beside the field, and no screen can skip the check |
| Me.Requery and Me.Refresh chains between open forms | Refetching the part of the page whose data changed | Forms no longer have to know which other forms are open |
| DLookup, DSum, DCount, DMax | A join or an aggregate in the query behind the page | One query answers the question for every row at once |
| DoCmd.RunSQL with a concatenated UPDATE or DELETE | A parameterised statement inside a transaction | Values travel as parameters instead of being glued into a string |
| Module-level Public variables such as gUserID | The signed-in user and the current request context | State belongs to the person, not to the front-end copy on their PC |
| MsgBox used to confirm something happened | An on-screen notification | Nothing waits for a click on an unattended desktop |
| MsgBox with vbYesNo used to ask permission | A confirmation step in the browser, with the rule enforced on the server | The decision is checked where the data is |
| Nz | COALESCE in SQL, or a default value in application code | Null handling becomes explicit rather than a habit |
| IIf | CASE WHEN in SQL, or a conditional in application code | Long nested expressions become readable branches |
| DoCmd.SetWarnings False | Nothing. The line is deleted | There are no confirmation dialogs to suppress |
| Application.Echo False and DoCmd.Hourglass | Nothing. Both lines are deleted | Redrawing and progress feedback are the browser's job |
| On Error GoTo with Err.Number | Structured error handling with server-side logging | Failures are recorded with their context instead of being lost |
| AutoExec macro and the startup form | Application configuration and the page a user lands on after signing in | Start-up stops being a form nobody may close |
Verified against Microsoft's Access VBA reference on August 16, 2026.
The five translations worth explaining
DoCmd.OpenForm becomes a URL
DoCmd.OpenForm "frmOrder", , , "CustomerID=" & Me.txtCustomerID opens a form and restricts it to matching records. Microsoft's reference describes the WhereCondition argument as a valid SQL WHERE clause without the word WHERE, which is exactly what it is: a filter passed to a screen.
On the web that pair becomes an address. The screen is a route, the filter is part of the path or the query string, and the result is a page that can be linked to. Every OpenArgs value used to smuggle a parameter into a form does, awkwardly, what a URL does natively.
The recordset loop becomes one statement
The most common piece of VBA in any Access database is a loop: open a recordset, walk it with rs.MoveNext until rs.EOF, edit or total something on each row. It is also the most expensive, because the Access database engine runs on the user's own PC and moves rows to the code.
A web application inverts that arrangement. The database server holds the data and executes the statement, and only the result travels. A nightly routine that walked forty thousand rows becomes an UPDATE with a WHERE clause, which is where most of the speed of a rebuilt application comes from.
Form_Current becomes loading a record, not an event chain
Microsoft's documentation records that the Current event fires when focus moves to a record, and also whenever the form is refreshed or requeried, and that on opening a form the sequence is Open, Load, Resize, Activate, Current. That is why Form_Current procedures grow flags and guard variables: they run more often than their author intended.
Rebuilt, the same logic runs when a record is loaded for display, once, deliberately. In the databases I have migrated, the guard flags never make it across, because there is nothing left to guard against.
DLookup becomes a join
DLookup reads one field from one record in a table or query. Microsoft's own note on it is candid: it is often more efficient to build a query containing the fields you need from both tables and base the form on that. Put a DLookup in a continuous form or a loop and you get one lookup per row.
On the web the lookup is part of the query that fills the page, so the database resolves it once for the whole set. DSum and DCount follow the same path into SUM and COUNT with a GROUP BY. Screens that took a minute to build usually collapse here rather than anywhere clever.
Module-level globals become the signed-in user
Almost every Access application has public variables in a standard module holding the current user, the current company, the financial year, a flag saying whether an admin is logged in. They exist because a desktop front end is a single-user copy of the application, so a global is a safe place to keep state.
A web application has real users signing in, so those globals become the session and the permissions attached to it. This is where a genuine improvement usually appears: the gIsAdmin variable people set by typing a password into a hidden form becomes an actual role, enforced on the server, for every screen at once.
If you want your own modules counted rather than guessed at, the free 24-hour Migration Blueprint maps them and comes back with a fixed quote. No call, no obligation.
The third that simply disappears
Now the part that surprises owners. A large share of legacy VBA is not business logic at all. It is instructions to Access about how to be an application: turning warnings off before an action query and back on afterwards, switching screen updating off so forms do not flicker, showing an hourglass, maximising windows, requerying one form because another changed, hiding the navigation pane.
In the databases I have migrated, roughly a third of the VBA has been that kind of code. That figure is my own experience across those projects rather than a measured industry statistic, and it varies with how long the database has been in service. The direction never varies: the older the application, the more of it is scar tissue.
None of that code is rewritten. It is read, understood well enough to be sure it is doing nothing else, and then deleted. Worth saying plainly, because a quote for rewriting your VBA is not a quote for rewriting all of it.
What does not translate
Three categories genuinely do not come across, and it is better to know which before the invoice.
Code that drives the Access interface. Anything reaching into the Access object model itself, Screen.ActiveForm, Application.SetOption, SendKeys, ribbon and navigation-pane manipulation, has no counterpart, because the thing it manipulates no longer exists. Where such code props up a real behaviour, the behaviour is rebuilt in web terms. Where it only manages Access, it goes.
Reports are rebuilt, not converted. An Access report is a layout bound to a query, with grouping levels, sorting, running sums and its own event model. The query and the grouping translate cleanly. The layout and any event code in the report do not, and they become a web report and a PDF. Reports carry more expectation than any other part of an Access system, so specify them precisely.
Code that only exists in a compiled .accde. Microsoft's guidance on hiding VBA code states that saving a database as an .accde file "compiles all VBA code modules, removes all editable source code, and compacts the destination database", and that the code "cannot be viewed or edited" afterwards. If the .accdb was lost and only the .accde survives, the logic is not there to read. Recovering it means observing behaviour and rebuilding from that: slower, more demanding of your team's time, still entirely doable.
Access SQL is its own small dialect too. Nz, IIf, # date literals, * wildcards and crosstab queries all need translating rather than copying.
What gets better on the way across
Four things improve as a consequence of the move, without anyone asking.
Real transactions. A VBA routine that writes a header row, then several line rows, then updates a stock figure has three chances to fail halfway. DoCmd.RunSQL takes a UseTransaction argument, but stitching several statements into one unit of work in VBA is a manual job most applications never attempt. On a database server the whole sequence commits or none of it does.
Validation in one place. In Access the same rule is often written three times: in the form's BeforeUpdate, in a table validation rule, and again in the import routine. Rebuilt, it is defined once and applies to every route into the data.
Concurrency the engine understands. File-based locking is why two people editing the same order produce a write conflict dialog nobody reads. A database server arbitrates properly, which also lifts the ceiling on how many people can safely be in at once.
An audit trail you did not have to build. Once every change arrives as a signed-in user, recording who changed what and when is a property of the design rather than a project of its own.
How the mapping is actually done
The work starts as an inventory, not as coding. Every form, report, query, macro and module is listed, and each procedure is marked translate, delete, or rebuild. That inventory is the blueprint, and it is what makes a fixed quote possible: the unknowns are counted before anyone commits. The VBA translation described here is step 4 of six; the whole conversion process from audit to cutover is its own guide.
The build then covers the whole application, forms, VBA logic, queries and reports rebuilt as a web application, not your tables in a browser. Your team keeps working in Access throughout, and cutover happens on a date you choose. At handover you own the source code, so the next developer to touch this system can read it, which is the only real protection against ending up where the .accde owners end up.
If the VBA is the reason you have not moved
That is the usual reason, and a fair one. The code is old, nobody has a full picture of it, and it is doing something important. The way out is not courage, it is an inventory: once each procedure is on a list with a decision beside it, the fear has nothing left to attach to.
Send me a short description of your Microsoft Access database and the Migration Blueprint below comes back within 24 hours with the scope, the timeline and an exact fixed quote. It is free, and there is no call.