I spend a fair amount of time helping teams decide how much of Blazor they actually need. Sometimes the answer is none of it. You just want a piece of .NET logic running in the browser, called from plain JavaScript, without pulling in a full Blazor render pipeline. .NET 7 makes that possible through a lighter WebAssembly path, and Khalid Abuhakmeh wrote a good walkthrough of it on his blog after digging into a Microsoft sample called HelloDotnetWasm. This article recreates that walkthrough with my own notes on where it holds up in production and where the tooling still has gaps.
The idea is straightforward once you see it working. You compile a small C# class library to a WASM binary, load the .NET runtime in the browser through a JavaScript module, and call your C# methods directly from JavaScript. No Razor components, no SignalR circuit, no Blazor JS interop wrapper in between.
What WebAssembly Actually Gives You Here
WebAssembly, or WASM, is a binary format that runs inside a host environment, most commonly a browser. Think of the compiled WASM module as a .dll that any host with a WASM runtime can load and execute. The host decides what capabilities the module can reach, such as the DOM, the network, or local storage, and the module has to ask for those through an explicit contract.
For .NET developers this is a meaningful shift. Your C# code compiles down to something the browser can run at close to native speed, without shipping a server-rendered Blazor app or a SignalR connection to keep alive. You get a real host contract between JavaScript and C#, which you will see directly in the code below through two attributes, JSExport and JSImport.
One limitation worth flagging early: multithreading support was still experimental in .NET 7. If your C# code leans on async/await patterns that expect real threading, test it early in this environment rather than assuming it behaves like a normal .NET process.
Setting Up the Project
You need the .NET 7 SDK and two additional workloads before anything will build. Run these from a terminal, adding sudo on macOS if your SDK install requires elevated permissions.
dotnet workload install wasm-tools
dotnet workload install wasm-experimental
wasm-tools brings in the MSBuild targets that know how to produce a browser-wasm build. wasm-experimental pulls in the newer JS interop pieces, including the JSExport and JSImport attributes used in this sample. Skipping either one gives you a build error that does not clearly point back to a missing workload, so if your build fails with an unfamiliar SDK-related error, check these two first.
Clone the sample repository (HelloDotnetWasm on Khalid’s GitHub) and run dotnet tool restore inside it. That restore step installs two local tools, dotnet-serve and run-script, which the sample uses to serve the built output over HTTP.
Inside the WASM Project File
The first thing that stands out in this project is how little the csproj file actually contains. Compare it to a typical console or web project and you will notice there is barely anything to configure.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<WasmMainJSPath>main.js</WasmMainJSPath>
<OutputType>Exe</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<WasmExtraFilesToDeploy Include="index.html" />
<WasmExtraFilesToDeploy Include="main.js" />
<WasmExtraFilesToDeploy Include="favicon.ico" />
</ItemGroup>
</Project>
The sparseness is intentional. Most of the real build logic lives inside the wasm-tools workload targets, not the project file itself. The property that actually matters here is RuntimeIdentifier set to browser-wasm, which tells the build to target the browser specifically rather than a generic WASM host. WasmExtraFilesToDeploy simply tells the build which static files need to travel alongside the compiled output into the AppBundle folder, which is the deployable unit for this whole approach.
The C# Side: Exporting and Importing Methods
Program.cs is where the actual contract between C# and JavaScript gets defined, using two attributes that are new to this interop model.
using System;
using System.Runtime.InteropServices.JavaScript;
Console.WriteLine("Hello, Browser!");
Console.WriteLine(string.Join(" ", args));
public partial class MyClass
{
[JSExport]
internal static string Greeting()
{
var text =
$"""
<div>
<h1>Hello, World! Greetings from WASM!</h1>
<p>Listening at {GetHRef()}</p>
</div>
""";
Console.WriteLine(text);
return text;
}
[JSImport("window.location.href", "main.js")]
internal static partial string GetHRef();
}
This uses top-level statements, so the two Console.WriteLine calls at the top run immediately when the WASM module starts, before any JavaScript calls into it. MyClass then exposes two very different things. Greeting is marked JSExport, meaning JavaScript can call it directly once the module loads. GetHRef is marked JSImport, meaning the implementation lives on the JavaScript side and C# is calling out to the host to get it.
JSImport takes two arguments: the name JavaScript should recognize, which must match how the function is exposed on the host side, and the module name where that implementation lives, main.js in this case. This is the part that trips people up the first time. GetHRef has no C# body at all, because partial marks it as an implementation JavaScript supplies at runtime, not something C# resolves at compile time.
The JavaScript Side: main.js
main.js is where you load the .NET runtime itself through dotnet.js, generated automatically as part of the build, and wire up the JavaScript implementations that C# expects through JSImport. The file breaks down into four logical sections.
First, the runtime bootstrap. This loads the dotnet object and configures a few runtime behaviors before anything else happens.
import { dotnet } from './dotnet.js'
const is_browser = typeof window != "undefined";
if (!is_browser) throw new Error(`Expected to be running in a browser`);
const { setModuleImports, getAssemblyExports, getConfig, runMainAndExit } = await dotnet
.withDiagnosticTracing(false)
.withApplicationArgumentsFromQuery()
.create();
withDiagnosticTracing and withApplicationArgumentsFromQuery are both optional configuration calls. The guard clause checking for window is defensive coding in case this same main.js ever gets loaded in a non-browser WASM host, which is unlikely in practice but costs nothing to include.
Second, registering the JavaScript implementations for anything C# marked with JSImport. This is the piece that satisfies GetHRef from Program.cs.
setModuleImports("main.js", {
window: {
location: {
href: () => globalThis.window.location.href
}
}
});
The object shape here has to line up with how C# referenced it, window.location.href. Get this nesting wrong and you will not get a compile error, you will get a runtime failure the first time C# tries to call GetHRef, which is a debugging trap worth knowing about ahead of time.
Third, pulling the exported C# methods into JavaScript so you can call them like any other function.
const config = getConfig();
const exports = await getAssemblyExports(config.mainAssemblyName);
const html = exports.MyClass.Greeting();
getAssemblyExports inspects the compiled assembly and hands back every method marked JSExport, organized by namespace and class. From here, calling exports.MyClass.Greeting() is indistinguishable from calling a native JavaScript function, even though it is running compiled C# under the hood.
Fourth and finally, rendering the result and running the actual entry point.
document.getElementById("out").innerHTML = `${html}`;
await runMainAndExit(config.mainAssemblyName, ["dotnet", "is", "great!"]);
runMainAndExit triggers the top-level statements in Program.cs, including the two Console.WriteLine calls, and you can pass command-line style arguments straight through as an array. Everything printed here shows up in the browser developer console, not the page itself, since Console.WriteLine in this environment maps to console.log rather than any DOM output.
The HTML Host Page
index.html is intentionally minimal. Its only job is to load main.js as a module and give the app something to render into.
<!DOCTYPE html>
<html>
<head>
<title>HelloDotnetWasm</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="modulepreload" href="./main.js" />
<link rel="modulepreload" href="./dotnet.js" />
</head>
<body>
<main id="out"></main>
<script type='module' src="./main.js"></script>
</body>
</html>
The modulepreload hints tell the browser to start fetching main.js and dotnet.js early, before the script tag is even parsed, which shaves a small amount of load time off an already payload-heavy page. The out element is where main.js injects the HTML returned by Greeting.
Building and Running the App
Build the project normally and you get an AppBundle folder containing everything needed to run in any browser, with no .NET backend involved at request time. Worth calling out clearly: this AppBundle includes the .NET runtime assemblies themselves, not just your compiled code, so the initial payload is noticeably larger than a comparable hand-written JavaScript app. That gap should narrow as the WASM tooling matures, but budget for it today if you are targeting mobile or low-bandwidth users.
Serve the AppBundle with any static file server. The sample includes a shortcut for this.
dotnet r start
This uses the run-script tool restored earlier to start dotnet-serve against the AppBundle output. Navigate to localhost:8080 and open your browser’s developer tools to see the Console.WriteLine output land in the JavaScript console.

