File uploads in Blazor
Blazor's built-in InputFile streams bytes through the circuit (Server) or buffers in the renderer (WASM), which struggles with large files. The pattern below keeps Blazor for your UI logic and lets the CoreUpload JS runtime move the bytes.
New: CoreUpload now ships a native <CoreUploader /> Blazor component in the package — no interop to hand-write. Your C# event handlers receive strongly-typed results. Works in Blazor Server and WebAssembly. The manual interop route is still documented at the bottom for fully custom UI.
1. Host the endpoints
Blazor Server and hosted WASM run on ASP.NET Core, so the server half is unchanged:
// Program.cs
builder.Services.AddCoreUpload();
...
app.MapCoreUploadEndpoints(); // /api/upload, /chunk, /chunk/status, /tus, /s3/* ... Standalone WASM with a separate API? Map the endpoints in the API project and point uploadUrl at it (CORS applies as with any cross-origin API).
2. Load the runtime
<!-- App.razor / _Host.cshtml / index.html -->
<link rel="stylesheet" href="_content/CoreUpload/css/coreupload.css" />
<script src="_content/CoreUpload/js/coreupload.js"></script> Load it before _framework/blazor.web.js. If those _content/… URLs 404 when you dotnet run, check that Properties/launchSettings.json sets ASPNETCORE_ENVIRONMENT=Development — ASP.NET Core only resolves static web assets out of a NuGet package in Development. dotnet publish copies them into wwwroot, so any environment works once published.
The component needs an interactive render mode (InteractiveServer or InteractiveWebAssembly). On a statically rendered page there is no circuit, so no JS interop and no events.
3. Drop in the component
That is the whole integration. Parameters configure the uploader; EventCallback parameters deliver results to your C# code as each file finishes.
@* Home.razor *@
@using CoreUpload.Components
<CoreUploader UploadUrl="/api/upload/upload"
Multiple="true"
Chunked="true"
InstantUpload="true"
OnTaskComplete="Completed"
OnError="Failed" />
<ul>@foreach (var f in _done) { <li>@f.FileName (@f.FileSize bytes)</li> }</ul>
@code {
readonly List<CoreUploadFile> _done = new();
void Completed(CoreUploadFile f) { _done.Add(f); StateHasChanged(); }
void Failed(CoreUploadError e) { Console.WriteLine(e.Message); }
} Each CoreUploadFile carries FileId (the stored GUID), FileName, FileSize, ContentType, RelativePath (folder uploads) and Deduped (instant upload). Other events: OnProgress, OnFileAdded, OnQueueComplete, OnInstantUpload.
Component parameters
| Parameter | Purpose |
|---|---|
UploadUrl | Endpoint that receives uploads. Defaults to /api/upload/upload. |
Multiple / MaxFiles | Multi-select and queue cap. |
Chunked / ChunkSize | Split large files into resumable chunks. |
MaxFileSize / AllowedExtensions | Client-side validation (re-checked server-side). |
WebkitDirectory | Pick a whole folder; paths arrive in RelativePath. |
InstantUpload | Skip the transfer when the server already stores identical content. |
Options | Escape hatch: any engine option without a dedicated parameter. |
Imperative control is available too: UploadAsync(), BrowseAsync(), PauseAsync(), ResumeAsync(), CancelAsync(), ClearAsync() and GetCompletedAsync() via @ref.
Advanced: your own interop
Prefer to drive the engine yourself — a bespoke queue UI, or wiring events the component does not surface? The manual route still works exactly as before.
A small interop module
// wwwroot/js/uploader-interop.js
export function create(element, dotnetRef, options) {
const u = CoreUpload.create(element, {
uploadUrl: '/api/upload/upload',
chunked: true,
chunkSize: 5 * 1024 * 1024,
persistState: true,
...options
});
u.on('taskProgress', t => dotnetRef.invokeMethodAsync('OnProgress', t.fileName, t.progress));
u.on('taskComplete', (t, r) => dotnetRef.invokeMethodAsync('OnCompleted', t.fileName, r.fileGuid));
u.on('taskError', (t, e) => dotnetRef.invokeMethodAsync('OnError', t.fileName, e));
return u;
} Hand-rolled component
@* Uploader.razor *@
<div @ref="_host"></div>
<p>@_status</p>
@code {
ElementReference _host;
string _status = "";
IJSObjectReference? _mod;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return;
_mod = await JS.InvokeAsync<IJSObjectReference>("import", "./js/uploader-interop.js");
await _mod.InvokeVoidAsync("create", _host, DotNetObjectReference.Create(this), new { });
}
[JSInvokable] public void OnProgress(string name, int pct) { _status = $"{name}: {pct}%"; StateHasChanged(); }
[JSInvokable] public void OnCompleted(string name, string guid) { _status = $"{name} done ({guid})"; StateHasChanged(); }
[JSInvokable] public void OnError(string name, string error) { _status = $"{name} failed: {error}"; StateHasChanged(); }
} Why not stream through InputFile?
- Blazor Server: bytes travel over the SignalR circuit — message-size limits, circuit pressure, and one dropped circuit kills the transfer with no resume.
- Retry/resume: you would re-implement chunking, backoff, offline persistence and cross-session resume yourself — the exact code this component exists to provide.
- Direct-to-cloud: browser-to-S3/Azure/GCS transfer is a JS-fetch pattern by nature; interop is the idiomatic bridge.