The idea was simple: show clients exactly where their hours go. A transparent worklog pulled directly from our Notion database, displayed on the website with real-time data. No manual exports, no stale spreadsheets.
What followed was a debugging journey through API version mismatches, ID confusion, unexpected field types, and the subtle art of displaying data without revealing too much.
The Goal
We track all client work in a Notion database. Each entry has a date, hours, client name, and action description. The website should pull this data and display it in a clean table, grouped by client, with tabs for filtering.
The Package Version Nightmare
First obstacle: the Notion SDK. Running pnpm add with the notionhq client package installed version five point x, the latest. Reasonable default.
The code looked straightforward. I imported the Client from the notionhq client package, created a new client instance with authentication from the environment variable, and called databases dot query with the database ID and a page size of one hundred.
Except it didn't work. The databases dot query method didn't exist.
After some investigation, it turns out Notion SDK version five introduced a completely different API structure. The method is now dataSources dot query, not databases dot query. The migration guide exists, but who reads those before hitting errors?
The fix was simple but frustrating: remove the notionhq client package and reinstall it at version two specifically.
Lesson learned: check the API documentation version against the installed package version before writing code.
Database ID vs Page ID
Next hurdle: "object not found" errors despite having the correct integration connected.
The Notion URL for our worklog looked something like notion dot so slash workspace slash HOURS dash abc123def456 and so on.
Copying that ID and using it as the database ID returned nothing. Turns out that's the page ID, not the database ID.
The database ID is found by opening the database as a full page, not inline, then copying the ID from the URL and using everything before the question mark v equals query parameter.
The Title-Type Date Field
Data was flowing, but every entry came back empty. The API request succeeded, Postman showed results, but the parsed entries were all null.
Time to compare what the API returned versus what the code expected. The code was trying to access a date property with a nested date object containing a start string. But what the API actually returned was a Date field with type title, containing a title array with a plain text value of the date string.
The "Date" field in Notion wasn't a date type - it was a title type. Someone had set up the database using the Date column as the primary field, which in Notion terms means it's stored as rich text, not a date object.
The fix required updating the parser to handle title-type fields. I created a parseWorklogEntry function that takes a page record and returns a WorklogEntry or null. For the date, it extracts from the title array's first element's plain text property. Hours come from a straightforward number type property. Client names come from a select type property. Action descriptions come from a rich text array's first element's plain text. If any required field is missing, the function returns null.
Never assume field types in Notion. Always check the actual API response.
Client Anonymization
Displaying real client names on a public website? Obviously not. The solution: deterministic anonymization.
I created a client name map and a function called getAnonymousClientName. If the real name is already in the map, it returns the existing anonymous name. Otherwise, it generates a new one by converting the current map size to a letter, with zero becoming A, one becoming B, and so on. This creates names like "Client A" and "Client B".
The anonymizeEntries function takes an array of entries and processes them in two passes. First, it collects all unique client names and sorts them alphabetically, then establishes the mapping for each one. Second, it applies the mapping to create a new array where all real client names are replaced with their anonymous equivalents.
The sorting step ensures consistent mapping across requests. "Acme Corp" will always be "Client A" regardless of which entry is processed first.
Pixelated Action Blocks
The action descriptions contain sensitive project details. Redacting them entirely loses context - you can't tell if an entry was a quick fix or a full day's implementation.
Solution: pixelated blocks that preserve length while hiding content.
I created a PixelatedBlock component that takes a seed and text length. It uses two rows with a cell size of twelve pixels. A seeded random function ensures consistent rendering by using the sine of the seed multiplied by ten thousand, then taking just the fractional part. The width is calculated based on actual text length, with the number of columns being the text length divided by three, clamped between eight and forty columns.
The component generates a grid of rectangles with random grayscale values between fifty and two hundred thirty. Each cell gets its own seeded random value based on its position, ensuring the same entry always renders with the same pixel pattern.
The seeded random ensures the same entry always renders with the same pixel pattern - no flickering between renders.
Data Fetching
For simplicity and real-time updates, the worklog fetches fresh data on every request. No caching layers - just direct Notion API calls. The page component exports a dynamic constant set to "force-dynamic" to ensure Next.js always fetches fresh data.
This approach prioritizes data freshness over performance. When the Notion database is updated, the changes appear immediately on the next page load.
The Final Architecture
The complete data flow lives in a lib slash notion dot ts file. It imports the Client and error handling utilities from the notionhq client package. The getWorklogEntries function paginates through all results using a while loop. Each iteration queries the database with a page size of one hundred, sorted by date descending. For each page in the results, if it has properties, we parse it into an entry and add it to our collection. After fetching all pages, the entries are sorted oldest first for display.
Error handling catches Notion-specific errors and logs helpful messages for common issues like database not found or unauthorized access.
The page component in app slash worklog slash page dot tsx is a server component that fetches raw entries from Notion, anonymizes them, extracts unique client names, and passes everything to a WorklogContent client component for rendering.
Files Modified
The implementation involved creating a new lib slash notion dot ts file for the Notion client, parsing logic, and anonymization. The app slash worklog slash page dot tsx was modified to be a server component with dynamic rendering. The app slash worklog slash worklog-content dot tsx was modified to be a client component with tabs, tables, and pixelated blocks. A new app slash worklog slash layout dot tsx was created for SEO metadata. The env file was updated with the Notion API key and worklog database ID environment variables. Finally, package dot json was updated to include notionhq client at version two point three point zero.
Takeaways
First, check SDK versions. The Notion SDK version five is a complete rewrite. Most documentation assumes version two. Pin your version explicitly.
Second, database ID does not equal page ID. In Notion, the URL you see might be for the page containing the database, not the database itself. Open as full page to get the right ID.
Third, never assume field types. A column named "Date" might be a title field, not a date field. Always inspect the actual API response structure.
Fourth, use deterministic anonymization. Sort inputs before mapping to ensure consistent output across requests. "Client A" should always mean the same client.
Fifth, preserve context without content. Pixelated blocks let viewers understand relative effort, distinguishing short versus long descriptions, without revealing sensitive details.
The worklog is now live, updating automatically from Notion, with proper privacy controls. Clients can see exactly where their hours go - without seeing where anyone else's hours go.