DEV Community

Steven Wallace
Steven Wallace

Posted on

I Built a Spaced Repetition Flashcard App and Deployed It to Azure for $5/month

A couple of years ago, I built a custom flashcard app. I had a huge list of words and sentences in Japanese that I collected in an Excel file. I wanted an app that could easily take them and display them on flashcards.

The flashcard app was useful, but the main issue was that I could only use it on my laptop. This meant that when I wasn't home, I had no access to it. I made some updates so that I could deploy it to Azure and now I can use it on the train or at the park. I wanted to share the app and lessons learned during development.

Main Menu

What It Does

The app is a straightforward spaced repetition flashcard tool. You create collections, fill them with cards (front/back/optional notes), and review them. After each card you rate your recall:

Button Meaning
Easy Remembered without effort
Good Remembered correctly
Hard Remembered with difficulty
Again Forgot (resets to day 1)

Ratings feed the SM-2 algorithm, which is the same algorithm as other popular spaced repetition apps like Anki. Cards that are easy get pushed further and further into the future. Cards that are difficult will come back sooner. After a while, you're just reviewing what you actually need to review.

There's also a 45-second timer per card. If it expires before you complete the card, it automatically counts as Again (Resets to day 1). Before the timer, I found it easy to lose focus or open another tab and forget about the current card. This has helped me stay focused for longer and stay on this task.

The CSS is specifically designed to be mobile friendly.

The Tech Stack

  • Frontend: Blazor WebAssembly (.NET 10)
  • Backend: ASP.NET Core minimal API (.NET 10)
  • Database: Azure SQL (Basic DTU tier)
  • Hosting: Azure Static Web Apps (frontend) + Azure App Service F1 free tier (backend)

I mostly use C# at work, so Blazor WASM was a natural fit. The whole app shares models and flows together without jumping between languages.

Importing Cards from Excel

This Excel import function is one of the main reasons I made this app. It only has three columns, Front, Back, and Notes. Back and Notes shows together when the card is flipped over.

The backend uses ClosedXML to parse the file:

// Import flashcards from an .xlsx file (multipart form); expected columns: front, back, notes (optional)
app.MapPost("/collections/{id}/import", async (int id, HttpRequest request, FlashcardsDbContext db) =>
{
    var file = request.Form.Files[0];
    using var workbook = new XLWorkbook(file.OpenReadStream());
    var sheet = workbook.Worksheet(1);

    var headerRow = sheet.Row(1);
    int? frontCol = null, backCol = null, notesCol = null;
    foreach (var cell in headerRow.CellsUsed())
    {
        switch (cell.GetString().Trim().ToLowerInvariant())
        {
            case "front": frontCol = cell.Address.ColumnNumber; break;
            case "back":  backCol  = cell.Address.ColumnNumber; break;
            case "notes": notesCol = cell.Address.ColumnNumber; break;
        }
    }
    // ... build and save cards
});
Enter fullscreen mode Exit fullscreen mode

The column detection is case-insensitive and the collection page has a template download so you don't have to guess the format.

The SM-2 Algorithm

SM-2 is fairly compact. It runs using an easiness factor (EF) per card that adjusts based on how well you know it, and an interval that grows exponentially the more you know a card.

// quality: 0=Again, 1=Easy, 2=Normal, 3=Hard
float newEf = currentEf + (0.1f - (5 - sm2Quality) * (0.08f + (5 - sm2Quality) * 0.02f));
newEf = Math.Max(1.3f, newEf); // EF never drops below 1.3

newInterval = currentRepetitions switch
{
    0 => 1,   // first review: come back tomorrow
    1 => 6,   // second review: come back in 6 days
    _ => (int)Math.Round(currentInterval * currentEf) // growing intervals after that
};
Enter fullscreen mode Exit fullscreen mode

The constants (0.1, 0.08, 0.02) were found in Piotr Wozniak's original SM-2 paper. They are tuned values that have held up since he wrote his paper, so I just kept them as-is for this app.

Here is a link to an explanation of the algorithm by Wozniak for anyone interested.

