BrandGhost
How to Multi-Target a NuGet Package for .NET 6, .NET 8, and .NET Standard

How to Multi-Target a NuGet Package for .NET 6, .NET 8, and .NET Standard

07/08/2026

How to Multi-Target a NuGet Package for .NET 6, .NET 8, and .NET Standard

Publishing a multi-target NuGet package is one of the most impactful decisions you can make as a .NET library author. It lets consumers running .NET 6, .NET 8, or even older .NET Framework apps all install your library from a single .nupkg file -- and each runtime gets a binary compiled specifically for its target framework. No compatibility hacks. No forced upgrades. Just the right binary for the right platform.

This guide walks through everything you need to set up target framework NuGet support correctly: the correct property name, TFM monikers, conditional compilation with #if directives, per-TFM package references, and how to verify the generated output.

If you're just getting started with library packaging, check out The Complete Guide to Creating NuGet Packages in .NET first. This article builds on that foundation -- focusing entirely on multi-targeting.

TargetFrameworks vs TargetFramework: One Letter, Big Difference

This is the most common source of confusion when you first try to multi-target a NuGet package. Your .csproj file has two related but distinct properties:

  • <TargetFramework> (singular) -- targets a single framework
  • <TargetFrameworks> (plural) -- targets multiple frameworks simultaneously

This is not a typo and it is not interchangeable. The .NET SDK checks specifically for the plural form when deciding whether to build for multiple TFMs. If you accidentally write <TargetFramework>net6.0;net8.0</TargetFramework> (singular with semicolons), the build fails immediately with an unrecognized framework identifier error.

When you use <TargetFrameworks> (plural), the SDK builds your library once per listed TFM. Each compiled output lands in its own subdirectory. When those outputs are assembled into the .nupkg, NuGet reads the subdirectory names at install time and picks the best match for the consumer's project. That's the whole mechanism -- and it hinges entirely on using the plural property name.

Single-target is fine for application projects. For any library you intend to distribute as a NuGet package, plural is almost always what you want.

Common TFM Monikers for Modern .NET

When you set up a multi-target NuGet package, the first thing you need is the correct TFM string for each platform you want to support. The Target Framework Moniker (TFM) is the short identifier string that represents a specific framework version. Here are the most relevant ones for modern .NET library authors:

TFM Description
net6.0 .NET 6 (LTS, supported until Nov 2024)
net7.0 .NET 7 (STS, 18-month support window)
net8.0 .NET 8 (LTS, supported until Nov 2026)
net9.0 .NET 9 (STS, 18-month support window)
net10.0 .NET 10 (LTS, releasing Nov 2025)
netstandard2.0 .NET Standard 2.0 -- runs on .NET 4.6.1+, .NET Core 2.0+, Xamarin
netstandard2.1 .NET Standard 2.1 -- .NET Core 3.0+, NOT .NET Framework
net48 .NET Framework 4.8

LTS (Long-Term Support) means three years of official support. STS (Standard-Term Support) means 18 months. For most libraries, focusing on LTS versions plus .NET Standard 2.0 gives you broad reach with minimal maintenance overhead.

One critical nuance: netstandard2.1 does not support .NET Framework. If you need .NET Framework consumers to install your package, netstandard2.0 is the correct choice -- not netstandard2.1.

Setting Up Multi-Targeting in Your .csproj

Configuring a multi-target NuGet package starts with a single property change in your .csproj. Here's what it looks like for a library supporting .NET 6, .NET 8, and .NET Standard 2.0:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFrameworks>net6.0;net8.0;netstandard2.0</TargetFrameworks>
    <Nullable>enable</Nullable>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>

    <!-- Package metadata -->
    <PackageId>MyAwesomeLibrary</PackageId>
    <Version>1.0.0</Version>
    <Authors>Your Name</Authors>
    <Description>A library that targets multiple .NET frameworks.</Description>
  </PropertyGroup>

</Project>

