<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Razi Syed</title>
    <description>The latest articles on DEV Community by Razi Syed (@razisyed).</description>
    <link>https://dev.to/razisyed</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4139957%2Fc36062f3-b42e-44a2-8829-02dd81a0f21c.png</url>
      <title>DEV Community: Razi Syed</title>
      <link>https://dev.to/razisyed</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/razisyed"/>
    <language>en</language>
    <item>
      <title>Add self-service ad hoc reporting to an ASP.NET Core app (without writing every report yourself)</title>
      <dc:creator>Razi Syed</dc:creator>
      <pubDate>Wed, 23 Sep 2026 18:51:20 +0000</pubDate>
      <link>https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng</link>
      <guid>https://dev.to/razisyed/add-self-service-ad-hoc-reporting-to-an-aspnet-core-app-without-writing-every-report-yourself-44ng</guid>
      <description>&lt;p&gt;&lt;em&gt;By Razi Syed. Sample code: &lt;a href="https://github.com/dotnetreport/dotnetreport-aspnetcore-quickstart" rel="noopener noreferrer"&gt;github.com/dotnetreport/dotnetreport-aspnetcore-quickstart&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every line-of-business app I've worked on eventually grows a &lt;code&gt;Reports&lt;/code&gt; folder, and every one of those folders tells the same story. The first report was a nice Razor page with a grid. The tenth had four query-string parameters and an Excel export someone bolted on. By the thirtieth, "can you add a column to the sales report" was a two-day ticket, and the backlog of report requests was longer than the backlog of actual features.&lt;/p&gt;

&lt;p&gt;The fix is not a better &lt;code&gt;Reports&lt;/code&gt; folder. It's letting the people who want the reports build them — inside your app, over your data, without you in the loop. That's what a self-service (ad hoc) report builder does, and this article shows how to embed one in an ASP.NET Core app in a few steps.&lt;/p&gt;

&lt;p&gt;I'll use &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, which I work on (so, disclosure), because its integration is a NuGet package plus a few lines of config and I can show real code. The steps — install, configure, register services, expose your schema, hand it to users — are what any embedded builder needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-service" actually means here
&lt;/h2&gt;

&lt;p&gt;Concretely, after this is wired up, a non-developer in your app can:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;pick tables and columns from the ones you've exposed (not raw SQL — a guided picker)&lt;/li&gt;
&lt;li&gt;filter, group, sort, aggregate, and add sub-totals&lt;/li&gt;
&lt;li&gt;turn the result into a chart or a dashboard&lt;/li&gt;
&lt;li&gt;drill down into grouped rows&lt;/li&gt;
&lt;li&gt;export to Excel/PDF, or schedule it to be emailed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And &lt;em&gt;you&lt;/em&gt; never write those reports. You expose the schema once and get out of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — install the package
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet add package DotnetReport
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's for .NET 6+. Legacy .NET Framework apps use &lt;code&gt;DotnetReport.Mvc&lt;/code&gt; (MVC) or &lt;code&gt;DotnetReport.aspx&lt;/code&gt; (Web Forms). The package drops in the report builder's controllers, Razor views and scripts; they live in your project, which means you can restyle them to match your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — configuration
&lt;/h2&gt;

