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:
- Building the entire Quick Launch navigation structure from a JSON file
- Creating up to three levels of navigation nodes (level 1, 2, and 3)
- Setting English and French
TitleResourcetranslations on each node at creation time - Clearing pre-existing navigation before building, ensuring a clean result
- Writing a CSV log of every action taken for auditing and troubleshooting
Prerequisites
- PnP PowerShell module version 2.2.0 or later
- Site owner permissions
- Multilingual features enabled on the site
- French language pack installed on the site
- A JSON configuration file defining the navigation structure (see format below)
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
- Set
$jsonFilePathto the full path of your JSON configuration file before running - The script clears the existing Quick Launch before building — verify the JSON structure is complete before running in production
- URLs in the JSON can be relative paths (e.g.
/sites/siteName/SitePages/home.aspx) or#for header nodes with no link; the script prepends the site URL automatically - Only
frandenare supported by default. To support a different language, update theswitchblock insideSet-NavNodeTranslationto map your language key to the correct culture code:
Then add the matching key to your JSONforeach ($lang in $Translations.Keys) { $culture = switch ($lang.ToLower()) { 'fr' { 'fr-FR' } 'en' { 'en-US' } 'es' { 'es-ES' } # example: add Spanish default { throw "Unsupported language '$lang'" } } $Node.TitleResource.SetValueForUICulture($culture, $Translations[$lang]) }translationsobject, e.g."es": "Marco regulatorio". - A CSV log is written to
navigation-creation-log.csvin the working directory after every run - Test in a development site before running against a production environment