The critical line is <TargetFrameworks>net6.0;net8.0;netstandard2.0</TargetFrameworks>. Semicolons separate each TFM. No plugins, no custom MSBuild targets -- the SDK handles everything else.

When you run dotnet build, the SDK builds the project three times in sequence, once per TFM. Each build output lands under bin/Debug/net6.0/, bin/Debug/net8.0/, and bin/Debug/netstandard2.0/. Running dotnet pack then assembles these into the correct folder structure inside the .nupkg.

You can speed up the inner loop during development by targeting a single TFM:

dotnet build -f net8.0
dotnet test -f net8.0

The -f (or --framework) flag restricts the build to one TFM. This is handy when you're iterating on a feature and don't need to rebuild all targets every time.

Conditional Compilation with #if Directives

Multi-targeting becomes genuinely powerful when you need different implementations per framework. The .NET SDK automatically defines preprocessor symbols based on which TFM is currently being compiled. The naming convention follows a [TFM]_OR_GREATER pattern for version guards.

Common symbols you'll use when writing conditionally compiled code:

  • NET6_0_OR_GREATER -- true for .NET 6, 7, 8, 9, 10+
  • NET8_0_OR_GREATER -- true for .NET 8, 9, 10+
  • NETSTANDARD2_0 -- true only for .NET Standard 2.0 builds
  • NETSTANDARD2_1 -- true only for .NET Standard 2.1 builds
  • NETFRAMEWORK -- true for any .NET Framework target

Here's a practical example. Suppose your library wraps HttpClient behavior. .NET 5+ includes HttpClient.Send(HttpRequestMessage) (introduced in .NET 5) as a synchronous API that didn't exist in .NET Standard 2.0. You can conditionally expose it:

using System.Net.Http;

public sealed class MyHttpWrapper
{
    private readonly HttpClient _client;

    public MyHttpWrapper(HttpClient client)
    {
        _client = client;
    }

    // Available on all target frameworks
    public async Task<string> GetStringAsync(string url)
    {
        var response = await _client.GetAsync(url);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }

#if NET6_0_OR_GREATER
    // Only compiled for .NET 6+ targets in our matrix -- HttpClient.Send was introduced in .NET 5,
    // but we guard on NET6_0_OR_GREATER because our matrix starts at net6.0.
    // If your matrix includes net5.0, use NET5_0_OR_GREATER instead.
    public string GetString(string url)
    {
        var request = new HttpRequestMessage(HttpMethod.Get, url);
        var response = _client.Send(request);
        response.EnsureSuccessStatusCode();
        return response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
    }
#endif
}

The #if NET6_0_OR_GREATER block is included for every target in the matrix that is .NET 6 or newer -- in our case, both net6.0 and net8.0. The netstandard2.0 build skips it entirely. Your .NET Standard consumers see a clean API surface with no accidental references to APIs that don't exist on their platform.

You can also chain symbols to handle version-specific branches:

#if NET8_0_OR_GREATER
    // FrozenSet<T> was introduced in .NET 8 -- use it for zero-allocation lookups
    var lookup = items.ToFrozenSet();
#elif NET6_0_OR_GREATER
    // .NET 6 fallback -- HashSet is fine
    var lookup = new HashSet<string>(items);
#else
    // .NET Standard 2.0 fallback
    var lookup = new HashSet<string>(items);
#endif

Keep conditional blocks small and focused. Large regions of #if code become difficult to read, test, and maintain. If a particular TFM requires fundamentally different logic, consider a partial class or an adapter layer instead of a wall of preprocessor directives.

Per-TFM Package References

Sometimes your library depends on a NuGet package that is only relevant for certain targets. A compatibility shim might only be needed for older runtimes. A high-performance library might only compile against .NET 8+. You can scope any <PackageReference> to a specific TFM using the Condition attribute:

