Updated 23 April 2026

If you maintain a .NET library, you’ve faced this question: which target framework should I use? Target only the latest .NET and you cut off consumers still on older runtimes. Target only .NET Standard and you miss out on modern APIs and performance improvements. The answer, almost always, is multi-targeting.

Multi-targeting lets you build a single project that produces assemblies for multiple target framework monikers (TFMs), all packaged into one NuGet package. NuGet automatically selects the best assembly for each consumer. Your modern .NET consumers get the latest APIs. Your .NET Framework consumers get a compatible fallback. Everyone wins.

In this guide, I’ll walk through how multi-targeting works, which TFMs to target, how to write platform-specific code, and the common mistakes I’ve come across.

What has changed since .NET Standard?

If you’ve been multi-targeting since the .NET Standard era, the landscape has shifted significantly. When I first wrote about this topic back in 2019, tools like MSBuild.Sdk.Extras were essential.

The modern .NET SDK (since .NET 6) natively supports platform-specific TFMs like net10.0-android, net10.0-ios, net10.0-windows, and net10.0-maccatalyst. The platforms that once required MSBuild.Sdk.Extras (e.g. Silverlight, Windows Phone, PCL profiles, legacy Xamarin) are all end-of-life.

Setting up multi-targeting in your project

Multi-targeting starts with one small change to your .csproj by swapping TargetFramework (singular) for TargetFrameworks (plural):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <!-- Build for modern .NET and .NET Standard for broad reach -->
    <TargetFrameworks>net10.0;net8.0;netstandard2.0</TargetFrameworks>
  </PropertyGroup>
</Project>

That’s it. When you build, the SDK compiles your project once for each TFM. When you pack (dotnet pack), all outputs go into a single .nupkg with the correct lib/{tfm}/ folder structure. NuGet handles the rest and consumers get the best match for their project automatically.

When you need platform-specific TFMs

If your library needs to call platform-specific APIs, add platform-specific TFMs alongside your base targets:

<TargetFrameworks>net10.0;net10.0-android;net10.0-ios;net10.0-windows10.0.19041.0;netstandard2.0</TargetFrameworks>

You can also conditionally include dependencies or resources for specific targets:

<ItemGroup Condition="'$(TargetFramework)' == 'net10.0-android'">
  <AndroidResource Include="Resources\**" />
</ItemGroup>

<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
  <PackageReference Include="System.Text.Json" Version="9.0.4" />
</ItemGroup>

Conditional compilation with preprocessor symbols

Multi-targeting isn’t just about packaging, it also lets you write different code for different targets using preprocessor directives. The .NET SDK automatically defines symbols for each TFM you’re building against. Below are a few examples, but for a complete list of preprocessor symbols for .NET, take a look at the official Microsoft Learn docs!

The _OR_GREATER pattern

The most useful symbols are the _OR_GREATER variants introduced in .NET 5+. These let you write forward-compatible checks that don’t need updating every time a new .NET version ships:

public class NetworkHelper
{
    public async Task<string> FetchAsync(string url)
    {
#if NET8_0_OR_GREATER
        // Modern .NET — use the latest HttpClient features
        using var client = new HttpClient();
        return await client.GetStringAsync(url);
#else
        throw new PlatformNotSupportedException();
#endif
    }
}

Platform-specific symbols

When targeting OS-specific TFMs, you get platform symbols too:

public static class DeviceInfo
{
    public static string GetPlatformName()
    {
#if ANDROID
        return "Android";
#elif IOS
        return "iOS";
#elif WINDOWS
        return "Windows";
#else
        return "Unknown";
#endif
    }
}

When you still need .NET Standard

.NET Standard 2.0 isn’t dead, it’s the lowest common denominator that bridges modern .NET and .NET Framework. If your library has consumers on .NET Framework 4.6.1+, you need netstandard2.0.

Here’s how to decide:

  • New library, all consumers on modern .NET? → Target net10.0 (or net8.0) only. No need for .NET Standard.
  • Need .NET Framework support? → Multi-target net10.0;netstandard2.0. The .NET Standard assembly serves as the fallback.
  • Maximum reach across all .NET ecosystems? → Multi-target net10.0;net8.0;netstandard2.0.

The official guidance from Microsoft is clear:

