# The Checkbox With No API: Opening SharePoint Navigation Links in a New Tab

- Date: 2026-10-15
- Author: Jeppe Spanggaard
- Description: Learn how to set OpenInNewWindow on SharePoint navigation nodes programmatically with the MenuState and SaveMenuState REST endpoints, since PnP templates and CSOM can't.
- URL: https://jeppe-spanggaard.dk/blogs/sharepoint-navigation-open-in-new-tab/
- Tags: SharePoint, PnP, Provisioning

## TL;DR

The flag is `OpenInNewWindow` on the navigation menu tree, and the only way to set it programmatically is the REST pair the navigation editor itself uses: `POST /_api/navigation/MenuState` to read the tree, flip the flag, `POST /_api/navigation/SaveMenuState` to write the whole tree back. Echo the `Version` token, and send `odata=nometadata` on the Content-Type or the save fails with a 500.


My provisioning engine creates SharePoint sites from a PnP template, navigation included. Two of the links point at other internal tools on the same tenant, and the customer wanted those to open in a new tab - by default SharePoint opens same-tenant links in the same tab, and users kept losing the site they came from.

Sounds like a checkbox. It *is* a checkbox - edit the navigation by hand and the panel has a literal "Open in a new tab" toggle.

But I had hundreds of sites to provision. So I went looking for the attribute in the PnP provisioning schema. Not there. Fine, I'll set it with CSOM after the template applies. `NavigationNode` has `Title`, `Url`, `IsExternal`... and no target property either.

A checkbox in the UI with no API underneath it. Or so it seemed.

## Spying on the Checkbox

If the schema doesn't have it and CSOM doesn't have it, what does the browser do when *you* click the checkbox? Open dev tools, edit the navigation, save: a POST to `/_api/navigation/SaveMenuState` carrying the entire menu tree as JSON, each node with an `OpenInNewWindow` property. The matching read is `/_api/navigation/MenuState`. (pnp/cli-microsoft365 uses the same endpoints, which is where I stole the retrieval body shape from.)

So the recipe is read-modify-write:

```csharp
public async Task SetOpenInNewWindowAsync(
    string siteUrl, IReadOnlyCollection<string> nodeTitles, CancellationToken ct) {

    // 1. Read the current-navigation menu tree. Note: a POST with a
    //    retrieval body, not a GET.
    var body = new JsonObject {
        ["customProperties"] = null,
        ["depth"] = 10,
        ["mapProviderName"] = null,
        ["menuNodeKey"] = null,  // null = QuickLaunch, "1002" = top navigation
    }.ToJsonString();
    var getJson = await PostAsync(siteUrl, "_api/navigation/MenuState", body, ct);
    var menuState = UnwrapMenuState(JsonNode.Parse(getJson));

    // 2. Flip OpenInNewWindow on matching nodes, preserving everything
    //    else (incl. the "Version" token SaveMenuState requires back).
    var wanted = new HashSet<string>(nodeTitles, StringComparer.OrdinalIgnoreCase);
    var changed = ApplyOpenInNewWindow(menuState["Nodes"] as JsonArray, wanted);
    if (changed == 0) return;

    // 3. POST the whole tree back under a "menuState" wrapper.
    var payload = new JsonObject { ["menuState"] = menuState.DeepClone() };
    await PostAsync(siteUrl, "_api/navigation/SaveMenuState", payload.ToJsonString(), ct);
}

private static int ApplyOpenInNewWindow(JsonArray? nodes, HashSet<string> wantedTitles) {
    if (nodes is null) return 0;

    var changed = 0;
    foreach (var node in nodes) {
        if (node is not JsonObject obj) continue;

        var title = obj["Title"]?.GetValue<string>();
        if (title is not null && wantedTitles.Contains(title)
            && obj["OpenInNewWindow"]?.GetValue<bool>() != true) {
            obj["OpenInNewWindow"] = true;
            changed++;
        }
        changed += ApplyOpenInNewWindow(obj["Nodes"] as JsonArray, wantedTitles);
    }
    return changed;
}
```

**What's happening here?**

1. The tree stays raw `JsonNode` on purpose. `SaveMenuState` wants the *whole* menu state back, including properties I don't care about and a `Version` concurrency token I must echo unchanged. A typed model risks silently dropping something SharePoint expects to get back.
2. Matching is by title, recursively, because nodes can nest. Titles work for me since my PnP template fixes them; if yours vary, match on `SimpleUrl` instead.
3. A node only counts as changed if the flag wasn't already `true`, and `changed == 0` skips the save - the operation is idempotent, so my provisioning engine can retry the step freely.

## Gotchas

- **`odata=nometadata` on the Content-Type, or a 500.** Without `Content-Type: application/json;odata=nometadata`, SharePoint parses your body as odata=verbose, expects arrays as `{"results": [...]}`, and `SaveMenuState` answers with a `TargetInvocationException` that says nothing about why. The payload was fine. The header made it wrong. Ask me how long that took to find.
- **`MenuState` is a POST, not a GET.** You send a retrieval body to read data. The navigation editor does it the same way; don't fight it.
- **Echo the `Version` token.** Send back exactly the tree you got, modified only where you mean it.
- **The read response shape varies.** Bare, wrapped in a `MenuState` property, or under `d.MenuState` in verbose mode, depending on your Accept header. Normalize before touching `Nodes`.
- **Order matters in provisioning.** The nodes must exist before you can flag them, so this runs after the PnP template apply.
- **No X-RequestDigest needed with bearer auth.** The digest dance is only for cookie-based auth.

## Wrapping Up

When a SharePoint setting has a checkbox in the UI but no property in the PnP schema or CSOM, watch the network tab: the modern UI runs on REST endpoints you can call yourself. For navigation link targets that's the `MenuState`/`SaveMenuState` pair - read the tree, flip `OpenInNewWindow`, write the whole tree back, and mind the `odata=nometadata` header on the way in.

