Collaborate, Innovate, Automate

Create Bilingual Navigation

This PnP PowerShell script builds a complete bilingual EN/FR Quick Launch navigation in SharePoint Online from scratch, reading the full node structure and translations from a JSON configuration file. Unlike the Add Navigation Translations script — which searches for and updates nodes that already exist — this script clears the existing Quick Launch and creates every node fresh, setting English and French titles in a single operation.

Purpose

This script helps with multilingual navigation deployments by:

Prerequisites

JSON Configuration File

Define your full navigation structure in a JSON file. Each node supports a title (English), a url, a translations object for the French title, and an optional children array for sub-nodes. Pass the file path to $jsonFilePath in the script.

{
  "level1": [
    {
      "title": "Organisation",
      "url": "#",
      "translations": {
        "fr": "Organisation"
      },
      "children": [
        {
          "title": "Regulatory Framework",
          "url": "#",
          "translations": {
            "fr": "Cadre réglementaire"
          },
          "children": []
        }
      ]
    }
  ]
}

PowerShell Script

# ────────────────────────────────────────────────────────────────────────────────
# SharePoint Quick Launch – bilingual creation (EN/FR)
# Requires: PnP.PowerShell  ≥ 2.2.0
# ────────────────────────────────────────────────────────────────────────────────

$siteUrl      = "https://tenantName.sharepoint.com/sites/siteName"
$clientId     = ""
$jsonFilePath = ""
$logFilePath  = ".\navigation-creation-log.csv"
$logEntries   = @()

# ──────────────────────────────────────────────────────────────────  helpers ── #

function Write-ToLog {
    param([string]$Action,[string]$NavigationNode,[string]$Status,[string]$Message)
    $script:logEntries += [PSCustomObject]@{
        Timestamp      = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
        Action         = $Action
        NavigationNode = $NavigationNode
        Status         = $Status
        Message        = $Message
    }
}

function Set-NavNodeTranslation {
    param(
        [Microsoft.SharePoint.Client.NavigationNode]$Node,
        [hashtable]$Translations
    )

    if (-not $Translations -or $Translations.Count -eq 0) { return }

    $ctx = Get-PnPContext
    $ctx.Load($Node)
    $ctx.ExecuteQuery()

    foreach ($lang in $Translations.Keys) {
        $culture = switch ($lang.ToLower()) {
            'fr' { 'fr-FR' }
            'en' { 'en-US' }
            default { throw "Unsupported language '$lang'" }
        }
        $Node.TitleResource.SetValueForUICulture($culture, $Translations[$lang])
    }

    $Node.Update()
    $ctx.ExecuteQuery()
    Write-ToLog -Action "Translation" -NavigationNode $Node.Title -Status "Success" `
                -Message "Set translations: $($Translations.Keys -join ',')"
}

function Build-FullUrl([string]$BaseUrl,[string]$RelativePath){
    $BaseUrl.TrimEnd('/') + '/' + $RelativePath.TrimStart('/')
}

function Add-SafeNavigationNode {
    param(
        [string]$Title,
        [string]$Location = "QuickLaunch",
        [string]$Url,
        [int]   $Parent,
        [hashtable]$Translations
    )

    try {
        if     ($Parent -and $Url) { $node = Add-PnPNavigationNode -Location $Location -Title $Title -Url $Url    -Parent $Parent -ErrorAction Stop }
        elseif ($Parent)           { $node = Add-PnPNavigationNode -Location $Location -Title $Title               -Parent $Parent -ErrorAction Stop }
        elseif ($Url)              { $node = Add-PnPNavigationNode -Location $Location -Title $Title -Url $Url                 -ErrorAction Stop }
        else                        { $node = Add-PnPNavigationNode -Location $Location -Title $Title                           -ErrorAction Stop }

        Write-ToLog "Create" $Title "Success" "Created navigation node"

        Set-NavNodeTranslation -Node $node -Translations $Translations

        return $node
    }
    catch {
        Write-ToLog "Create" $Title "Error" $_.Exception.Message
        Write-Warning "Failed to create node '$Title' : $($_.Exception.Message)"
    }
}

# ────────────────────────────────────────────────────────────────────────────────

try {
    Write-Host "Connecting to SharePoint Online…" -f Yellow
    Connect-PnPOnline -Url $siteUrl -Interactive -ClientId $clientId

    $site = Get-PnPWeb
    Write-Host "Connected: $($site.Title)" -f Green
    Write-ToLog "Connection" "N/A" "Success" "Connected to SharePoint Online"

    # ── read JSON config ──
    $navConfig = Get-Content $jsonFilePath -Raw | ConvertFrom-Json
    Write-ToLog "ConfigRead" "N/A" "Success" "Navigation configuration loaded"

    # ── clear existing nav ──
    Write-Host "Clearing existing Quick Launch…" -f Yellow
    Get-PnPNavigationNode -Location QuickLaunch | ForEach-Object {
        Remove-PnPNavigationNode -Identity $_.Id -Force
        Write-ToLog "Remove" $_.Title "Success" "Removed existing node"
    }

    # ── build nav from JSON ──
    foreach ($lvl1 in $navConfig.level1) {
        $lvl1Url = if ($lvl1.url) { Build-FullUrl $siteUrl $lvl1.url } else { $null }

        $lvl1Translations = @{}
        if ($lvl1.translations) { $lvl1.translations.PSObject.Properties |
                                  ForEach-Object { $lvl1Translations[$_.Name] = $_.Value } }

        $lvl1Node = Add-SafeNavigationNode -Title $lvl1.title -Url $lvl1Url `
                                           -Translations $lvl1Translations

        foreach ($lvl2 in $lvl1.children) {
            $lvl2Url = if ($lvl2.url) { Build-FullUrl $siteUrl $lvl2.url } else { $null }
            $lvl2Translations = @{}
            if ($lvl2.translations) { $lvl2.translations.PSObject.Properties |
                                      ForEach-Object { $lvl2Translations[$_.Name] = $_.Value } }

            $lvl2Node = Add-SafeNavigationNode -Title $lvl2.title -Url $lvl2Url `
                                               -Parent $lvl1Node.Id -Translations $lvl2Translations

            foreach ($lvl3 in $lvl2.children) {
                $lvl3Url = if ($lvl3.url) { Build-FullUrl $siteUrl $lvl3.url } else { $null }
                $lvl3Translations = @{}
                if ($lvl3.translations) { $lvl3.translations.PSObject.Properties |
                                          ForEach-Object { $lvl3Translations[$_.Name] = $_.Value } }

                Add-SafeNavigationNode -Title $lvl3.title -Url $lvl3Url `
                                       -Parent $lvl2Node.Id -Translations $lvl3Translations
            }
        }
    }

    $final = Get-PnPNavigationNode -Location QuickLaunch
    Write-Host "Created $($final.Count) navigation nodes." -f Green

} catch {
    Write-ToLog "Error" "N/A" "Failed" $_.Exception.Message
    Write-Error $_.Exception.Message
} finally {
    Disconnect-PnPOnline
    Write-ToLog "Connection" "N/A" "Success" "Disconnected"
    $logEntries | Export-Csv -Path $logFilePath -NoTypeInformation
    Write-Host "Log written to $logFilePath" -f Cyan
}
          

Usage Notes