What you are looking at is a page rendered entirely by C# that ran inside the browser, with no server round trip after the initial page load. The current URL, read from JavaScript’s window.location.href, gets threaded back through the JSImport call and shows up inside the rendered HTML.
Where the Tooling Still Falls Short
Writing main.js today is a bit of a leap of faith. dotnet.js only exists after you run a build, so while you are writing main.js against it, your editor has no type information and no autocomplete for any of the dotnet object’s methods. You are relying on documentation and sample code rather than tooling to get the API surface right, which is exactly the kind of thing that leads to typos in a setModuleImports object shape going unnoticed until runtime.
A source-generator-based approach could close this gap, generating a typed wrapper around the exported and imported methods as part of the build, so your editor sees real method signatures instead of a generic getAssemblyExports call. Something along these lines is the kind of ergonomic improvement worth watching for in later .NET releases.
import { program } from './program.js'
program.imports["main.js"].window.location.href = () => globalThis.window.location.href;
const html = program.exports.MyClass.Greeting();
document.getElementById("out").innerHTML = `${html}`;
program.Main(["dotnet", "is", "great!"]);
This is not real generated code today, it is a sketch of what a friendlier wrapper could look like. The core idea is that a generated program.js file would give you named properties on imports and exports instead of raw dictionary-style access, and would make it easier to pass a program object around between JavaScript modules in a larger app.
WASM Interop vs Blazor: When to Reach for Which
If your app is already JavaScript-first, React, Vue, Svelte, whatever the stack, and you just need a specific piece of C# logic such as a shared validation rule, a calculation engine, or a legacy algorithm ported from a .NET backend, this raw WASM path is the better fit. You avoid pulling in Blazor’s component model, its own JS interop layer, and its render lifecycle for a job that is really just calling a function.
Blazor WebAssembly still makes more sense when you want your UI itself written in C#, with routing, component state, and forms handled by the Blazor framework rather than hand-rolled JavaScript. Blazor’s JS interop is also considerably more mature at this point, with better error handling around serialization and a larger ecosystem of component libraries built on top of it. Reach for raw WASM interop when C# is a utility, and reach for Blazor when C# is the UI.
Practical Takeaways
Test payload size early if bandwidth matters for your users. The AppBundle ships the .NET runtime itself, and that overhead is fixed regardless of how small your actual application code is, so this pattern suits internal tools and desktop-class users better than public-facing mobile-heavy sites, at least in the .NET 7 timeframe.
Keep the JSImport and JSExport surface area small and deliberate. Every method you expose across that boundary is a manual contract with no compiler checking the JavaScript side, so treat it the way you would treat a public API contract between two separate services, with the same care around naming and versioning.
Conclusion
Targeting WASM directly from .NET 7, without going through Blazor, fills a real gap for teams that want C# logic running in the browser inside an otherwise JavaScript-first application. The developer experience around main.js still has rough edges, particularly around tooling and type safety across the JS boundary, but the underlying capability works as advertised. Worth trying on a small internal tool before committing to it for anything customer-facing.
Leave a Reply