Your First ASP.NET Core Web API Project: .NET SDK, global.json and Project Structure
SDK versus runtime, multiple SDKs and global.json, how the dotnet new webapi template changed across versions, the csproj and TargetFramework, launchSettings.json and why the first request returns 307.
Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.
Guess First
By the end of this post you will have a running API, and the terminal will tell you that the application is listening on both https://localhost:7278 and http://localhost:5214. You send your first request to the HTTP address: curl -i http://localhost:5214/weatherforecast. Do you get the weather JSON, or something else? The answer is in the section where we send that first request.
SDK and Runtime: Two Different Things
When you install .NET you meet two pieces. The runtime is the engine that runs a compiled application; on a server, that is all you need. The SDK is the toolbox you install on a developer machine: the compiler, dotnet new, dotnet build, dotnet run and the project templates live in it. When you install an SDK, a matching runtime comes along.
If you are setting up a new machine today, install the .NET 10 SDK. .NET 10 is the current LTS release and it can also build projects that target .NET 6. That is exactly how I verify every example in this block: I build the same code against both net6.0 and net10.0. If you have a project that runs on .NET 6, its last SDK is 6.0.428. This is how you see which SDKs are on your machine:
$ dotnet --list-sdks
8.0.419 [/usr/local/share/dotnet/sdk]
10.0.103 [/usr/local/share/dotnet/sdk]
10.0.402 [/usr/local/share/dotnet/sdk]
On my machine three SDKs sit side by side without overwriting each other. The same goes for runtimes; dotnet --list-runtimes lists them.
Multiple SDKs and global.json
With several SDKs installed, the dotnet command picks the newest one by default. Most of the time that is what you want, but if you want everyone on the team and the CI server to build with the same SDK family, you put a global.json in the project folder:
$ dotnet new globaljson --sdk-version 10.0.100 --roll-forward latestFeature
The template "global.json file" was created successfully.
$ cat global.json
{
"sdk": {
"rollForward": "latestFeature",
"version": "10.0.100"
}
}
$ dotnet --version
10.0.402
version sets the lower bound and rollForward says how far up you may go. latestFeature means "use the newest installed SDK as long as it stays in the 10.0 family"; that is why 10.0.402 was selected even though we asked for 10.0.100. The 8.0 SDK is also on the machine, but it did not drop down to it.
And what if you ask for an SDK that is not installed? In the same folder I pinned global.json to 6.0.428 and tried again:
$ dotnet --version
The command could not be loaded, possibly because:
* You intended to execute a .NET application:
The application '--version' does not exist or is not a managed .dll or .exe.
* You intended to execute a .NET SDK command:
A compatible .NET SDK was not found.
Requested SDK version: 6.0.428
The error is long, but the message is clear: the requested SDK is missing. If even the simplest command blows up like this right after you clone a repository, global.json is the first place to look.
dotnet new webapi
Project templates ship with the SDK, so the output of dotnet new webapi depends on which SDK you use. Run this command with the .NET 6 SDK and you get a project with controllers and Swagger:
dotnet new webapi -o ProductCatalog.Api
The .NET 6 and 7 templates come with controllers and Swagger UI provided by Swashbuckle. Starting with the .NET 8 SDK the template produces a minimal API by default, and from .NET 9 on, built-in OpenAPI support replaces Swashbuckle; you ask for a controller project with --use-controllers. The details of these changes are in the version posts.
With the .NET 10 SDK, this gets you the closest project to this series' starting skeleton:
dotnet new webapi --use-controllers --no-openapi -o ProductCatalog.Api
Apart from the Swagger lines, this project is the same as the .NET 6 template. We will cover Swagger in a separate post. If you want to see the exact .NET 6 template, you can also install its template package: dotnet new install Microsoft.DotNet.Web.ProjectTemplates.6.0, then dotnet new webapi -f net6.0 -o ProductCatalog.Api. That is how I generated the files in this post.
A Look at the Project Structure
The generated folder looks like this:
ProductCatalog.Api/
Controllers/
WeatherForecastController.cs
Properties/
launchSettings.json
appsettings.json
appsettings.Development.json
Program.cs
ProductCatalog.Api.csproj
WeatherForecast.cs
Program.cs is the application's entry point: it registers services, builds the pipeline and starts the server. We will read it line by line in the next post. appsettings.json and the environment-specific appsettings.Development.json are configuration files. WeatherForecast.cs and the controller are the sample the template adds to show you "where to start". Let's take a quick look at the controller:
using Microsoft.AspNetCore.Mvc;
namespace ProductCatalog.Api.Controllers;
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet(Name = "GetWeatherForecast")]
public IEnumerable<WeatherForecast> Get()
{
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = DateTime.Now.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = Summaries[Random.Shared.Next(Summaries.Length)]
})
.ToArray();
}
}
There is a single using at the top of the file, yet ILogger, IEnumerable and Enumerable work fine. Implicit usings, which we will see in the csproj in a moment, make that happen. Writing namespace ProductCatalog.Api.Controllers; without braces is the file-scoped namespace that came with C# 10. Thanks to [Route("[controller]")], this controller is reached at /weatherforecast. We will look at each attribute in detail in the routing and controller posts.
The csproj and TargetFramework
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
</Project>
A short file, but every line does a job. Microsoft.NET.Sdk.Web makes the project a web project and references the ASP.NET Core shared framework automatically. You do not need separate packages for Kestrel, routing, MVC or logging. TargetFramework says which .NET version the application runs on. Nullable turns on compiler warnings for references that may be null. ImplicitUsings adds frequently used namespaces such as System, System.Linq and Microsoft.AspNetCore.Builder to every file as invisible global using directives. The only package is Swashbuckle; the last .NET 6 template brings it in at version 6.5.0.
A project can also build for more than one target at once. This is the project file I use to verify the examples in this block:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net6.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
The property name became plural: TargetFrameworks. dotnet build now produces two separate outputs: bin/Debug/net6.0 and bin/Debug/net10.0. For the net6.0 target, the .NET 10 SDK prints the NETSDK1138 warning on every build: it reminds you that .NET 6 is out of support and will not receive security updates. The warning does not stop the build.
launchSettings.json
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:49146",
"sslPort": 44310
}
},
"profiles": {
"ProductCatalog.Api": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "https://localhost:7278;http://localhost:5214",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
This file is for local development only; dotnet run, Visual Studio and Rider read it. The template picks the port numbers at random, so yours will differ. The line that really matters is ASPNETCORE_ENVIRONMENT: it tells the application that it is running in the Development environment. Swagger UI is also enabled in Program.cs only for Development. If you bypass this file with dotnet run --no-launch-profile, the application starts in the Production environment at http://localhost:5000, and /swagger/index.html returns 404. An application that is published and deployed to a server never sees this file at all.
dotnet run and the First Request
Run dotnet run in the project folder. The application's own logs look like this:
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:7278
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5214
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
Now back to your guess; let's send a request to the HTTP address:
$ curl -i http://localhost:5214/weatherforecast
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Tue, 03 Nov 2026 21:12:32 GMT
Server: Kestrel
Location: https://localhost:7278/weatherforecast
Not JSON, but 307 Temporary Redirect. The reason is the app.UseHttpsRedirection() line in Program.cs: it redirects every request that arrives over HTTP to the HTTPS version of the same address. A browser follows this redirect on its own; curl does not unless you pass -L. Let's go straight to the HTTPS address:
$ curl -k https://localhost:7278/weatherforecast
[{"date":"2026-11-05T00:12:48.604094+03:00","temperatureC":42,"temperatureF":107,"summary":"Warm"},{"date":"2026-11-06T00:12:48.604176+03:00","temperatureC":45,"temperatureF":112,"summary":"Sweltering"},{"date":"2026-11-07T00:12:48.604176+03:00","temperatureC":40,"temperatureF":103,"summary":"Bracing"},{"date":"2026-11-08T00:12:48.604176+03:00","temperatureC":-5,"temperatureF":24,"summary":"Scorching"},{"date":"2026-11-09T00:12:48.604177+03:00","temperatureC":52,"temperatureF":125,"summary":"Cool"}]
-k tells curl to accept the development certificate without validating it. To trust the certificate permanently, run dotnet dev-certs https --trust once. If you open https://localhost:7278/swagger in the browser, you can try the same endpoint through Swagger UI as well.
Instead of stopping and starting the application every time you change code, use dotnet watch. It detects the change the moment you save the file; thanks to Hot Reload, which came with .NET 6, it applies most changes without restarting the application. For changes Hot Reload cannot apply, the application has to be restarted.
Common Pitfalls
Generating the .NET 10 template and lowering TargetFramework to net6.0. The new template's controller uses a collection expression, and since the default language for .NET 6 is C# 10, the build stops with this error: error CS8936: Feature 'collection expressions' is not available in C# 10.0. Please use language version 12.0 or greater. If you lower the target, you also have to roll back the C# features that belong to the newer version.
Forgetting that the repository has a global.json. If no command works in a project you just cloned, look at that file and at the output of dotnet --list-sdks first.
Relying on launchSettings.json for production settings. The environment name, ports and environment variables in this file only apply on your machine. On the server you have to provide them through real environment variables or configuration.
Being surprised by an empty response on the HTTP port. curl does not follow the 307; either use the HTTPS address or add -L.
A Small Experiment
★☆☆ Run dotnet --list-sdks and dotnet --list-runtimes on your machine. Then, in an empty folder, pin the oldest SDK you have with global.json and watch the output of dotnet --version change.
★★☆ Create the ProductCatalog.Api project, run it with dotnet run, and use curl -i -L http://localhost:PORT/weatherforecast to see the redirect being followed. Then run it with dotnet run --no-launch-profile and read from the logs which address and which environment it started with.
★★★ Raise the target in the csproj to two versions with TargetFrameworks and inspect the folders created under bin/Debug after dotnet build.
The project is up and running, but we have not yet talked about what is behind those few lines in Program.cs. In the next post we open up the hosting model that starts with WebApplication.CreateBuilder; we look at the split between the builder and the app, at environments, and at what exactly app.Run() does.