---
title: "Migrate from HtmlAgilityPack to onehtml"
url: https://recursiveai.org/onehtml/articles/migrate-from-htmlagilitypack/
description: "Move C# code from HtmlAgilityPack to onehtml: LoadHtml, SelectNodes, InnerText, OuterHtml and Save mapped, with both sides run. No XPath; .NET 10 only."
date: 2026-10-26
author: onehtml team
---

# Migrate from HtmlAgilityPack to onehtml

Move C# code from HtmlAgilityPack to onehtml: LoadHtml, SelectNodes, InnerText, OuterHtml and Save mapped, with both sides run. No XPath; .NET 10 only.

October 26, 2026 · onehtml team

Migrating from HtmlAgilityPack to onehtml replaces `HtmlDocument.LoadHtml` with `Document.Parse`, XPath queries through `SelectNodes` with CSS selectors through `QuerySelectorAll`, `InnerText` plus `HtmlEntity.DeEntitize` with `TextContent`, and in-place changes followed by `Save` with a list of edits applied in one step. Two things don’t carry over. **onehtml has no XPath**, so every XPath expression becomes a CSS selector, LINQ or DOM navigation. **onehtml runs on .NET 10 only**, with no .NET Framework or .NET Standard build. If either is a blocker, stay on HtmlAgilityPack. Every pair below was run on both sides, HtmlAgilityPack 1.13.1 and onehtml 1.0.0 on .NET 10, against the same input.

## Before you migrate