<Project Sdk="Microsoft.NET.Sdk">

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

  <ItemGroup>
    <!-- Available to all targets -->
    <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
  </ItemGroup>

  <ItemGroup>
    <!-- System.Memory provides Span<T> compatibility shims for .NET Standard 2.0 -->
    <PackageReference Include="System.Memory" Version="4.5.5"
      Condition="'$(TargetFramework)' == 'netstandard2.0'" />
  </ItemGroup>

  <ItemGroup>
    <!-- Microsoft.Extensions.ObjectPool 8.x targets .NET 8 APIs -->
    <PackageReference Include="Microsoft.Extensions.ObjectPool" Version="8.0.0"
      Condition="'$(TargetFramework)' == 'net8.0'" />
  </ItemGroup>

</Project>

The Condition attribute uses MSBuild property syntax. '$(TargetFramework)' evaluates to the TFM currently being built during each pass. The System.Memory reference is pulled in only when building netstandard2.0. .NET 8 consumers never see it in their transitive dependency graph.

This matters for consumers. Every unconditional dependency you add shows up for every consumer regardless of their runtime. Scoping dependencies tightly keeps the package lightweight and avoids version conflicts in downstream projects.

Choosing Your Target Framework Matrix

One of the most important decisions when building a multi-target NuGet package is choosing which TFMs to actually include. Not every library needs to target every TFM. More targets mean more build time and more ongoing maintenance. Here's a decision framework for building a practical support matrix:

Always include .NET Standard 2.0 if you want the widest possible reach. It runs on .NET Framework 4.6.1 and later, .NET Core 2.0 and later, and Xamarin. A substantial portion of the existing .NET ecosystem can install a netstandard2.0 package.

Include modern .NET LTS versions (net6.0, net8.0) when your library benefits from newer APIs, performance improvements (like Span<T> without compatibility packages), or platform-specific features. Consumers on .NET 8 get a binary built specifically for them -- no polyfill overhead.

Skip intermediate STS versions (net7.0, net9.0) unless you have specific API requirements tied to those exact versions. The _OR_GREATER preprocessor convention means a consumer running .NET 7 will simply use your net6.0 assembly -- it runs fine. You're not leaving them stranded.

A solid starting matrix for new libraries looks like this:

<TargetFrameworks>net8.0;net6.0;netstandard2.0</TargetFrameworks>

This covers .NET 8 consumers with the latest APIs, .NET 6 consumers on extended deployments, and everything else via .NET Standard 2.0. Note that .NET 6 LTS ended in November 2024 -- if you're starting a new library in mid-2026, replacing net6.0 with net10.0 (released November 2025) is the more forward-looking choice. Keeping the matrix lean directly reduces your CI build time and the surface area of issues you'll need to investigate.

.NET Standard 2.0: The Compatibility Baseline

.NET Standard 2.0 deserves its own focus because it's the single widest compatibility layer available in the .NET ecosystem. It defines a common API contract that any conforming runtime must implement. That contract is what makes a single netstandard2.0 binary runnable on:

  • .NET Framework 4.6.1, 4.7.x, 4.8.x
  • .NET Core 2.0, 2.1, 3.0, 3.1
  • .NET 5, 6, 7, 8, 9, 10
  • Xamarin iOS, Android, macOS
  • Unity (with some restrictions)

The tradeoff is a smaller API surface. You lose access to Span<T> without compatibility packages, IAsyncEnumerable<T> (available on netstandard2.0 via the Microsoft.Bcl.AsyncInterfaces NuGet package rather than being in the BCL), System.Text.Json (in its high-performance form), and many newer BCL types. These are real limitations when building performance-sensitive code.

.NET Standard 2.1 expands the API surface considerably -- but it permanently drops .NET Framework support. The .NET team has stated explicitly that .NET Framework will never implement .NET Standard 2.1. So if any .NET Framework support matters to your consumers, netstandard2.0 is the ceiling for your compatibility target.