&lt;p&gt;The builder needs three tokens from a Dotnet Report account (there's a free tier) and a connection string to the database users will report on. Add to &lt;code&gt;appsettings.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dotNetReport"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"accountapiurl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://dotnetreport.com/portal/api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"apiurl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://dotnetreport.com/api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"accountApiToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR-PUBLIC-ACCOUNT-API-TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dataconnectApiToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR-DATA-CONNECT-API-TOKEN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"privateApiToken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"YOUR-PRIVATE-API-TOKEN"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionStrings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ConnectionKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Server=.;Database=YourDb;Trusted_Connection=True;TrustServerCertificate=True;"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two notes worth stating plainly. First, the connection string name is &lt;code&gt;ConnectionKey&lt;/code&gt;. Second, &lt;strong&gt;don't commit real tokens&lt;/strong&gt; — put them in user secrets or environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet user-secrets &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="s2"&gt;"dotNetReport:privateApiToken"&lt;/span&gt; &lt;span class="s2"&gt;"..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — register the services it depends on
&lt;/h2&gt;

&lt;p&gt;The report builder is plain MVC: controllers, Razor views, and jQuery/Knockout scripts. So it needs the things any MVC feature needs, plus session (it keeps the user/role context there) and an &lt;code&gt;HttpClient&lt;/code&gt; to talk to the reporting service.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;WebApplication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CreateBuilder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;services&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddControllersWithViews&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddHttpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IdleTimeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpOnly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Cookie&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IsEssential&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Any auth works — the report routes are [Authorize]-protected.&lt;/span&gt;
&lt;span class="n"&gt;services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAuthentication&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CookieAuthenticationDefaults&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AuthenticationScheme&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddCookie&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoginPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"/Home/Login"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseHttpsRedirection&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseStaticFiles&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// the builder's scripts and CSS&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseRouting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthentication&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseAuthorization&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;UseSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// before endpoints that read session&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;MapControllerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"{controller=Home}/{action=Index}/{id?}"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already have an app, you almost certainly have most of this; the additions are usually just &lt;code&gt;AddHttpClient&lt;/code&gt;, &lt;code&gt;AddHttpContextAccessor&lt;/code&gt;, &lt;code&gt;AddSession&lt;/code&gt;/&lt;code&gt;UseSession&lt;/code&gt;. Order matters in the middleware pipeline: routing, then auth, then session, then endpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — expose your schema
&lt;/h2&gt;

&lt;p&gt;Run the app and open &lt;strong&gt;&lt;code&gt;/dotnetsetup&lt;/code&gt;&lt;/strong&gt;. This is a developer/admin screen: it connects with your &lt;code&gt;ConnectionKey&lt;/code&gt; string, lists every table and view in the database, and lets you choose which ones end users may report on, with friendly names.&lt;/p&gt;

&lt;p&gt;This is the one design decision that deserves thought. Expose &lt;em&gt;views&lt;/em&gt; rather than raw tables where your schema is normalized or messy — a &lt;code&gt;vw_SalesByCustomer&lt;/code&gt; view that already joins and names things sensibly makes users successful, whereas dumping 200 raw tables at them does not. You are curating a reporting surface, not opening the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — hand it to users
&lt;/h2&gt;

&lt;p&gt;Open &lt;strong&gt;&lt;code&gt;/dotnetreport&lt;/code&gt;&lt;/strong&gt;. That's the report builder. Users pick from the tables you exposed, build, chart, save to folders, share, export and schedule. Link to it from your app's navigation and you're done with the integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The next two things you'll need
&lt;/h2&gt;

&lt;p&gt;Getting the builder on screen is the easy part; two follow-ups matter in real apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scope it to the current user and tenant.&lt;/strong&gt; Out of the box the builder doesn't know who your user is. A single server-side method, &lt;code&gt;GetSettings()&lt;/code&gt;, is where you pass the current user id, tenant id, and roles — and, crucially, &lt;strong&gt;row-level-security filters&lt;/strong&gt; the engine appends to every query so users only ever see their own rows. I wrote that up separately with code: &lt;em&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-multitenant-rls" rel="noopener noreferrer"&gt;Multi-tenant reporting in ASP.NET Core: row-level security your users can't bypass&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn on scheduled delivery.&lt;/strong&gt; Users will immediately ask for "email me this every Monday." The package ships a Quartz.NET job for that; enabling it is one line plus SMTP config — see the &lt;em&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-scheduled-reports" rel="noopener noreferrer"&gt;scheduled reports sample&lt;/a&gt;&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you shouldn't do this
&lt;/h2&gt;

&lt;p&gt;Honest scoping helps: if you have three fixed reports that never change, a report builder is overkill — write the three Razor pages. Self-service earns its keep when the &lt;em&gt;variety&lt;/em&gt; of report requests is the problem, when different customers want different views of the same data, or when you're shipping reporting as a feature to your own customers in a SaaS product. That last case is where it pays off most, because you're turning an endless internal backlog into a capability your users own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Install the package, add the config, register controllers/session/HttpClient, expose a curated schema at &lt;code&gt;/dotnetsetup&lt;/code&gt;, and point users at &lt;code&gt;/dotnetreport&lt;/code&gt;. The working host, sample config and a &lt;code&gt;.gitignore&lt;/code&gt; that keeps tokens out of git are in the repo: &lt;strong&gt;&lt;a href="https://github.com/dotnetreport/dotnetreport-aspnetcore-quickstart" rel="noopener noreferrer"&gt;dotnetreport/dotnetreport-aspnetcore-quickstart&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Razi Syed builds &lt;a href="https://dotnetreport.com" rel="noopener noreferrer"&gt;Dotnet Report&lt;/a&gt;, an embedded self-service reporting platform for .NET whose report-builder front-end is source-available on GitHub. He writes about adding reporting and analytics to SaaS products without rebuilding them from scratch.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>aspnetcore</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