- **No XPath.** There is no `SelectNodes` equivalent that takes XPath, and no XSLT. The [XPath section](#xpath) shows how common expressions translate.
- **.NET 10 only.** The package targets `net10.0`. HtmlAgilityPack 1.13.1 ships builds for .NET Framework 3.5 and later ([NuGet](https://www.nuget.org/packages/HtmlAgilityPack/1.13.1)).
- **No `HtmlWeb`.** onehtml never touches the network or the file system. You read the bytes and pass them in.
- **Documents are immutable.** You don’t change nodes in place. You collect edits and apply them, which returns a new document.
- **The tree is the browser’s.** Rows get a `tbody`, and misnested tags are repaired as the HTML standard says, so some queries written against HtmlAgilityPack’s tree need adjusting (see [HtmlAgilityPack vs AngleSharp](https://recursiveai.org/onehtml/articles/htmlagilitypack-vs-anglesharp/) for the measured differences).

## HtmlAgilityPack to onehtml: the API map

| HtmlAgilityPack | onehtml |
|---|---|
| `var doc = new HtmlDocument(); doc.LoadHtml(html);` | `Document doc = Document.Parse(html);` |
| `doc.Load(path)`, `DetectEncodingAndLoad(path)` | `Document.Parse(File.ReadAllBytes(path))`: the encoding is detected from the bytes |
| `new HtmlWeb().Load(url)` | `HttpClient`, then `Document.Parse(bytes, new ParseOptions { ContentType = … })` |
| `doc.DocumentNode` | `doc.Root` (the document node); `doc.DocumentElement` is `<html>` |
| `SelectNodes(xpath)`, `null` when nothing matches | `QuerySelectorAll(css)`, an empty `List<Node>` when nothing matches |
| `SelectSingleNode(xpath)`, `null` when nothing matches | `QuerySelector(css)`, the null node when nothing matches: check `IsNull` |
| `node.Descendants("a")` | `node.QuerySelectorAll("a")` |
| `ChildNodes`, `ParentNode`, `NextSibling` | `ChildNodes`, `Parent` or `ParentElement`, `NextSibling` or `NextElementSibling` |
| `node.Name` | `node.LocalName` |
| `GetAttributeValue("href", "")`, `Attributes["href"]?.Value` | `GetAttribute("href") ?? ""`: `null` when absent |
| `HtmlEntity.DeEntitize(node.InnerText)` | `node.TextContent`: already decoded |
| `InnerHtml`, `OuterHtml` | `doc.SerializeChildren(node)`, `doc.SerializeNode(node)` |
| `SetAttributeValue(name, value)` | `Edit.SetAttribute(node, name, value)` |
| `node.Remove()` | `Edit.Remove(node)` |
| `AppendChild(HtmlNode.CreateNode(html))` | `Edit.Append(node, html)` |
| `doc.Save(path)` | `File.WriteAllBytes(path, result.Document!.ToLosslessBytes())` |

Install onehtml with `dotnet add package RecursiveAI.OneHtml --version 1.0.0`. The samples read this page, `catalog.html`:

*catalog.html · HTML*

```
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Kitchen &amp; home</title>
</head>
<body>
  <nav class=breadcrumbs><a href="/">Home</a> &rsaquo; <a href="/kitchen/">Kitchen</a></nav>
  <div class="products">
    <div class="product" data-sku="K-100">
      <h2><a href="/p/kettle">Kettle</a></h2>
      <span class="price">$39.00</span>
    </div>
    <div class="product sale" data-sku="T-200">
      <h2><a href="/p/toaster">Toaster</a></h2>
      <span class="price">$24.50</span>
    </div>
    <div class="product" data-sku="M-300">
      <h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
      <span class="price">$189.00</span>
    </div>
  </div>
  <h3>Specs</h3>
  <table class="specs"><tr><th>Power</th><td>2200 W</td></tr><tr><th>Capacity</th><td>1.7 l</td></tr></table>
  <a href="/kitchen/?page=2" class="pager">Next page</a>
  <script>track("catalog");</script>
</body>
</html>
```

## Load a document and query it

*Before · HtmlAgilityPack 1.13.1*

```
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
 
// Match the class token "product", not "products"
IEnumerable<HtmlNode> products = doc.DocumentNode.SelectNodes(
    "//div[contains(concat(' ', normalize-space(@class), ' '), ' product ')]") ?? Enumerable.Empty<HtmlNode>();
 
foreach (HtmlNode product in products)
{
    HtmlNode link = product.SelectSingleNode(".//h2/a")!;
    string name = HtmlEntity.DeEntitize(link.InnerText);
    string price = product.SelectSingleNode(".//span[@class='price']")!.InnerText;
    Console.WriteLine($"{product.GetAttributeValue("data-sku", "")}  {name,-20} {price,8}  {link.GetAttributeValue("href", "")}");
}
 
// SelectNodes returns null, not an empty collection, when nothing matches
HtmlNodeCollection? none = doc.DocumentNode.SelectNodes("//div[@class='discontinued']");
Console.WriteLine($"No match: {(none is null ? "null" : none.Count.ToString())}");
```

*Output · HtmlAgilityPack*

```
K-100  Kettle                 $39.00  /p/kettle
T-200  Toaster                $24.50  /p/toaster
M-300  Stand mixer & bowl    $189.00  https://partner.example/mixer
No match: null
```

*After · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
 
// A class selector matches the class token "product", not "products"
foreach (Node product in doc.Root.QuerySelectorAll("div.product"))
{
    Node link = product.QuerySelector("h2 > a");
    string name = link.TextContent!;  // character references are already decoded
    string price = product.QuerySelector("span.price").TextContent!;
    Console.WriteLine($"{product.GetAttribute("data-sku") ?? ""}  {name,-20} {price,8}  {link.GetAttribute("href") ?? ""}");
}
 
// QuerySelectorAll returns an empty list when nothing matches
List<Node> none = doc.Root.QuerySelectorAll("div.discontinued");
Console.WriteLine($"No match: {none.Count}");
```

*Output · onehtml*

```
K-100  Kettle                 $39.00  /p/kettle
T-200  Toaster                $24.50  /p/toaster
M-300  Stand mixer & bowl    $189.00  https://partner.example/mixer
No match: 0
```

- **Class tests get shorter.** `div.product` matches the class token, which in XPath takes the `concat(' ', normalize-space(@class), ' ')` idiom. `contains(@class, 'product')` would also match the `products` wrapper.
- **Null checks move.** `SelectNodes` returns `null` for no match ([docs](https://html-agility-pack.net/select-nodes)), so HtmlAgilityPack code is full of `??`. `QuerySelectorAll` returns an empty list. `QuerySelector` returns a null node rather than `null`, because `Node` is a struct: test `IsNull`.
- **`DeEntitize` goes away.** The parser decodes character references, so `TextContent` gives “Stand mixer & bowl”.

## XPath without a CSS equivalent

Most XPath maps to a selector. Text tests and sibling axes don’t, and they become a line of LINQ or a DOM property:

*Before · HtmlAgilityPack 1.13.1*

```
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
HtmlNode root = doc.DocumentNode;
 
// An element that contains a matching descendant
HtmlNode partner = root.SelectSingleNode(
    "//div[contains(concat(' ', normalize-space(@class), ' '), ' product ')][.//a[starts-with(@href, 'https://')]]")!;
Console.WriteLine($"Partner product: {partner.GetAttributeValue("data-sku", "")}");
 
// Match on text
HtmlNode next = root.SelectSingleNode("//a[contains(text(), 'Next')]")!;
Console.WriteLine($"Next page: {next.GetAttributeValue("href", "")}");
 
// Axes: the cell after a header cell, and the table after a heading
HtmlNode power = root.SelectSingleNode("//th[text()='Power']/following-sibling::td[1]")!;
HtmlNode specs = root.SelectSingleNode("//h3[text()='Specs']/following-sibling::table[1]")!;
Console.WriteLine($"Power: {power.InnerText}, spec rows: {specs.SelectNodes(".//tr")!.Count}");
 
// Up the tree
HtmlNode card = root.SelectSingleNode("//a[@href='/p/toaster']/ancestor::div[1]")!;
Console.WriteLine($"Toaster card: {card.GetAttributeValue("class", "")}");
 
// Rows written directly inside <table>
Console.WriteLine($"table/tr: {root.SelectNodes("//table[@class='specs']/tr")?.Count ?? 0}");
```

*Output · HtmlAgilityPack*

```
Partner product: M-300
Next page: /kitchen/?page=2
Power: 2200 W, spec rows: 2
Toaster card: product sale
table/tr: 2
```

*After · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
Node root = doc.Root;
 
// An element that contains a matching descendant: :has()
Node partner = root.QuerySelector("div.product:has(a[href^='https://'])");
Console.WriteLine($"Partner product: {partner.GetAttribute("data-sku")}");
 
// Match on text: CSS has no text selector, so filter with LINQ
Node next = root.QuerySelectorAll("a").FirstOrDefault(a => a.TextContent!.Contains("Next"));
Console.WriteLine($"Next page: {next.GetAttribute("href")}");
 
// Axes become DOM navigation
Node power = root.QuerySelectorAll("th").First(th => th.TextContent == "Power").NextElementSibling;
Node specs = root.QuerySelectorAll("h3").First(h => h.TextContent == "Specs").NextElementSibling;
Console.WriteLine($"Power: {power.TextContent}, spec rows: {specs.QuerySelectorAll("tr").Count}");
 
// Up the tree: Closest
Node card = root.QuerySelector("a[href='/p/toaster']").Closest("div");
Console.WriteLine($"Toaster card: {card.GetAttribute("class")}");
 
// Rows written directly inside <table>: the parser adds the <tbody> a browser adds
Console.WriteLine($"table > tr: {root.QuerySelectorAll("table.specs > tr").Count}, table > tbody > tr: {root.QuerySelectorAll("table.specs > tbody > tr").Count}");
```

*Output · onehtml*

```
Partner product: M-300
Next page: /kitchen/?page=2
Power: 2200 W, spec rows: 2
Toaster card: product sale
table > tr: 0, table > tbody > tr: 2
```

| XPath | onehtml |
|---|---|
| `//div[.//a[starts-with(@href, 'https://')]]` | `QuerySelector("div:has(a[href^='https://'])")` |
| `//a[contains(text(), 'Next')]` | `QuerySelectorAll("a").FirstOrDefault(a => a.TextContent!.Contains("Next"))` |
| `following-sibling::td[1]` | `NextElementSibling`, or the selector `th + td` |
| `ancestor::div[1]` | `Closest("div")` |
| `..` | `ParentElement` |
| `//li[2]` | `li:nth-of-type(2)` |
| `//table/tr` | `table > tbody > tr`, or `table tr` |

`FirstOrDefault` on a list of nodes returns the null node when nothing matches, so the `IsNull` check works there too. The last line of each output is the one to watch. HtmlAgilityPack keeps rows directly under `table`, while onehtml adds the `tbody` a browser adds, so `table > tr` matches nothing. The [CSS selectors recipe](https://recursiveai.org/onehtml/cookbook/css-selectors/) covers `:has()`, `:is()` and `:not()`.

## InnerText becomes TextContent

`TextContent` is decoded, but it follows the DOM standard, so it includes the text of `script` and `style` elements, which HtmlAgilityPack’s `InnerText` leaves out of the body. If you used `InnerText` to get readable text, use the text walk in [extract visible text from HTML in C#](https://recursiveai.org/onehtml/articles/extract-visible-text-from-html-csharp/). That article runs both libraries on the same page and can also drop text hidden by CSS.

## InnerHtml and OuterHtml

*Before · HtmlAgilityPack 1.13.1*

```
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
 
HtmlNode heading = doc.DocumentNode.SelectSingleNode("//a[starts-with(@href, 'https://')]/..")!;
Console.WriteLine(heading.OuterHtml);
Console.WriteLine(heading.InnerHtml);
```

*Output · HtmlAgilityPack*

```
<h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
<a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a>
```

*After · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
 
Node heading = doc.Root.QuerySelector("a[href^='https://']").ParentElement;
Console.WriteLine(doc.SerializeNode(heading));      // outerHTML, as a browser serializes it
Console.WriteLine(doc.SerializeChildren(heading));  // innerHTML
 
// The original markup, character for character, from the source map
Console.WriteLine(doc.Source.Text[heading.Source.Range.Start..heading.Source.EndTag.End]);
```

*Output · onehtml*

```
<h2><a href="https://partner.example/mixer">Stand mixer &amp; bowl</a></h2>
<a href="https://partner.example/mixer">Stand mixer &amp; bowl</a>
<h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
```

HtmlAgilityPack returns the markup as it was written, single quotes included. `SerializeNode` and `SerializeChildren` write the tree the way a browser serializes `outerHTML` and `innerHTML`. When you need the original text, slice it from the source with the node’s source range, as the last line does.

## Edit and save

The same three changes on both sides: add `rel="nofollow"` to external links, remove scripts, and append a note to the product list.

*Before · HtmlAgilityPack 1.13.1*

```
using HtmlAgilityPack;
 
var doc = new HtmlDocument();
doc.Load("catalog.html");
 
foreach (HtmlNode link in doc.DocumentNode.SelectNodes("//a[starts-with(@href, 'https://')]") ?? Enumerable.Empty<HtmlNode>())
{
    link.SetAttributeValue("rel", "nofollow");
}
foreach (HtmlNode script in doc.DocumentNode.SelectNodes("//script") ?? Enumerable.Empty<HtmlNode>())
{
    script.Remove();
}
HtmlNode list = doc.DocumentNode.SelectSingleNode("//div[@class='products']")!;
list.AppendChild(HtmlNode.CreateNode("<p class=\"note\">Prices include VAT.</p>"));
 
doc.Save("catalog.out.html");
```

*After · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
using RecursiveAI.OneHtml.Editing;
 
Document doc = Document.Parse(File.ReadAllBytes("catalog.html"));
 
var edits = new List<Edit>();
foreach (Node link in doc.Root.QuerySelectorAll("a[href^='https://']"))
{
    edits.Add(Edit.SetAttribute(link, "rel", "nofollow"));
}
foreach (Node script in doc.Root.QuerySelectorAll("script"))
{
    edits.Add(Edit.Remove(script));
}
edits.Add(Edit.Append(doc.Root.QuerySelector("div.products"), "<p class=\"note\">Prices include VAT.</p>"));
 
// All edits apply or none do; the result is re-parsed and verified, and doc is unchanged
EditResult result = doc.Apply(edits);
if (!result.Succeeded)
{
    throw new InvalidOperationException(result.Failure);
}
File.WriteAllBytes("catalog.out.html", result.Document!.ToLosslessBytes());
```

Here is what each program changed, from `diff catalog.html catalog.out.html`:

*diff · HtmlAgilityPack 1.13.1*

```
1c1
< <!DOCTYPE html>
---
> ﻿<!DOCTYPE html>
19c19
<       <h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
---
>       <h2><a href='https://partner.example/mixer' rel="nofollow">Stand mixer &amp; bowl</a></h2>
22c22
<   </div>
---
>   <p class="note">Prices include VAT.</p></div>
26c26
<   <script>track("catalog");</script>
---
>
```

*diff · onehtml 1.0.0*

```
19c19
<       <h2><a href='https://partner.example/mixer'>Stand mixer &amp; bowl</a></h2>
---
>       <h2><a href='https://partner.example/mixer' rel="nofollow">Stand mixer &amp; bowl</a></h2>
21a22
>     <p class="note">Prices include VAT.</p>
26c27
<   <script>track("catalog");</script>
---
>
```

- **Both leave untouched markup alone.** The single-quoted `href`, the unquoted `class=breadcrumbs` and the `&rsaquo;` reference are unchanged in both files.
- **HtmlAgilityPack’s `Save(path)` added a byte order mark.** Line 1 differs only by the three bytes `EF BB BF` that it wrote at the start of the file. onehtml writes back exactly the bytes it read, apart from the edits.
- **The appended element** goes on its own line, indented like its siblings, in onehtml. HtmlAgilityPack puts it straight before `</div>`.
- **Edits are atomic and checked.** `Apply` applies every edit or none. The result is re-parsed and compared with the expected tree before it is returned, and the original `doc` stays valid. [Editing](https://recursiveai.org/onehtml/docs/editing/) explains what happens when a splice would change how the page parses.

## Loading from the web

*Before · HtmlAgilityPack 1.13.1*

```
using HtmlAgilityPack;
 
var web = new HtmlWeb();
HtmlDocument doc = web.Load("https://example.com/");
 
Console.WriteLine(doc.DocumentNode.SelectSingleNode("//title")!.InnerText);
```

*After · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
 
// onehtml never touches the network: fetch the bytes yourself and pass the Content-Type
using var http = new HttpClient();
using HttpResponseMessage response = await http.GetAsync("https://example.com/");
response.EnsureSuccessStatusCode();
 
Document doc = Document.Parse(await response.Content.ReadAsByteArrayAsync(),
    new ParseOptions { ContentType = response.Content.Headers.ContentType?.ToString() });
 
Console.WriteLine(doc.Root.QuerySelector("title").TextContent);
```

Both print “Example Domain”. Fetching yourself lets you set timeouts, headers, retries and caching on your own `HttpClient`, and passing `ContentType` lets a charset in the response header take priority, as in a browser.

## Legacy encodings

`menu.html` is a windows-1252 page that declares its encoding with `<meta charset="windows-1252">`. The [parse HTML in C#](https://recursiveai.org/onehtml/articles/parse-html-csharp/) article has the lines that create it.

*Before · HtmlAgilityPack 1.13.1*

```
using System.Text;
using HtmlAgilityPack;
 
// .NET only ships UTF-8, UTF-16 and a few others; register the legacy code pages first
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
 
var doc = new HtmlDocument();
doc.Load("menu.html");
Console.WriteLine($"Load:                  {doc.Encoding?.WebName}: {doc.DocumentNode.SelectSingleNode("//h1")!.InnerText}");
 
var detected = new HtmlDocument();
detected.DetectEncodingAndLoad("menu.html");
Console.WriteLine($"DetectEncodingAndLoad: {detected.Encoding?.WebName}: {detected.DocumentNode.SelectSingleNode("//h1")!.InnerText}");
 
var given = new HtmlDocument();
given.Load("menu.html", Encoding.GetEncoding(1252));
Console.WriteLine($"Load with encoding:    {given.Encoding?.WebName}: {given.DocumentNode.SelectSingleNode("//h1")!.InnerText}");
```

*Output · HtmlAgilityPack*

```
Load:                  windows-1252: Caf� cr�me � �3.20
DetectEncodingAndLoad: windows-1252: Caf� cr�me � �3.20
Load with encoding:    windows-1252: Café crème – €3.20
```

*After · onehtml 1.0.0*

```
using RecursiveAI.OneHtml;
 
// The WHATWG encodings are built in: no provider to register
Document doc = Document.Parse(File.ReadAllBytes("menu.html"));
Console.WriteLine($"{doc.Source.Encoding.Name}: {doc.Root.QuerySelector("h1").TextContent}");
```

*Output · onehtml*

```
windows-1252: Café crème – €3.20
```

In this run, HtmlAgilityPack’s `Load` and `DetectEncodingAndLoad` reported windows-1252 but returned garbled text, and only passing the encoding to `Load` decoded the page correctly. onehtml found the encoding from the bytes, without a code-page provider. If you pass explicit encodings to HtmlAgilityPack today for pages that declare their encoding, you can drop them when you move.

## Where next

- [Getting started with onehtml](https://recursiveai.org/onehtml/docs/getting-started/): parse, query, edit, compute styles and check accessibility.
- [Edit HTML without reformatting it](https://recursiveai.org/onehtml/cookbook/edit-html-csharp-lossless/) and [save and replay edits](https://recursiveai.org/onehtml/cookbook/save-and-replay-edits/).
- [Remove scripts and event handlers](https://recursiveai.org/onehtml/cookbook/remove-scripts/), a common HtmlAgilityPack job.
- [HtmlAgilityPack vs AngleSharp](https://recursiveai.org/onehtml/articles/htmlagilitypack-vs-anglesharp/), if you are still choosing.