If you're building an abstraction layer or utility library that other libraries will depend on, netstandard2.0 as a baseline ensures maximum composability. Following good abstraction design through the Dependency Inversion Principle naturally supports this -- libraries that depend on interfaces rather than concrete platform types fit comfortably within the .NET Standard 2.0 surface.

A Note on Source Generators and Multi-Targeting

Source generators have special multi-targeting requirements worth mentioning -- even though they deserve their own deep-dive. A source generator project must target netstandard2.0 specifically, because Roslyn (the compiler host) requires that. This is separate from the end-user TFMs your main library binary supports. Your generator and your library can have different <TargetFrameworks> values, and that's expected.

DevLeader has dedicated content exploring this space. If you're evaluating whether source generators are the right tool for your library, C# Reflection vs Source Generators in .NET 10: Which Should You Choose? and Source Generation vs Reflection in Needlr: Choosing the Right Approach both explore the decision in depth.

Verifying Your Multi-Target NuGet Package Output

After running dotnet pack, confirm the generated .nupkg actually contains assemblies for each target. A multi-target NuGet package is only correct if every listed TFM has its own assembly in the lib/ folder -- missing any one of them causes "no compatible framework" install errors for consumers on that platform. A .nupkg is just a ZIP file. You can rename it to .zip and open it in Windows Explorer, or use a tool like NuGet Package Explorer. You can also inspect it directly from the command line.

Here's the expected lib/ folder structure for a package targeting net6.0;net8.0;netstandard2.0:

MyAwesomeLibrary.1.0.0.nupkg
└── lib/
    ├── net6.0/
    │   └── MyAwesomeLibrary.dll
    ├── net8.0/
    │   └── MyAwesomeLibrary.dll
    └── netstandard2.0/
        └── MyAwesomeLibrary.dll

To verify from the command line with PowerShell after running dotnet pack:

# Pack in Release configuration
dotnet pack -c Release