✔️ DO start with including a net8.0 target or later for new libraries.

✔️ CONSIDER including a netstandard2.0 target if you need broad compatibility or .NET Framework support.

One important caveat: if your library or its dependencies multi-target, don’t publish only netstandard2.0. .NET Standard 2.0 is not a runtime, and multi-targeted projects provide runtime framework-specific libraries that are needed when running on specific frameworks.

Packaging your multi-targeted library

When you’re ready to ship, dotnet pack handles everything. The SDK automatically places each TFM’s output into the correct lib/{tfm}/ folder inside the .nupkg.

Essential package metadata

Set up your .csproj with proper NuGet metadata:

<PropertyGroup>
  <TargetFrameworks>net10.0;net8.0;netstandard2.0</TargetFrameworks>

  <!-- NuGet metadata -->
  <PackageId>YourLibrary</PackageId>
  <Version>2.0.0</Version>
  <Authors>Your Name</Authors>
  <Description>A cross-platform .NET library</Description>
  <PackageLicenseExpression>MIT</PackageLicenseExpression>
  <PackageProjectUrl>https://github.com/you/project</PackageProjectUrl>
  <RepositoryUrl>https://github.com/you/project</RepositoryUrl>
  <PackageReadmeFile>README.md</PackageReadmeFile>

  <!-- Source Link for debugger source stepping -->
  <PublishRepositoryUrl>true</PublishRepositoryUrl>
  <EmbedUntrackedSources>true</EmbedUntrackedSources>
  <IncludeSymbols>true</IncludeSymbols>
  <SymbolPackageFormat>snupkg</SymbolPackageFormat>

  <!-- Deterministic builds for reproducibility -->
  <ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.SourceLink.GitHub" Version="10.0.203" PrivateAssets="All" />
</ItemGroup>

<ItemGroup>
  <None Include="README.md" Pack="true" PackagePath="\" />
</ItemGroup>

A few things to highlight:

  • Source Link enables consumers to step into your library’s source code while debugging, a massive quality-of-life improvement that costs you nothing
  • Symbol packages (.snupkg) publish alongside your main package and integrate with NuGet.org’s symbol server
  • Deterministic builds ensure the same source produces the same binary, important for supply chain security

For the full CI/CD workflow of building and publishing your packages, check out my guides on publishing NuGet packages with GitHub Actions and Azure Pipelines.

Central Package Management

If your library is part of a multi-project solution, Central Package Management keeps dependency versions consistent across all projects:

<!-- Directory.Packages.props (solution root) -->
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
    <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.0" />
  </ItemGroup>
</Project>

Individual .csproj files then reference packages without specifying versions:

<ItemGroup>
  <PackageReference Include="Newtonsoft.Json" />
</ItemGroup>

This eliminates version drift across projects and makes dependency updates a single-file change.

Common mistakes when multi-targeting

Over the years of maintaining open-source .NET libraries, I’ve seen (and made) these mistakes repeatedly:

Targeting too many TFMs

More targets means more build time, more testing surface, and more maintenance. Only target what your consumers actually need of your package. Use the lowest common denominator.

Changing assembly names per TFM

This is explicitly called out in the official guidance: don’t change the assembly name for different TFMs. It breaks package consumers who depend on consistent assembly identity.

Ignoring trimming and AOT support

Modern .NET supports trimming and Native AOT compilation. If your library targets net8.0 or later, consider enabling trimming support:

<PropertyGroup>
  <TargetFrameworks>netstandard2.0;net8.0;net10.0</TargetFrameworks>
  <IsTrimmable Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">true</IsTrimmable>
</PropertyGroup>

This article was originally published in 2019 covering MSBuild.Sdk.Extras for enhanced multi-targeting. It has been rewritten to reflect the modern .NET SDK’s native multi-targeting capabilities, current LTS versions (.NET 10), and current best practices.


Discover more from James Croft

Subscribe to get the latest posts sent to your email.

James Croft

Author James Croft

James is a senior software engineer at Microsoft with over 10 years experience designing and building large-scale, distributed, cloud-native systems. He's deeply experienced in C#, Python, and TypeScript, with specialization in AI agent architectures, retrieval-augmented generation (RAG), and production-grade Azure systems.

More posts by James Croft

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.