Over the past few months, we have developed a workflow for building real GeoBlazor apps with Claude Code as the primary author of the code. The bridge demo it produces (and that we ran live on Wednesday, June 3, at the 2026 Iowa Technology & Geospatial Conference) starts from an empty terminal and ends with a running Blazor app that visualizes around 24,000 Iowa bridge inspections, color-coded by condition, with sidebar filters and a "Find Bridges at Risk" query. What follows is the stack we use, the workflow itself, the failure modes we encountered, and how the prompt evolved across runs.
What's in the Stack?
We used three tools together.
Claude Code is Anthropic's coding agent. It runs in a terminal next to your project, reads files, writes code across multiple files in a single turn, runs your build, and reports back when something breaks. For our purposes, it was the translator between "here is what we want the map to do" and the C# and Razor that made it happen.
GeoBlazor is our open-source library (MIT licensed) that wraps the ArcGIS Maps SDK for JavaScript in Blazor components, so a .NET team can build a serious GIS web app in C# without writing any JavaScript. FeatureLayer, UniqueValueRenderer, PopupTemplate, QueryFeatures: all of it shows up as Razor markup and C# APIs that the compiler can validate.
The ArcGIS Maps SDK is the Esri engine underneath everything: WebMaps, FeatureLayers, widgets, and Arcade, the expression language we reached for to compute a composite bridge condition from three raw inspection fields.
GeoBlazor's declarative Razor markup turned out to be a good fit for agentic code generation, because every map feature is a named C# component that the compiler can validate the moment Claude writes it. When the agent invented an API name, we usually found out before the code ever ran.
How Does the Describe-Build-Refine Workflow Run?
Every AI-assisted build we have done falls into the same three steps, in order. The work we put into the first step is what makes the other two manageable.
Describe
The first step was to write down what the map needed to do, in plain English. For the bridge demo, the starting description looked like this:
Build a Blazor map of Iowa's bridges from the National Bridge Inventory. Color them by condition: green for good, amber for fair, red for poor. Add a sidebar with filters for condition, year built, and county, and a button to find the bridges most at risk.
That was the seed, but the actual spec we handed Claude Code was several pages long, checked into the project as a CLAUDE.md file. It covered the plain-English intent, the GeoBlazor version we were targeting, the data schema with real field names, the visual design, and the success criteria. It also pinned the things we had learned to prevent in earlier runs.
Build
Claude Code read the spec, consulted the GeoBlazor docs and the ArcGIS service metadata at ?f=json, and wrote the project, scaffolding BridgeMap.razor, BridgeSidebar.razor, a BridgeFilterState record with a ToDefinitionExpression() method, an IowaCounties helper, and the page at Components/Pages/Home.razor. It ran dotnet build between major edits, and when something failed to compile, it read the error message and tried again.
What we have learned across several runs is that the spec is what makes "Build" work on the first pass. A vague spec produces a vague app; a spec that names the field, the renderer, and the version of GeoBlazor produces a tight first draft.
Refine
Once the initial build was running, we talked to Claude Code the way we would talk to a developer who has been on the team for a week: "The legend is too big." "That broke the popup." "The colors are all the same; none of the bridges are being classified." Each refinement was a sentence, not a ticket. The agent read the project state, identified a likely cause, applied a fix, and told us what it had changed so we could review the diff.
Refinement was also where we caught the bugs that compiled cleanly but rendered wrong. We will get to those in the next section.