# Inspect the lib/ folder contents inside the .nupkg
$pkg = Get-Item ./bin/Release/*.nupkg | Select-Object -First 1
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [System.IO.Compression.ZipFile]::OpenRead($pkg.FullName)
$zip.Entries | Where-Object { $_.FullName -match '^lib/' } | Select-Object FullName
$zip.Dispose()

# Expected output:
# lib/net6.0/MyAwesomeLibrary.dll
# lib/net8.0/MyAwesomeLibrary.dll
# lib/netstandard2.0/MyAwesomeLibrary.dll

If a TFM is missing from the lib/ folder, NuGet consumers on that platform get a "no compatible framework" error at install time. Catch it before publishing -- this quick check takes seconds and saves you from pushing a broken package.

You can also sanity-check the build itself with a minimal verbosity build and count the success lines:

dotnet build -v minimal

With three TFMs configured, you should see three distinct build passes complete successfully. If any TFM fails to compile, the output will show the offending errors before the pack step.

Good SOLID design principles pay dividends here too -- libraries with clean interfaces and minimal platform coupling are much easier to keep compiling across multiple TFMs than libraries with tangled dependencies on platform-specific APIs.

Frequently Asked Questions

What is the difference between TargetFramework and TargetFrameworks in a .csproj?

<TargetFramework> (singular) builds a project for exactly one platform. <TargetFrameworks> (plural) builds for multiple platforms in a single command. Using the singular form with semicolons like net6.0;net8.0 causes an immediate build error -- the SDK does not interpret the semicolons as a list in the singular form. Always use the plural form when you want to multi-target a NuGet package.

Can I multi-target a NuGet package without using any conditional compilation?

Yes. If your library's public API surface is identical across all target frameworks and you're not using any TFM-specific APIs, you don't need any #if directives. List the frameworks in <TargetFrameworks> and the SDK builds each one with no extra code changes required. Conditional compilation only becomes necessary when your implementation needs to vary per TFM.

Does including netstandard2.0 mean .NET 8 consumers get a slower binary?

No -- if you also include net8.0 in your <TargetFrameworks>, NuGet picks the most specific compatible match for each consumer's project. A .NET 8 project receives the net8.0 binary. The netstandard2.0 binary is only used by runtimes that don't match any more specific TFM listed in the package. Multi-targeting is always additive -- you're not penalizing modern consumers by also supporting older ones.

Which preprocessor symbols are automatically defined for conditional compilation?

The SDK defines them automatically based on the TFM being compiled. The most useful are: NET6_0_OR_GREATER, NET7_0_OR_GREATER, NET8_0_OR_GREATER, NET9_0_OR_GREATER, NET10_0_OR_GREATER, NETSTANDARD2_0, NETSTANDARD2_1, and NETFRAMEWORK. The _OR_GREATER variants evaluate to true for that version and all newer versions, which makes them the standard choice for version guards rather than exact version checks.

Should I include .NET Standard 2.1 in my target framework matrix?

Usually not. If you're already targeting netstandard2.0 for .NET Framework support and modern .NET TFMs for performance, netstandard2.1 sits in an awkward middle ground. It doesn't help .NET Framework consumers (it's not supported there), and .NET 5+ consumers already have better matches from your modern targets. Include it only if you have specific consumers stuck on .NET Core 3.x or .NET 5 who need APIs beyond the .NET Standard 2.0 surface.

How do per-TFM package references work with the Condition attribute?

The Condition attribute on <PackageReference> uses MSBuild evaluation syntax. Set it to '$(TargetFramework)' == 'netstandard2.0' to include a dependency only when building that target. You can also use comparisons against net8.0 or any other TFM moniker. The MSBuild engine evaluates the condition during each of the per-TFM build passes, including or excluding the reference accordingly. This keeps transitive dependency graphs clean for consumers on platforms that don't need the conditional package.

What is the minimum TFM matrix I should support for a new library in 2026?

A practical minimum for 2026 is net8.0;net10.0;netstandard2.0, covering both current LTS releases and everything else (including .NET Framework) through .NET Standard 2.0. Adding net6.0 is no longer recommended -- its LTS support ended November 2024. As LTS windows close, you can drop older TFMs in a future major version of your library.

Wrapping Up

Multi-targeting a NuGet package comes down to a handful of deliberate decisions: which TFMs to list in <TargetFrameworks>, where #if directives are needed for TFM-specific code paths, how to scope per-TFM package references with the Condition attribute, and how to verify the generated .nupkg lib/ structure before publishing.

Start with a matrix that covers .NET Standard 2.0 for broad compatibility and at least one current LTS .NET version for modern API access. Use NET6_0_OR_GREATER, NET8_0_OR_GREATER, and related symbols to branch where the platform API surface differs. Scope package references with Condition to keep consumer dependency graphs lean. Then verify the pack output -- confirm all expected lib/ subdirectories are present before pushing to NuGet.org.

If your library uses source generators, the multi-targeting story gets a bit more involved -- generator projects always target netstandard2.0 separately from the main library's TFMs. C# Reflection vs Source Generators in .NET 10: Which Should You Choose? is the right starting point for that topic.

For the complete picture of building and publishing NuGet packages end to end, revisit The Complete Guide to Creating NuGet Packages in .NET. Multi-targeting is one spoke in that larger story -- and now you have everything you need to implement it correctly.

How to Create a NuGet Package in C# with dotnet pack

Step-by-step guide to creating a NuGet package in C# using dotnet pack. Learn how to configure your .csproj, set package properties, and generate a .nupkg file.

How to Publish a NuGet Package to NuGet.org

Learn how to publish NuGet package files to NuGet.org using dotnet nuget push. Covers API keys, nuget.config setup, the validation queue, and unlisting.

The Complete Guide to Creating NuGet Packages in .NET

Learn how to create, version, and publish NuGet packages in .NET. This complete guide covers dotnet pack, metadata, versioning, publishing, CI/CD, and more.

An error has occurred. This application may no longer respond until reloaded. Reload