Blazingly Fast Serverless Note App with Blazor, Amazon Bedrock and .NET 10 Native AOT
I have already explored building native AOT Lambda functions for .NET 8, before the official runtime was announced, using Amazon Linux 2023 custom Lambda runtime. With this post, I wanted to revisit that topic but focusing on .NET 10 with the addition of a frontend, using Blazor and the component library MudBlazor. On top of that, I wanted to utilize Amazon Bedrock and the Nova Lite AI model. The result is a blazingly fast AI powered note app, where Bedrock will be used to categorize notes that the user creates.
One of the goals with this project is to utilize C# for the entire stack, therefore we will use AWS Cloud Development Kit (CDK) to define the infrastructure, in C#.
If you are curious about the benefits of building native AOT applications in .NET, check out the previous post.
Implementation
Solution Architecture
In addition to AWS Lambda and Amazon Bedrock, this project will utilize Amazon CloudFront to serve the Blazor application with Amazon S3 as the static origin, Amazon Cognito for auth, and Amazon DynamoDB, where we will store the notes.

Solution Structure
The solution will be structured like this:
. ├── infra/ │ └── SmartNoteOrganizer.Infrastructure/ └── src/ ├── SmartNoteOrganizer.AI/ ├── SmartNoteOrganizer.Notes/ ├── SmartNoteOrganizer.Shared/ └── SmartNoteOrganizer.UI/
Bootstrapping
We will use dotnet templates to kickstart the project, let's start with generating the solution and the projects:
dotnet new sln -n SmartNoteOrganizer # Backend & Infrastructure dotnet new classlib -n SmartNoteOrganizer.Infrastructure -f net10.0 -o infra/SmartNoteOrganizer.Infrastructure dotnet new classlib -n SmartNoteOrganizer.Shared -f net10.0 -o src/SmartNoteOrganizer.Shared dotnet new web -n SmartNoteOrganizer.Notes -f net10.0 -o src/SmartNoteOrganizer.Notes dotnet new classlib -n SmartNoteOrganizer.AI -f net10.0 -o src/SmartNoteOrganizer.AI
Now, let's add the required NuGet packages:
# CDK dotnet add infra/SmartNoteOrganizer.Infrastructure/SmartNoteOrganizer.Infrastructure.csproj package Amazon.CDK.Lib dotnet add infra/SmartNoteOrganizer.Infrastructure/SmartNoteOrganizer.Infrastructure.csproj package Constructs # Lambda dotnet add src/SmartNoteOrganizer.Notes/SmartNoteOrganizer.Notes.csproj package Amazon.Lambda.AspNetCoreServer.Hosting dotnet add src/SmartNoteOrganizer.Notes/SmartNoteOrganizer.Notes.csproj package AWSSDK.DynamoDBv2 dotnet add src/SmartNoteOrganizer.Notes/SmartNoteOrganizer.Notes.csproj package AWSSDK.BedrockRuntime # AI dotnet add src/SmartNoteOrganizer.AI/SmartNoteOrganizer.AI.csproj package AWSSDK.BedrockRuntime # References dotnet add src/SmartNoteOrganizer.Notes/SmartNoteOrganizer.Notes.csproj reference src/SmartNoteOrganizer.AI/SmartNoteOrganizer.AI.csproj dotnet add src/SmartNoteOrganizer.Notes/SmartNoteOrganizer.Notes.csproj reference src/SmartNoteOrganizer.Shared/SmartNoteOrganizer.Shared.csproj
Shared Models
Great! Now let's implement the shared models. Create the file src/SmartNoteOrganizer.Shared/Models.cs and define the Note models:
namespace SmartNoteOrganizer.Shared; public record Note(string Id, string UserId, string Content, List<string> Tags, DateTime CreatedAt, DateTime UpdatedAt); public record CreateNoteRequest(string Content); public record NoteResponse(string Id, string Content, List<string> Tags, List<string> SuggestedTags, DateTime CreatedAt);
Configure Lambda for Native AOT
In src/SmartNoteOrganizer.Notes/SmartNoteOrganizer.Notes.csproj, edit the first PropertyGroup:
<PropertyGroup> <TargetFramework>net10.0</TargetFramework> <PublishAot>true</PublishAot> <AssemblyName>bootstrap</AssemblyName> <RuntimeIdentifiers>linux-arm64</RuntimeIdentifiers> <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault> </PropertyGroup>
Now, let's create LambdaJsonSerializerContext.cs in the notes project:
using System.Text.Json.Serialization; using Amazon.Lambda.APIGatewayEvents; using SmartNoteOrganizer.Shared; namespace SmartNoteOrganizer.Notes; // Lambda runtime types [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyRequest))] [JsonSerializable(typeof(APIGatewayHttpApiV2ProxyResponse))] // Endpoint types [JsonSerializable(typeof(CreateNoteRequest))] [JsonSerializable(typeof(NoteResponse))] [JsonSerializable(typeof(List<Note>))] public partial class LambdaJsonSerializerContext : JsonSerializerContext;
And update Program.cs to utilize the json serializer context:
using Amazon.Lambda.Serialization.SystemTextJson; builder.Services.AddAWSLambdaHosting(LambdaEventSource.HttpApi, new SourceGeneratorLambdaJsonSerializer<LambdaJsonSerializerContext>()); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Insert(0, LambdaJsonSerializerContext.Default); });
Implement Bedrock AI Service
First, let's mark SmartNoteOrganizer.AI.csproj as AOT-compatible:
<IsAotCompatible>true</IsAotCompatible> <JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
And create the JSON serializer context based on the Nova API, create BedrockJsonSerializerContext.cs:
using System.Text.Json.Serialization; namespace SmartNoteOrganizer.AI; [JsonSerializable(typeof(NovaRequest))] [JsonSerializable(typeof(NovaMessage))] [JsonSerializable(typeof(NovaContent))] [JsonSerializable(typeof(NovaInferenceConfig))] [JsonSerializable(typeof(NovaResponse))] [JsonSerializable(typeof(NovaOutput))] [JsonSerializable(typeof(NovaResponseMessage))] [JsonSerializable(typeof(List<string>))] public partial class BedrockJsonSerializerContext : JsonSerializerContext; public record NovaRequest( [property: JsonPropertyName("messages")] NovaMessage[] Messages, [property: JsonPropertyName("inferenceConfig")] NovaInferenceConfig InferenceConfig ); public record NovaMessage( [property: JsonPropertyName("role")] string Role, [property: JsonPropertyName("content")] NovaContent[] Content ); public record NovaContent( [property: JsonPropertyName("text")] string Text ); public record NovaInferenceConfig( [property: JsonPropertyName("max_new_tokens")] int MaxTokens, [property: JsonPropertyName("temperature")] float Temperature, [property: JsonPropertyName("top_p")] float TopP ); public record NovaResponse( [property: JsonPropertyName("output")] NovaOutput? Output ); public record NovaOutput( [property: JsonPropertyName("message")] NovaResponseMessage? Message ); public record NovaResponseMessage( [property: JsonPropertyName("content")] List<NovaContent>? Content );
Now it's time to create the service which will utilize Bedrock to categorize notes for the user, create BedrockService.cs:
using System.Text.Json; using Amazon.BedrockRuntime; using Amazon.BedrockRuntime.Model; namespace SmartNoteOrganizer.AI; public interface IBedrockService { Task<List<string>> SuggestTagsAsync(string noteContent); } public class BedrockService(IAmazonBedrockRuntime bedrockClient) : IBedrockService { private const string ModelId = "amazon.nova-lite-v1:0"; public async Task<List<string>> SuggestTagsAsync(string noteContent) { var novaRequest = new NovaRequest( Messages: [ new NovaMessage( Role: "user", Content: [ new NovaContent( Text: $""" Analyze this note and suggest 3-5 relevant tags that categorize it. Return ONLY a JSON array of tag strings, nothing else. Note: {noteContent} Example response: ["work", "meeting", "important"] """ ) ] ) ], InferenceConfig: new NovaInferenceConfig( MaxTokens: 200, Temperature: 0.3f, TopP: 0.9f ) ); var request = new InvokeModelRequest { ModelId = ModelId, ContentType = "application/json", Body = new MemoryStream(JsonSerializer.SerializeToUtf8Bytes( novaRequest, BedrockJsonSerializerContext.Default.NovaRequest)) }; try { var response = await bedrockClient.InvokeModelAsync(request); using var reader = new StreamReader(response.Body); var responseText = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(responseText)) return []; var result = JsonSerializer.Deserialize( responseText, BedrockJsonSerializerContext.Default.NovaResponse); var tagsJson = result?.Output?.Message?.Content?.FirstOrDefault()?.Text?.Trim() ?? "[]"; // Strip markdown code blocks if present if (tagsJson.StartsWith("```")) { var lines = tagsJson.Split('\n'); tagsJson = string.Join('\n', lines.Skip(1).Take(lines.Length - 2)).Trim(); } return JsonSerializer.Deserialize( tagsJson, BedrockJsonSerializerContext.Default.ListString) ?? []; } catch { return []; } } }
This uses amazon.nova-lite-v1:0, which is AWS's fastest model, excellent for simple tasks like this.
Implement DynamoDB Repository
Now create NotesRepository.cs in the notes project. The repository will handle two tasks: creating and listing user notes.
using Amazon.DynamoDBv2; using Amazon.DynamoDBv2.DocumentModel; using SmartNoteOrganizer.Shared; public interface INotesRepository { Task<Note> CreateNoteAsync(string userId, string content, List<string> tags); Task<List<Note>> GetUserNotesAsync(string userId); } public class NotesRepository(IAmazonDynamoDB dynamoDb, string tableName) : INotesRepository { private readonly Table _table = new TableBuilder(dynamoDb, tableName) .AddHashKey("PK", DynamoDBEntryType.String) .AddRangeKey("SK", DynamoDBEntryType.String) .Build(); public async Task<Note> CreateNoteAsync(string userId, string content, List<string> tags) { var now = DateTime.UtcNow; var note = new Note(Guid.NewGuid().ToString(), userId, content, tags, now, now); await _table.PutItemAsync(new Document { ["PK"] = $"USER#{userId}", ["SK"] = $"NOTE#{note.Id}", ["Id"] = note.Id, ["UserId"] = note.UserId, ["Content"] = note.Content, ["Tags"] = tags, ["CreatedAt"] = now.ToString("O"), ["UpdatedAt"] = now.ToString("O") }); return note; } public async Task<List<Note>> GetUserNotesAsync(string userId) => [.. (await _table.Query($"USER#{userId}", new QueryFilter()).GetRemainingAsync()) .Select(doc => new Note( doc["Id"], doc["UserId"], doc["Content"], doc["Tags"].AsListOfString(), DateTime.Parse(doc["CreatedAt"]), DateTime.Parse(doc["UpdatedAt"]) ))]; }
Now update the dependency injection to add the required services, in src/SmartNoteOrganizer.Notes/Program.cs, add:
builder.Services.AddSingleton<IAmazonDynamoDB, AmazonDynamoDBClient>(); builder.Services.AddSingleton<IAmazonBedrockRuntime, AmazonBedrockRuntimeClient>(); builder.Services.AddSingleton<INotesRepository>(sp => new NotesRepository(sp.GetRequiredService<IAmazonDynamoDB>(), Environment.GetEnvironmentVariable("TABLE_NAME") ?? "SmartNotes")); builder.Services.AddSingleton<IBedrockService, BedrockService>();
Map Our API Endpoints
We will need two API endpoints for this project, GET (to list) and POST (to create) notes. In Program.cs, add:
app.MapGet("/notes", async (INotesRepository repo, HttpContext context) => Results.Ok(await repo.GetUserNotesAsync(context.User.FindFirst("sub")!.Value))); app.MapPost("/notes", async (CreateNoteRequest request, INotesRepository repo, IBedrockService bedrock, HttpContext context) => { var userId = context.User.FindFirst("sub")!.Value; var tags = await bedrock.SuggestTagsAsync(request.Content); var note = await repo.CreateNoteAsync(userId, request.Content, tags); return Results.Created($"/notes/{note.Id}", new NoteResponse(note.Id, note.Content, note.Tags, tags, note.CreatedAt)); });
Define Backend Infrastructure with CDK
Now let's create the backend infrastructure in infra/SmartNoteOrganizer.Infrastructure/SmartNoteStack.cs. Start with the DynamoDB table:
using Amazon.CDK; using Amazon.CDK.AWS.Cognito; using Amazon.CDK.AWS.DynamoDB; using Amazon.CDK.AWS.IAM; using Amazon.CDK.AWS.Lambda; using Amazon.CDK.AWS.Apigatewayv2; using Amazon.CDK.AwsApigatewayv2Authorizers; using Amazon.CDK.AwsApigatewayv2Integrations; using Constructs; using Attribute = Amazon.CDK.AWS.DynamoDB.Attribute; namespace SmartNoteOrganizer.Infrastructure; public class SmartNoteStack : Stack { private static readonly string[] BuildCommands = [ "apt-get update", "apt-get install -y clang zlib1g-dev", "cd /asset-input/src/SmartNoteOrganizer.Notes", "dotnet publish -c Release -r linux-arm64 -o /asset-output", "chmod 755 /asset-output/bootstrap" ]; public SmartNoteStack(Construct scope, string id, IStackProps? props = null) : base(scope, id, props) { // DynamoDB table with single-table design var notesTable = new Table(this, "NotesTable", new TableProps { PartitionKey = new Attribute { Name = "PK", Type = AttributeType.STRING }, SortKey = new Attribute { Name = "SK", Type = AttributeType.STRING }, BillingMode = BillingMode.PAY_PER_REQUEST, RemovalPolicy = RemovalPolicy.DESTROY }); // Cognito User Pool var userPool = new UserPool(this, "UserPool", new UserPoolProps { SelfSignUpEnabled = true, SignInAliases = new SignInAliases { Email = true }, RemovalPolicy = RemovalPolicy.DESTROY }); var userPoolDomain = userPool.AddDomain("UserPoolDomain", new UserPoolDomainOptions { CognitoDomain = new CognitoDomainOptions { DomainPrefix = "smart-notes-" + Account } }); // Define OAuth2 Resource Server with custom scopes var readScope = new ResourceServerScope(new ResourceServerScopeProps { ScopeName = "read", ScopeDescription = "Read access to notes" }); var writeScope = new ResourceServerScope(new ResourceServerScopeProps { ScopeName = "write", ScopeDescription = "Write access to notes" }); var resourceServer = userPool.AddResourceServer("NotesResourceServer", new UserPoolResourceServerOptions { Identifier = "notes-api", Scopes = [readScope, writeScope] }); // Lambda function with Native AOT, ARM64, ZIP package var lambdaFunction = new Function(this, "NotesFunction", new FunctionProps { Runtime = Runtime.PROVIDED_AL2023, Architecture = Architecture.ARM_64, Handler = "bootstrap", Code = Code.FromAsset("../", new Amazon.CDK.AWS.S3.Assets.AssetOptions { Bundling = new BundlingOptions { Image = DockerImage.FromRegistry("mcr.microsoft.com/dotnet/sdk:10.0"), User = "root", Command = ["bash", "-c", string.Join(" && ", BuildCommands)] } }), MemorySize = 512, Timeout = Duration.Seconds(30), Environment = new Dictionary<string, string> { ["TABLE_NAME"] = notesTable.TableName, ["ALLOWED_ORIGINS"] = "*" } }); // Grant permissions notesTable.GrantReadWriteData(lambdaFunction); lambdaFunction.AddToRolePolicy(new PolicyStatement(new PolicyStatementProps { Effect = Effect.ALLOW, Actions = ["bedrock:InvokeModel"], Resources = ["*"] })); // User Pool Client (callback URL will be updated in part 2 with CloudFront URL) var userPoolClient = new UserPoolClient(this, "UserPoolClient", new UserPoolClientProps { UserPool = userPool, AuthFlows = new AuthFlow { UserPassword = true, UserSrp = true }, OAuth = new OAuthSettings { Flows = new OAuthFlows { AuthorizationCodeGrant = true }, Scopes = [ OAuthScope.EMAIL, OAuthScope.OPENID, OAuthScope.PROFILE, OAuthScope.ResourceServer(resourceServer, readScope), OAuthScope.ResourceServer(resourceServer, writeScope) ], CallbackUrls = ["http://localhost:3000/"] // Placeholder, updated in part 2 }, GenerateSecret = false }); // JWT Authorizer var authorizer = new HttpJwtAuthorizer("CognitoAuthorizer", $"https://cognito-idp.{Region}.amazonaws.com/{userPool.UserPoolId}", new HttpJwtAuthorizerProps { JwtAudience = [userPoolClient.UserPoolClientId] }); // HTTP API with JWT authorization var api = new HttpApi(this, "NotesApi", new HttpApiProps { ApiName = "Smart Note Organizer API", DefaultAuthorizer = authorizer, DefaultAuthorizationScopes = ["notes-api/read", "notes-api/write"], CorsPreflight = new CorsPreflightOptions { AllowOrigins = ["*"], AllowMethods = [CorsHttpMethod.GET, CorsHttpMethod.POST, CorsHttpMethod.OPTIONS], AllowHeaders = ["Content-Type", "Authorization"] } }); // API Routes with scope-based authorization api.AddRoutes(new AddRoutesOptions { Path = "/notes", Methods = [HttpMethod.GET], Integration = new HttpLambdaIntegration("GetNotesIntegration", lambdaFunction), AuthorizationScopes = ["notes-api/read"] }); api.AddRoutes(new AddRoutesOptions { Path = "/notes", Methods = [HttpMethod.POST], Integration = new HttpLambdaIntegration("CreateNoteIntegration", lambdaFunction), AuthorizationScopes = ["notes-api/write"] }); // Outputs _ = new CfnOutput(this, "ApiUrl", new CfnOutputProps { Value = api.Url ?? "" }); _ = new CfnOutput(this, "UserPoolId", new CfnOutputProps { Value = userPool.UserPoolId }); _ = new CfnOutput(this, "UserPoolClientId", new CfnOutputProps { Value = userPoolClient.UserPoolClientId }); _ = new CfnOutput(this, "CognitoLoginUrl", new CfnOutputProps { Value = $"https://{userPoolDomain.DomainName}.auth.{Region}.amazoncognito.com" }); } }
The CDK uses Docker bundling to build the Native AOT Lambda inside a Linux container. This solves the cross-compilation challenge when developing on macOS. The Lambda is packaged as a ZIP file for optimal cold start performance.
Create infra/cdk.json:
{ "app": "dotnet run --project SmartNoteOrganizer.Infrastructure/SmartNoteOrganizer.Infrastructure.csproj" }
Conclusion
We've now built a blazingly fast serverless backend using .NET 10 Native AOT on AWS Lambda with ARM64/Graviton. The API integrates with Amazon Bedrock Nova Lite for AI-powered tag suggestions and stores data in DynamoDB using a single-table design. With JWT authentication via Cognito, the backend is secure and ready for production.
In part 2, we'll build the Blazor WebAssembly frontend with MudBlazor for Material Design components, implement OAuth2 authentication, and deploy everything to AWS with CDK. The complete code is available in the companion repository.