What Does the Iowa Bridge App Actually Do?
The data source is the FHWA National Bridge Inventory hosted on NTAD's ArcGIS service, which contains all 50 states in one FeatureLayer. Our baseline DefinitionExpression keeps only Iowa:
STATE_CODE_001 = '19'
STATE_CODE_001 = '19'
Every user-applied filter (condition, year built, county) is combined with that Iowa clause, never replacing it. The condition checkboxes filter on the same deck-condition field the map colors by, so the dots that drop out always match the boxes you uncheck:
STATE_CODE_001 = '19' AND DECK_COND_058 IN ('0','1','2','3','4') AND YEAR_BUILT_027 BETWEEN 1920 AND 1980 AND COUNTY_CODE_003 = '013'
STATE_CODE_001 = '19' AND DECK_COND_058 IN ('0','1','2','3','4') AND YEAR_BUILT_027 BETWEEN 1920 AND 1980 AND COUNTY_CODE_003 = '013'
The condition coloring uses a UniqueValueRenderer keyed on the deck-condition field (DECK_COND_058), mapping the NBI 0-9 scale to Poor (0-4), Fair (5-6), and Good (7-9). Anything else, including the 'N' not-applicable code that culverts carry, falls through to a default "Unknown / culvert" symbol:
new UniqueValueRenderer(field: "DECK_COND_058", defaultLabel: "Unknown / culvert", defaultSymbol: greySymbol, uniqueValueInfos: [ new UniqueValueInfo("Poor (NBI 0-4)", poorSymbol, "0"), new UniqueValueInfo("Poor", poorSymbol, "1"), // codes 2-4 also map to Poor new UniqueValueInfo("Fair (NBI 5-6)", fairSymbol, "5"), new UniqueValueInfo("Fair", fairSymbol, "6"), new UniqueValueInfo("Good (NBI 7-9)", goodSymbol, "7") // codes 8-9 also map to Good ]);
new UniqueValueRenderer(field: "DECK_COND_058", defaultLabel: "Unknown / culvert", defaultSymbol: greySymbol, uniqueValueInfos: [ new UniqueValueInfo("Poor (NBI 0-4)", poorSymbol, "0"), new UniqueValueInfo("Poor", poorSymbol, "1"), // codes 2-4 also map to Poor new UniqueValueInfo("Fair (NBI 5-6)", fairSymbol, "5"), new UniqueValueInfo("Fair", fairSymbol, "6"), new UniqueValueInfo("Good (NBI 7-9)", goodSymbol, "7") // codes 8-9 also map to Good ]);
We originally specified an Arcade ValueExpression to compute a composite score from all three condition fields, but in GeoBlazor 4.4.4 it left every bridge classified as "Unknown" (one of the silent failures described below), so the app classifies by deck condition, which the service serves directly.
The Blazor-side wiring for filter changes follows GeoBlazor's async pattern:
await bridgeLayer.SetDefinitionExpression(filterState.ToDefinitionExpression());
await bridgeLayer.SetDefinitionExpression(filterState.ToDefinitionExpression());
The "Find Bridges at Risk" button runs a server-side QueryFeatures call rather than a filter. The query returns bridges built before 1960, with a composite condition score of 4 or below, and average daily traffic above 5,000 (typically a few dozen results across Iowa). Each result goes into a GraphicsLayer as a hollow accent-color ring drawn over the existing point, so the condition color and the at-risk highlight are both visible, and the view zooms to fit:
Query query = new() { Where = """ STATE_CODE_001 = '19' AND (DECK_COND_058 IN ('0','1','2','3','4') OR SUPERSTRUCTURE_COND_059 IN ('0','1','2','3','4') OR SUBSTRUCTURE_COND_060 IN ('0','1','2','3','4')) AND YEAR_BUILT_027 < 1960 AND ADT_029 > 5000 """, OutFields = ["*"], ReturnGeometry = true }; FeatureSet? result = await _bridgeLayer.QueryFeatures(query); foreach (Graphic feature in result.Features) { await feature.SetSymbol(_atRiskRingSymbol); await _atRiskLayer.Add(feature); } await _mapView.GoTo(result.Features);
Query query = new() { Where = """ STATE_CODE_001 = '19' AND (DECK_COND_058 IN ('0','1','2','3','4') OR SUPERSTRUCTURE_COND_059 IN ('0','1','2','3','4') OR SUBSTRUCTURE_COND_060 IN ('0','1','2','3','4')) AND YEAR_BUILT_027 < 1960 AND ADT_029 > 5000 """, OutFields = ["*"], ReturnGeometry = true }; FeatureSet? result = await _bridgeLayer.QueryFeatures(query); foreach (Graphic feature in result.Features) { await feature.SetSymbol(_atRiskRingSymbol); await _atRiskLayer.Add(feature); } await _mapView.GoTo(result.Features);
A few things in this code are worth pointing out. SetDefinitionExpression, QueryFeatures, SetSymbol, and GoTo are all Task-returning, and any state change after the first render is async. Telling the agent that up front prevents fire-and-forget patterns. The Iowa clause is always prefixed onto the filter expression, because the NTAD service is national. And we asked for a 250 ms debounce on the year slider; otherwise the agent would have wired it up to fire on every tick.
What Are the Real Failure Modes?
Across four runs of this same build, we kept a record of what actually went wrong. The pattern was more specific than "the AI hallucinates," and the failures fell into two groups. The second is the dangerous one.
Loud Failures the Build Catches
Claude did reach for the wrong API name on occasion, but the pattern was library version drift rather than pure invention. The most consequential example was ClassBreaksRenderer, which exists in the ArcGIS Maps SDK 5.x and in upcoming GeoBlazor pre-releases but not in the current 4.4.4 package. Our first spec told Claude to use it, and the agent had to switch to UniqueValueRenderer on its own. We hit a handful of others in the same family: UniqueValueInfo, SimpleMarkerSymbolStyle, an Extent constructor with a SpatialReference parameter that does not exist, a MapView.GoTo overload expecting an Extent when given a Point. In every case, Claude reached for the most common name in the ArcGIS JavaScript world and assumed GeoBlazor's C# bindings mirrored it. When they did not, the compiler told us within seconds, and Claude fixed it on the next pass.
GeoBlazor 5.x will greatly improve the API correlation between ArcGIS and GeoBlazor, making the task for AI agents much simpler.
Silent Failures That Compile and Run
These are the ones that survive a careless review and reach the running app looking like working features. In our most recent run, the agent built a UniqueValueRenderer with an Arcade ValueExpression that was syntactically valid, compiled, ran, and silently classified every bridge as "Unknown" because the expression evaluated against the wrong field reference. The legend showed four categories and every bridge on the map was grey. On a different run, the popup template had the right field bindings but no visible content, because the FieldInfo entries were declared as direct children of PopupTemplate (where they only register as metadata) instead of inside FieldsPopupContent. The popups opened with titles and an empty body, exactly the kind of thing a developer skim-reviewing a diff will not notice. On another run, the FeatureService's own default renderer was winning the race during initialization, so the map rendered with uniform red-brown dots instead of our color palette. The immediate fix was to assign the renderer with SetRenderer after the layer had initialized. The more permanent fix was to update the prompt with specific testing criteria.
Environment Traps
GeoBlazor's template ships an appsettings.Development.json with placeholder values for the ArcGIS API key and license key. Paste your real keys into appsettings.json but leave the placeholders in the Development file, and the Development file silently overrides your real keys at runtime. You get an InvalidRegistrationException that points at the registration, not the file. Claude figured this out on the second run, and we now call it out explicitly in the spec.
The Failure We Didn't See: Context Drift
One failure mode we did not observe, despite how often it gets cited: long sessions filling up the context window and contradicting earlier decisions. Each of our runs was 20–50 assistant turns, and the failures were front-loaded: wrong API names, wrong project structure, the silent renderer. Not late-context drift. For a 30-minute focused build, context exhaustion is not the thing to worry about. The silent renderer is.
Recovery Habits
Paste the full stack trace verbatim rather than paraphrasing. Describe what you saw versus what you expected, especially for visual bugs the build will not catch. And commit before any "let me refactor everything" request, so you always have a clean state to return to.
How Did the Spec Evolve Across Runs?
The spec we hand Claude Code today is not the spec we started with. Across four runs of this build, the prompt grew from roughly 5,700 characters to roughly 13,900, and almost every addition was the agent (or one of us) writing down a thing that went wrong. Treating the spec as a living document, and explicitly asking the agent to suggest edits to it after a run, has been the single most useful habit we have adopted.
After one of the runs, we paused and asked: "Review the prompt I gave you, and suggest changes based on what was wrong or misleading." Claude came back with a 2,000-word reply that read like a careful bug report against the spec, and is the source of almost every consequential addition in the runs after it. The additions, in roughly the order of time saved:
- A verified data source URL. The first run spent more than twenty minutes trying ArcGIS service URLs that returned 400 errors. The current spec hardcodes the FHWA NBI URL, and Claude no longer goes hunting.
- A pinned render mode. Mixing
InteractiveAutowith a server-only project produced confusingCS0234errors. The current spec is explicit: this app uses Interactive Server. - Real keys pasted in, with a warning about the placeholder trap described above.
- A hard 20-minute time budget on getting to a running app. Mixed result. In the latest run, the app was running and viewable 12 minutes after start. But the time pressure surfaced a shortcut. Claude said in the log, "Moving on. I'll write code based on known patterns and fix issues at build time," and that decision is what produced the silent Arcade renderer failure. We are still tuning this one.

- The canonical NBI field list, with types and gotchas. Naming
OPERATING_RATING_064explicitly (rather than the olderOPER_RATING_064from outdated documentation) prevents a guess that compiles fine but throws at runtime. - The async return-type convention. Spelling out that
SetDefinitionExpression,QueryFeatures, andGoToare allTask-returning prevents fire-and-forget patterns.
Not every addition stuck. Specifying file layout to the byte produced a sprawling sidebar component; we now describe the public surface of each file instead. The pattern that works is: be explicit about anything the agent cannot infer from public docs (your data schema, your keys, your version), and loose about anything the agent can iterate on safely.
Who Does AI-Assisted GeoBlazor Development Change Things For?
The shift looks different depending on what you do.
For a GIS analyst, the immediate change is being able to produce a working prototype inside an afternoon. The map you described to a developer at a meeting last week is now something you can show your boss at the end of the day, and dashboards, internal tools, and public-facing maps all move from "we should scope that" to "let me try it." The judgment calls that used to need a developer's calendar (what the legend should say, what color scale fits the audience, how the popup should read) are now where the human time goes.
For a .NET developer, the change is less about new capabilities and more about which boilerplate you stop writing. The fiftieth time you wire up a MapView, a FeatureLayer, a Renderer, and a PopupTemplate, the marginal value of doing it by hand approaches zero. The time we used to spend on the rote parts goes into the parts that need engineering judgment: performance, security, data integrity, error handling, accessibility. Claude is also a surprisingly capable tutor for the corners of a domain you only half know, happy to explain what an Arcade expression is doing or point out that your spatial join is returning nulls because two layers are in different projections. Not a substitute for domain experience, but a useful sparring partner for one.
For someone who decides what to build, prototyping is now cheap enough to use as a way of thinking through an idea rather than a way of delivering one. We have started building several versions of a dashboard before writing the spec for it, on the theory that picking the right thing to build is harder than building it.
What stays on our side of the line is deciding what a good answer looks like for our users and our data, and noticing when Claude's plausible-sounding answer is wrong for the situation. The agent will produce something that looks right; whether it is right is still our call.
Try It Yourself
GeoBlazor is open source under the MIT license, Claude Code has a free tier, and the Iowa DOT bridge data is a public ArcGIS service. There is nothing stopping you from trying this Monday morning.
- Iowa Bridges sample project on GitHub — the finished code from this post
- GeoBlazor on GitHub
- GeoBlazor documentation
- GeoBlazor NuGet package
- Claude Code
- FHWA National Bridge Inventory (NTAD)
Frequently Asked Questions
What does Claude Code add to a GeoBlazor project that a developer can't get from regular autocomplete?
Claude Code reads your project files, writes coordinated changes across multiple files, runs your build, and reports back when something breaks. It acts as a translator between a plain-English description of what the map should do and the C# and Razor markup that makes it happen, rather than offering single-line suggestions.
Why is GeoBlazor a good fit for AI-assisted code generation?
GeoBlazor wraps the ArcGIS Maps SDK in named Razor components like FeatureLayer, UniqueValueRenderer, and PopupTemplate. Because every map feature is a typed C# component, the compiler can validate the code the agent generates, which catches a large class of hallucinations before runtime.
What does an AI agent need to know about your data that it can't figure out on its own?
The agent can't see private portal URLs, API tokens, or your custom layer schema. Field names like ADT_029 for Average Daily Traffic and OPERATING_RATING_064 for operating rating in the FHWA National Bridge Inventory need to be supplied explicitly in the prompt, otherwise the agent may guess and sometimes guess wrong.
What's the difference between loud and silent failures when an AI agent writes GeoBlazor code?
Loud failures are caught by the build — wrong API names, missing constructors, type mismatches — and the agent fixes them on the next pass. Silent failures compile and run but render the wrong result, like an Arcade renderer that classifies every feature as "Unknown" or a popup template whose FieldInfo entries are in the wrong parent component so the body renders empty. Silent failures are the ones that survive a careless review.
