diff --git a/.agents/build-release-ci.md b/.agents/build-release-ci.md
index b6192ff..8a87bb9 100644
--- a/.agents/build-release-ci.md
+++ b/.agents/build-release-ci.md
@@ -8,6 +8,12 @@ Procedure-level detail for humans is in [`RELEASING.md`](../RELEASING.md); this
`netstandard2.0` is what covers .NET Framework / older runtimes — there are **no** `net481`/`net462`
TFMs anymore (dropped in 2.0, along with `net6.0`).
- **DI extension:** mirrors the same three TFMs.
+- **Templates package `GoogleMapsApi.Templates`** (`dotnet new` template, ships the `googlemaps-webapi`
+ template): a `PackageType=Template` package — no build output (`IncludeBuildOutput=false`,
+ `EnableDefaultCompileItems=false`), so its single `netstandard2.0` TFM is metadata-only. Its
+ `templates/` tree packs under `content/`. A `BeforeTargets="_GetPackageFiles"` target stamps the
+ resolved MinVer version into the packed `template.json`, so generated projects reference the
+ matching `GoogleMapsApi` version.
- **Tests:** `net8.0; net10.0`. **Samples:** `net10.0; net8.0`.
- `LangVersion=latest`, `Nullable=enable`, `GenerateDocumentationFile=true` (CS1591 suppressed).
@@ -18,7 +24,9 @@ Procedure-level detail for humans is in [`RELEASING.md`](../RELEASING.md); this
Versions come from **git tags** via **MinVer** (`v`). No version is
written in a csproj. `GeneratePackageOnBuild=true`, SourceLink + symbol packages (`.snupkg`) are on.
-Both packages are versioned in **lockstep** from the same `v*` tag.
+All three packages (`GoogleMapsApi`, `…Extensions.DependencyInjection`, `…Templates`) are versioned in
+**lockstep** from the same `v*` tag. Adding a packable project to `GoogleMapsApi.sln` is enough for the
+publish workflow to pack and push it — it globs `*.nupkg`, no per-package list.
## Release flow (`release.sh`)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8a877c7..8ef5689 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -8,7 +8,8 @@ one file relevant to your task.
A strongly-typed .NET wrapper for the Google Maps Web Services APIs (Geocoding, Directions/Routes,
Distance Matrix, Elevation, Time Zone, Places (New), Address Validation, Static Maps). Shipped on
-NuGet as `GoogleMapsApi` (+ a sibling `GoogleMapsApi.Extensions.DependencyInjection`).
+NuGet as `GoogleMapsApi` (+ siblings `GoogleMapsApi.Extensions.DependencyInjection` and the
+`dotnet new` template pack `GoogleMapsApi.Templates`).
## Architecture in 30 seconds
@@ -21,7 +22,8 @@ NuGet as `GoogleMapsApi` (+ a sibling `GoogleMapsApi.Extensions.DependencyInject
mismatches a compile error.
- **Observability is built in:** one OpenTelemetry span per call from `ActivitySource "GoogleMapsApi"`,
with the API key/signature redacted from the traced URL.
-- **Two packages, lockstep-versioned:** core `GoogleMapsApi` and the optional DI extension.
+- **Three packages, lockstep-versioned:** core `GoogleMapsApi`, the optional DI extension, and the
+ `dotnet new` template pack `GoogleMapsApi.Templates`.
Flow: `GoogleMapsClient` → `IEngineFacade` → `HttpClientEngineFacade` (internal) → `MapsAPIGenericEngine` (internal abstract; static-method HTTP+JSON engine).
diff --git a/GoogleMapsApi.Templates/.gitignore b/GoogleMapsApi.Templates/.gitignore
new file mode 100644
index 0000000..2b187a3
--- /dev/null
+++ b/GoogleMapsApi.Templates/.gitignore
@@ -0,0 +1,4 @@
+# The files under templates/ are template SOURCE and must be committed verbatim so they ship in
+# the NuGet package (CI builds from a clean git checkout). The repo-root .gitignore rule
+# `**/appsettings.json` would otherwise exclude the template's own appsettings.json — re-include it.
+!templates/**/appsettings.json
diff --git a/GoogleMapsApi.Templates/GoogleMapsApi.Templates.csproj b/GoogleMapsApi.Templates/GoogleMapsApi.Templates.csproj
new file mode 100644
index 0000000..2f35009
--- /dev/null
+++ b/GoogleMapsApi.Templates/GoogleMapsApi.Templates.csproj
@@ -0,0 +1,67 @@
+
+
+ netstandard2.0
+ v
+ True
+ Template
+ GoogleMapsApi.Templates
+ Google Maps API project templates
+ Maxim Novak
+ BSD-2-Clause
+ dotnet new templates for getting started with GoogleMapsApi. Includes "googlemaps-webapi" — a minimal ASP.NET Core Web API wired to IGoogleMapsClient.
+ Copyright © 2010-$([System.DateTime]::Now.Year)
+ Google;Maps;API;dotnet-new;Template;Scaffold;AspNetCore
+ git
+ https://github.com/maximn/google-maps
+ https://github.com/maximn/google-maps
+ README.md
+ true
+
+ false
+ content
+ true
+ $(NoWarn);NU5128;CS1591
+ false
+ false
+ true
+
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <_TemplateConfig>$(IntermediateOutputPath)template.json
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/dotnetcli.host.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/dotnetcli.host.json
new file mode 100644
index 0000000..c9ecbb5
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/dotnetcli.host.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "http://json.schemastore.org/dotnetcli.host",
+ "symbolInfo": {
+ "apikey": {
+ "longName": "apikey",
+ "shortName": ""
+ },
+ "Framework": {
+ "longName": "framework",
+ "shortName": "f"
+ }
+ }
+}
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/template.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/template.json
new file mode 100644
index 0000000..3703640
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/.template.config/template.json
@@ -0,0 +1,54 @@
+{
+ "$schema": "http://json.schemastore.org/template",
+ "author": "Maxim Novak",
+ "classifications": [ "Web", "WebAPI", "Google Maps" ],
+ "identity": "GoogleMapsApi.WebApi.CSharp",
+ "name": "Google Maps Web API",
+ "description": "A minimal ASP.NET Core Web API wired to GoogleMapsApi with /geocode and /directions endpoints.",
+ "shortName": "googlemaps-webapi",
+ "defaultName": "GoogleMapsWebApi",
+ "sourceName": "GoogleMapsWebApi",
+ "tags": {
+ "language": "C#",
+ "type": "project"
+ },
+ "preferNameDirectory": true,
+ "sources": [
+ {
+ "rename": {
+ "gitignore": ".gitignore"
+ }
+ }
+ ],
+ "symbols": {
+ "apikey": {
+ "type": "parameter",
+ "datatype": "string",
+ "defaultValue": "",
+ "replaces": "GOOGLE_API_KEY_PLACEHOLDER",
+ "description": "Google Maps API key written into appsettings.Development.json (kept out of source control)."
+ },
+ "Framework": {
+ "type": "parameter",
+ "datatype": "choice",
+ "choices": [
+ { "choice": "net10.0", "description": ".NET 10.0" },
+ { "choice": "net8.0", "description": ".NET 8.0" }
+ ],
+ "defaultValue": "net10.0",
+ "replaces": "FRAMEWORK_TOKEN",
+ "description": "The target framework for the generated project."
+ },
+ "GoogleMapsApiVersion": {
+ "type": "generated",
+ "generator": "constant",
+ "parameters": {
+ "value": "PACKAGE_VERSION_TOKEN"
+ },
+ "replaces": "GOOGLE_MAPS_API_VERSION"
+ }
+ },
+ "primaryOutputs": [
+ { "path": "GoogleMapsWebApi.csproj" }
+ ]
+}
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/GoogleMapsWebApi.csproj b/GoogleMapsApi.Templates/templates/googlemaps-webapi/GoogleMapsWebApi.csproj
new file mode 100644
index 0000000..e5ddf78
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/GoogleMapsWebApi.csproj
@@ -0,0 +1,14 @@
+
+
+
+ FRAMEWORK_TOKEN
+ latest
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/Program.cs b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Program.cs
new file mode 100644
index 0000000..5e3407f
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Program.cs
@@ -0,0 +1,94 @@
+using GoogleMapsApi;
+using GoogleMapsApi.Entities.Directions.Request;
+using GoogleMapsApi.Entities.Geocoding.Request;
+using GoogleMapsApi.Entities.Geocoding.Response;
+
+var builder = WebApplication.CreateBuilder(args);
+
+string? apiKey = builder.Configuration["GoogleApiKey"]
+ ?? Environment.GetEnvironmentVariable("GOOGLE_API_KEY");
+
+// Register IGoogleMapsClient (IHttpClientFactory-backed) and the ambient API key in one call.
+builder.Services.AddGoogleMaps(options => options.ApiKey = apiKey);
+
+var app = builder.Build();
+
+app.MapGet("/geocode", async (string address, IGoogleMapsClient maps) =>
+{
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ return Results.Problem(
+ "Set your key in appsettings.Development.json (GoogleApiKey) or the GOOGLE_API_KEY env var.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ if (string.IsNullOrWhiteSpace(address))
+ {
+ return Results.BadRequest(new { error = "address is required" });
+ }
+
+ // The ambient ApiKey from the options above is auto-filled into the request.
+ var response = await maps.Geocode.QueryAsync(new GeocodingRequest { Address = address });
+
+ if (response.Status != Status.OK || response.Results is null)
+ {
+ return Results.Problem(
+ $"Geocoding failed: {response.Status}",
+ statusCode: StatusCodes.Status502BadGateway);
+ }
+
+ return Results.Ok(response.Results.Select(result => new
+ {
+ formattedAddress = result.FormattedAddress,
+ latitude = result.Geometry.Location.Latitude,
+ longitude = result.Geometry.Location.Longitude,
+ placeId = result.PlaceId,
+ types = result.Types,
+ }));
+});
+
+app.MapGet("/directions", async (string origin, string destination, IGoogleMapsClient maps) =>
+{
+ if (string.IsNullOrWhiteSpace(apiKey))
+ {
+ return Results.Problem(
+ "Set your key in appsettings.Development.json (GoogleApiKey) or the GOOGLE_API_KEY env var.",
+ statusCode: StatusCodes.Status500InternalServerError);
+ }
+
+ if (string.IsNullOrWhiteSpace(origin) || string.IsNullOrWhiteSpace(destination))
+ {
+ return Results.BadRequest(new { error = "origin and destination are required" });
+ }
+
+ var request = new DirectionsRequest
+ {
+ Origin = origin,
+ Destination = destination,
+ };
+
+ var response = await maps.Directions.QueryAsync(request);
+
+ if (response.Status != GoogleMapsApi.Entities.Directions.Response.DirectionsStatusCodes.OK
+ || response.Routes is null)
+ {
+ return Results.Problem(
+ $"Directions failed: {response.Status} {response.ErrorMessage}",
+ statusCode: StatusCodes.Status502BadGateway);
+ }
+
+ var route = response.Routes.First();
+ var leg = route.Legs?.FirstOrDefault();
+
+ return Results.Ok(new
+ {
+ summary = route.Summary,
+ startAddress = leg?.StartAddress,
+ endAddress = leg?.EndAddress,
+ distance = leg?.Distance?.Text,
+ duration = leg?.Duration?.Text,
+ copyrights = route.Copyrights,
+ });
+});
+
+app.Run();
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/Properties/launchSettings.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Properties/launchSettings.json
new file mode 100644
index 0000000..543069b
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5080",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.Development.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.Development.json
new file mode 100644
index 0000000..2d2aca8
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.Development.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "GoogleApiKey": "GOOGLE_API_KEY_PLACEHOLDER"
+}
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.json b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.json
new file mode 100644
index 0000000..10f68b8
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/appsettings.json
@@ -0,0 +1,9 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/GoogleMapsApi.Templates/templates/googlemaps-webapi/gitignore b/GoogleMapsApi.Templates/templates/googlemaps-webapi/gitignore
new file mode 100644
index 0000000..6bc2463
--- /dev/null
+++ b/GoogleMapsApi.Templates/templates/googlemaps-webapi/gitignore
@@ -0,0 +1,5 @@
+bin/
+obj/
+
+# Keep your API key out of source control.
+appsettings.Development.json
diff --git a/GoogleMapsApi.sln b/GoogleMapsApi.sln
index 9a7ef2c..7dba16f 100644
--- a/GoogleMapsApi.sln
+++ b/GoogleMapsApi.sln
@@ -35,6 +35,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleMapsApi.Benchmarks", "benchmarks\GoogleMapsApi.Benchmarks\GoogleMapsApi.Benchmarks.csproj", "{6429978B-C1E9-415C-B289-DFBF9451393D}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GoogleMapsApi.Templates", "GoogleMapsApi.Templates\GoogleMapsApi.Templates.csproj", "{F095D702-59A0-4E5D-9E04-B90F1FF1D708}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -190,6 +192,22 @@ Global
{6429978B-C1E9-415C-B289-DFBF9451393D}.Release|x86.Build.0 = Release|Any CPU
{6429978B-C1E9-415C-B289-DFBF9451393D}.Release|x64.ActiveCfg = Release|Any CPU
{6429978B-C1E9-415C-B289-DFBF9451393D}.Release|x64.Build.0 = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x86.Build.0 = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Debug|x64.Build.0 = Debug|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|Mixed Platforms.Build.0 = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x86.ActiveCfg = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x86.Build.0 = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x64.ActiveCfg = Release|Any CPU
+ {F095D702-59A0-4E5D-9E04-B90F1FF1D708}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/README.md b/README.md
index d25a2c1..151dcb3 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,18 @@ dotnet add package GoogleMapsApi
Looking for runnable examples? See [`samples/`](samples/) — console, ASP.NET Core minimal API, and Blazor Server.
+## Scaffold a project in 10 seconds
+
+Spin up a working ASP.NET Core Web API (with `/geocode` and `/directions` endpoints) using the `dotnet new` template:
+
+```
+dotnet new install GoogleMapsApi.Templates
+dotnet new googlemaps-webapi -o MyMapsApi --apikey YOUR_API_KEY
+cd MyMapsApi && dotnet run
+```
+
+The key is written to `appsettings.Development.json` (gitignored), and the generated project references the matching `GoogleMapsApi` version. Pass `-f net8.0` to target .NET 8.
+
# Quickstart
## API Key Configuration