Adding an Azure Functions API

Adding an Azure Functions API

Adding an Azure Functions API

This post is part of the Azure Static Web Apps with Blazor series.

A complete working example is available here: mongeon/code-examples · azure-swa-blazor/03-api-functions.

So far, our Blazor WASM app is purely static. That’s enough for many projects, but sooner or later you’ll need a backend: calling a third-party API without exposing keys, accessing a database, or running some processing you don’t want to execute in the browser.

Azure Static Web Apps supports two approaches for this: managed functions (included in the free tier) and the linked backend (Standard tier). We’ll focus on managed functions, because it’s the simplest and most common approach.

The general idea

SWA acts as a reverse proxy. All requests to /api/* are automatically forwarded to your Azure Functions, transparently. On the Blazor side, you call /api/products as if it were an endpoint on the same server. No CORS to configure, no external URL to manage.

That detail is what makes the integration so pleasant. Your frontend and backend share the same domain, so the browser treats API calls as same-origin requests.

Creating the Azure Functions project

We’ll add an Azure Functions project in the same solution as our Blazor project. We use the isolated worker model with .NET:

# At the root of your solution
mkdir Api
cd Api
func init --worker-runtime dotnet-isolated --target-framework net10.0

This creates a Functions project with the isolated worker model. If you don’t have the Azure Functions Core Tools installed, now is the time: npm install -g azure-functions-core-tools@4.

Then, create an HTTP function:

func new --name GetProducts --template "HTTP trigger"

Modify the generated function to return something useful. Here’s a simple example:

using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using System.Net;

namespace Api;

public class GetProducts
{
    [Function("GetProducts")]
    public async Task<HttpResponseData> Run(
        [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "products")] HttpRequestData req)
    {
        // Simulated data, in real life you'd fetch this from a database
        var products = new[]
        {
            new { Id = 1, Name = "Widget", Price = 9.99 },
            new { Id = 2, Name = "Gadget", Price = 24.99 },
            new { Id = 3, Name = "Thingamajig", Price = 14.99 }
        };

        var response = req.CreateResponse(HttpStatusCode.OK);
        await response.WriteAsJsonAsync(products);
        return response;
    }
}

A few important points. AuthorizationLevel.Anonymous is necessary because SWA handles authentication upstream. Route = "products" means the endpoint will be available at /api/products.

Your solution structure should now look like this:

Client/
  Pages/
  wwwroot/
    staticwebapp.config.json
  Program.cs
  Client.csproj
Api/
  GetProducts.cs
  Program.cs
  host.json
  local.settings.json
  Api.csproj

Calling the API from Blazor

On the Blazor side, we use HttpClient to call our API. In the Client project’s Program.cs, the HttpClient is already configured with the right base address:

builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

Then, in a Razor component:

@page "/products"
@inject HttpClient Http

<h3>Products</h3>

@if (products == null)
{
    <p>Loading...</p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Name</th>
                <th>Price</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var product in products)
            {
                <tr>
                    <td>@product.Name</td>
                    <td>@product.Price.ToString("C")</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    private Product[]? products;

    protected override async Task OnInitializedAsync()
    {
        // The call goes to /api/products, same domain
        products = await Http.GetFromJsonAsync<Product[]>("api/products");
    }

    private class Product
    {
        public int Id { get; set; }
        public string Name { get; set; } = string.Empty;
        public decimal Price { get; set; }
    }
}

The relative URL api/products works because the HttpClient’s BaseAddress already points to the right domain, whether running locally or deployed.

Local development

This is where things can get tricky. In development, your Blazor app runs on one port (say localhost:5000) and Azure Functions runs on another (say localhost:7071). Calls to /api/products won’t go through because there’s no proxy.

The simplest solution for local development is to configure the HttpClient to point to the Functions port in development. In Program.cs:

var apiBaseAddress = builder.Configuration["ApiBaseAddress"]
    ?? builder.HostEnvironment.BaseAddress;

builder.Services.AddScoped(sp =>
    new HttpClient { BaseAddress = new Uri(apiBaseAddress) });

And in wwwroot/appsettings.Development.json:

{
  "ApiBaseAddress": "http://localhost:7071"
}

In production, ApiBaseAddress is not set, so the HttpClient uses the default BaseAddress. The other option is to use the swa CLI to simulate the proxy locally. We’ll cover that in detail in article 5 of the series.

Updating the GitHub Actions workflow

For SWA to know there’s an API to deploy, you need to add api_location in the workflow:

- name: Build And Deploy
  uses: Azure/static-web-apps-deploy@v1
  with:
    azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
    repo_token: ${{ secrets.GITHUB_TOKEN }}
    action: "upload"
    app_location: "Client"
    api_location: "Api"
    output_location: "wwwroot"

The api_location: "Api" tells the workflow to build and deploy the Functions project found in the Api folder. After deployment, your /api/ endpoints are automatically available under the same domain as your Blazor app.

Managed functions vs linked backend

Managed functions are deployed and managed directly by SWA. That’s what we’re using in this article. They have some limitations: requests are capped at 45 seconds, and the only supported triggers are HTTP. No timer triggers, no queue triggers.

The linked backend lets you connect an existing Azure Functions app (or even an App Service, Container App, etc.) to your SWA. It requires the Standard tier and configuration is done in the Azure portal rather than in the workflow. When you link a backend, set api_location to an empty string ("") in your workflow to avoid conflicts.

For most projects, managed functions are more than enough. If you need non-HTTP triggers or long-running requests, go with the linked backend.

Secrets and environment variables

Your Azure Functions will probably need API keys or connection strings. Don’t put them in code. Use application settings in the Azure portal: Static Web Apps > your app > Environment variables.

In code, you access them like regular environment variables:

var connectionString = Environment.GetEnvironmentVariable("DATABASE_CONNECTION_STRING");

Locally, put these values in Api/local.settings.json (which is in .gitignore by default). The default file looks like this:

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "UseDevelopmentStorage=true",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated"
  }
}

You would add your own secrets as needed, for example "DATABASE_CONNECTION_STRING": "your-local-connection-string" in the Values section.

In the next article, we’ll explore SWA’s built-in authentication: the built-in providers, the /.auth/ endpoint, and how to get the logged-in user in both Blazor and the API.

Articles in this series

  1. What is an Azure Static Web App?
  2. The staticwebapp.config.json file
  3. Adding an Azure Functions API (This article)
  4. SWA’s built-in authentication
  5. Local development with the swa CLI
  6. PR preview environments

Happy deploying, and if your API calls are returning HTML, check your staticwebapp.config.json.


This post was written with AI assistance and edited by me.


See also