OneNote — Search, Query, and Pagination
Overview
OneNote's dedicated search endpoint was deprecated in April 2024. The replacement — OData $filter queries on page listings — cannot search page body content, cannot search across all notebooks in a single call, and sometimes returns deleted pages in results. Pagination via @odata.nextLink is unreliable: the link is sometimes omitted even when more results exist. This skill provides production-tested patterns for content discovery, cross-notebook queries, and safe pagination with guard rails.
Key pain points addressed:
- The
$search parameter on /me/onenote/pages is deprecated — use $filter on metadata fields only
- No single endpoint searches across all notebooks — you must iterate notebooks and their sections
- Deleted pages continue appearing in
GET /sections/{id}/pages results for up to 30 minutes
@odata.nextLink may be absent even when $top items were returned (Graph bug with OneNote)
Prerequisites
- Azure app registration with delegated permissions:
Notes.Read or Notes.ReadWrite
- App-only auth deprecated March 31, 2025 — use delegated auth only
- Python:
pip install msgraph-sdk azure-identity
- Node/TypeScript:
npm install @microsoft/microsoft-graph-client @azure/identity @azure/msal-node
- Optional for client-side search:
npm install fuse.js or pip install thefuzz
Instructions
Step 1 — Query Pages with OData Filters
OData $filter works on page metadata fields — not body content. Supported fields: title, createdDateTime, lastModifiedDateTime.
import { Client } from "@microsoft/microsoft-graph-client";
const results = await client.api("/me/onenote/pages")
.filter("contains(title, 'sprint planning')")
.select("id,title,lastModifiedDateTime,parentSection")
.top(20)
.orderby("lastModifiedDateTime desc")
.get();
const recentPages = await client.api("/me/onenote/pages")
.filter("lastModifiedDateTime ge 2026-03-01T00:00:00Z")
.select("id,title,lastModifiedDateTime")
.top(50)
.orderby("lastModifiedDateTime desc")
.get();
Warning: $search was deprecated April 2024. Using it returns 400 Bad Request on most tenants. Use $filter with contains() on title, or implement client-side search on fetched content.
Step 2 — Cross-Notebook Search Pattern
There is no single Graph endpoint that searches page content across all notebooks. You must iterate:
interface SearchResult {
pageId: string;
title: string;
sectionName: string;
notebookName: string;
lastModified: string;
snippet?: string;
}
async function searchAcrossNotebooks(
client: Client,
query: string
): Promise<SearchResult[]> {
const results: SearchResult[] = [];
const notebooks = await client.api("/me/onenote/notebooks")
.select("id,displayName")
.get();
for (const notebook of notebooks.value) {
const sections = await client.api(
`/me/onenote/notebooks/${notebook.id}/sections`
).select("id,displayName").get();
for (const section of sections.value) {
const pages = await client.(
)
.(
Performance: This approach makes N+M API calls (N notebooks + M total sections). For users with many notebooks, cache the notebook/section structure and only fetch pages from recently modified sections.
Step 3 — Client-Side Full-Text Search
Since $filter only works on metadata, search page body content client-side after fetching:
import Fuse from "fuse.js";
interface IndexedPage {
id: string;
title: string;
plainText: string;
sectionId: string;
}
async function buildSearchIndex(client: Client, sectionId: string): Promise<Fuse<IndexedPage>> {
const pages = await client.api(`/me/onenote/sections/${sectionId}/pages`)
.select("id,title")
.top(100)
.get();
const indexed: IndexedPage[] = [];
for (const page of pages.value) {
const contentStream = await client.api(
`/me/onenote/pages/${page.id}/content`
).get();
const html = await (contentStream);
plainText = html.(, ).(, ).();
indexed.({ : page., : page., plainText, sectionId });
}
(indexed, {
: [
{ : , : },
{ : , : },
],
: ,
: ,
});
}
fuse = (client, sectionId);
results = fuse.();
Step 4 — Safe Pagination with Guard Rails
The @odata.nextLink from OneNote endpoints is sometimes missing even when more results exist. Always implement a safety limit:
interface PaginatedResult<T> {
items: T[];
totalFetched: number;
hitSafetyLimit: boolean;
}
async function paginateAll<T>(
client: Client,
initialUrl: string,
maxPages: number = 20,
pageSize: number = 100
): Promise<PaginatedResult<T>> {
const items: T[] = [];
let url: string | null = `${initialUrl}${initialUrl.includes("?") ? "&" : "?"}$top=${pageSize}`;
let pagesConsumed = 0;
while (url && pagesConsumed < maxPages) {
const response = await client.api(url).get();
const batch = response.value ?? [];
items.push(...batch);
pagesConsumed++;
if (batch.length < pageSize) break;
url = response[] ?? ;
(!url && batch. === pageSize) {
.();
skip = items.;
url = ;
}
}
{
items,
: items.,
: pagesConsumed >= maxPages,
};
}
Step 5 — Filter Deleted Pages from Results
Deleted pages can appear in list results for up to 30 minutes. Filter them before displaying:
async function getActivePages(client: Client, sectionId: string) {
const result = await paginateAll(
client,
`/me/onenote/sections/${sectionId}/pages?$select=id,title,lastModifiedDateTime,createdDateTime&$orderby=lastModifiedDateTime desc`
);
const activePages = result.items.filter((page: any) => {
if (!page.title) return false;
return true;
});
return activePages;
}
Step 6 — Python Async Pagination
from msgraph import GraphServiceClient
async def paginate_pages(client: GraphServiceClient, section_id: str, max_pages: int = 20):
"""Paginate through all pages in a section with safety limits."""
all_pages = []
pages_fetched = 0
result = await client.me.onenote.sections.by_onenote_section_id(
section_id
).pages.get()
while result and pages_fetched < max_pages:
all_pages.extend(result.value or [])
pages_fetched += 1
if not result.odata_next_link:
break
result = await client.me.onenote.sections.by_onenote_section_id(
section_id
).pages.with_url(result.odata_next_link).get()
return all_pages
Output
Search and query operations return:
- Page listing: JSON array with
id, title, createdDateTime, lastModifiedDateTime, parentSection
- Page content: XHTML stream (must be buffered and parsed)
- Pagination:
@odata.nextLink URL (when present) or @odata.count (when $count=true is specified)
Error Handling
| Status | Cause | Fix |
|---|
| 400 | Deprecated $search parameter, malformed $filter syntax | Use $filter with contains() — not $search |
| 400 | Invalid OData operator (e.g., eq on title) | Only contains() and startswith() work on string fields |
| 404 | Page deleted but appeared in listing | Filter by non-null title; verify with GET /pages/{id} |
| 429 | Rate limited during cross-notebook iteration | Implement per-request delays; see onenote-rate-limits |
| 502 | Token expired mid-pagination | Refresh auth token and resume from last @odata.nextLink |
Examples
TypeScript — Search recent pages modified this week:
const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const recent = await client.api("/me/onenote/pages")
.filter(`lastModifiedDateTime ge ${oneWeekAgo}`)
.select("id,title,lastModifiedDateTime")
.orderby("lastModifiedDateTime desc")
.top(50)
.get();
console.log(`Found ${recent.value.length} pages modified in the last 7 days`);
Python — Count pages per section:
notebooks = await client.me.onenote.notebooks.get()
for nb in notebooks.value:
sections = await client.me.onenote.notebooks.by_notebook_id(
nb.id
).sections.get()
for sec in sections.value:
pages = await client.me.onenote.sections.by_onenote_section_id(
sec.id
).pages.get()
count = len(pages.value) if pages.value else 0
print(f"{nb.display_name}/{sec.display_name}: {count} pages")
Resources
Next Steps
- See
onenote-core-workflow-a for CRUD operations on notebooks, sections, and pages
- See
onenote-rate-limits for throttling cross-notebook search patterns
- See
onenote-performance-tuning for $select optimization and caching strategies