Collection Page

Deploying to Azure

This app will run locally without deploying it to Azure, but I decided to deploy it to the cloud so that I could use it on my phone. It is a small app with minimal compute requirements. I also only use it at most an hour a day, so I wanted to keep the costs low.

This is the setup that I found to balance cost and capability.

  • Azure Static Web Apps hosts the Blazor WASM frontend: Free tier, deployed automatically via GitHub Actions on every push to main
  • Azure App Service F1 hosts the API: Free tier, with 60 minutes of CPU compute per day. For a lightweight flashcard app this is more than enough.
  • Azure SQL Basic DTU for the database: ~$5/month flat, always on, 2GB storage. I started with the Serverless tier thinking auto-pause would save money, but the minimum billing window kept it around $17/month. Basic DTU is simpler and cheaper for light daily use.

Passwordless Auth with Managed Identity

The authentication setup between the backend and the database was designed to use Managed Identities for authentication, so no need to mess with SQL passwords.

The connection string looks like this:

Server=tcp:your-server.database.windows.net,1433;
Initial Catalog=your-database;
Authentication=Active Directory Managed Identity;
Encrypt=True
Enter fullscreen mode Exit fullscreen mode

No password. No secret rotation. No risk of accidentally committing credentials. You just grant the identity access in SQL:

CREATE USER [your-app-service] FROM EXTERNAL PROVIDER;
ALTER ROLE db_datareader ADD MEMBER [your-app-service];
ALTER ROLE db_datawriter ADD MEMBER [your-app-service];
Enter fullscreen mode Exit fullscreen mode

For local development, I use SQL Server LocalDB with a trusted connection, loaded from a .env file that's gitignored.

Infrastructure as Code with Bicep

All the Azure resources are defined in a single Bicep file, so you can spin up your own instance in two commands:

az group create --name Flashcards --location japaneast

az deployment group create `
  --resource-group Flashcards `
  --template-file infra/main.bicep `
  --parameters entraAdminLogin="you@example.com" entraAdminObjectId="<your-object-id>"
Enter fullscreen mode Exit fullscreen mode

The file provisions the SQL server, database, App Service plan, App Service (with Managed Identity already wired up), and Static Web App. The only post-deployment step that can't be automated in Bicep is granting the Managed Identity access to the database. That requires running a couple of SQL commands directly, which are documented in the repo.

Flashcard

User Authentication with Entra ID

The API was initially publicly accessible to anyone who knew the URL, which was something I knew I needed to fix. I added user auth using Microsoft Entra ID (Azure AD) with the PKCE flow, which is the standard approach for browser-based apps where you can't safely store a client secret.

The frontend uses Microsoft.Authentication.WebAssembly.Msal to redirect to Microsoft's login page and receive an ID token on the way back. The backend uses Microsoft.Identity.Web to validate the JWT on every request. The client ID and tenant ID are injected into appsettings.json at build time by GitHub Actions so they never live in the repo.

The one issue I ran into is that in Blazor WASM, AuthorizeRouteView invokes its <NotAuthorized> handler for any page that has [Authorize] and the user isn't authenticated. The problem is that the login callback page is inherently unauthenticated at the moment it loads because the token hasn't arrived yet. If the callback page triggers <NotAuthorized>, it stores the callback URL as the post-login redirect target, and the app loops forever on "Completing login...".

The fix is to declare @attribute [Authorize] explicitly on each page that needs protection, rather than relying on a blanket global handler. <NotAuthorized> then only fires for those specific pages, and the callback page is left alone to complete the login flow.

What I'd Add Next

The app does exactly what I need it to. But if I were to keep building:

  • Progress charts: It would be nice to see breakdowns of how I'm studying and what I'm studying. I have a count of how many cards I have studied, but showing charts would make it look better visually, and help me see anywhere that I could improve.
  • Offline support: A Progressive Web App (PWA) mode would allow me to use it on a plane or in locations with spotty Wi-Fi.

The code is open source here on GitHub if you want to take a look or make your own instance.

Top comments (0)