
For the past few months, I've been cooking up a Notion inspired app to create blogs. I considered hardcoding blog posts or using other tools such as MDX, but I want the ability to make changes dynamically and instantly with a similar experience to taking notes in Notion. Here's a blog about how the blog is built.
"Software developers like to solve problems. If there are no problems available, they will create their own problems."
Sun Tzu, Circa 2001
The project employs Drizzle
as the ORM (Object-Relational Mapping) tool to interface with the database in the application. PostgreSQL
was chosen as the database because they have a free tier on Vercel for its compatibility with Drizzle
and Typescript
. Below is a simplified version of the database schema for the blogs
table:
The blog content data is typed in the codebase instead of the database level as a list of BlogElement
elements for flexibility in making changes to the data structure. Below, the BlogElementTypes
type is a union type defined by its type
, data
, and etc
objects, where data
defines the essential fields for an element while etc
contains secondary optional data I may want to support later on.
Since the app is hosted online, an authentication layer needs to be added to the API requests. Authjs
and Tanstack Query
are used for auth and client-side state management in the builder, where CRUD
operations are handled with Route Handlers
. Nextjs
server actions and server components are kind of a pain to use with client interactions and mutations, so the dashboard is made up of mostly client components like a traditional React
app.
Blogs are immutable outside of the builder, meaning that server actions, server components, and caching are suitable in optimizing SEO and application performance on published blog pages. The published blogs don't require the authentication layer and are cached using Incremental Static Regeneration strategies, which serves a cached prerendered static page for most requests, while blog likes are stored in Redis
. The server action is called in the server rendered components to generate metadata and is streamed into the rendered UI.
With the groundwork laid out, the focus can be shifted to the application interface features. Since I'm just one soy dev, I mostly looked towards libraries that I could piece together to save time on other features. Luckily, there were a few libraries that met what I was looking for.
"Hours of trial and error will save you minutes of reading documentation."
Olivia Rodrigo
The one feature I wanted to build off of was the ability to drag elements to reorder their positioning. I looked into a lot of drag and drop libraries but many had rough APIs to work with or had minimal documentation. I decided on dndkit
, as they had a sortable preset that matched my requirements. While the library is far from perfect, I am keeping an eye on the rewrite of the library once it becomes more stable.

A high level overview of the dndkit
sortable API is that the sortable container requires a sorted list of the item identifiers to monitor state changes and keep the sorting in sync with the ordering of the items. The SortableItem
is the container with the drag handle and uses React refs
to let the drag handlers know what to sort and render. Below is a simplified version of the sortable list component, which is very similar to the sortable preset docs.
Text shortcuts were also something I wanted to replicate from Notion. For example, typing ##
should turn the element into a secondary heading, pressing backspace in the front of the text should concatenate the text to the end of the nearest previous text, and hitting enter after a bullet point should create a new bullet point. Handling these state changes in itself is easier than it sounds when you conditionally render the element types as react will re-render automatically on state changes.

The surprisingly difficult part was managing the text cursor caret. In React, state mutation updates are asynchronous, so you would need to wait for the state to update before you can focus the element, or else it wouldn't work properly. Currently, the browser focus()
API has no options to set the cursor position for a text field so you need to do that with setSelectionRange()
, which requires the element to already be focused. I had to use a combination of flushSync
, useEffect
, useRef
, and setTimeout
to properly manage the cursor position.
File uploads & hosting are implemented with UploadThing
, a wrapper around AWS S3
. What's nice about UploadThing is that the free tier comes with 2GB of storage and no egress and ingress fees. I'm currently just using it for image uploads but may want to extend the builder to support other file types such as audio or pdf files.

Undo & redo functionality is handled very similarly to how a browser handles history state. I recognized it as one of the more popular LeetCode problems, but it didn't click for me until I looked into libraries to handle history state. The code doesn't take advantage of more optimal list operations that you'd find in the LeetCode solutions since React wants you to return a new object on state updates, instead of mutating state directly.
Since the entire blog state is stored at the highest level, changes to the state, regardless of how small, will prompt React to "re-render" just about everything in the blog builder. The mechanics of React re-rendering is out of scope of this blog, but long story short, React will 're-run' any code in your component along with its children whenever any of the component memory states changes to keep the interface in sync with the state. For example, the save button should be enabled / disabled depending on if the current state is different than the initial state, so React will always re-render the save button on blog state changes to check if it should update the DOM.
As the number of elements grows, the lag becomes very apparent when making changes such as typing in a text input element. Since each character typed updates the blog state, each state change is added to the history state list! Below is a visualization of the React re-renders when typing into an input. For context, the code is running in development mode with about 50 blog elements and most of the components are properly memoized with useMemo
, memo
, and useCallback
. The image below shows the re-renders and frame rate using React Scan when the text input component updates the blog state on all changes (no debouncing).
Debounce
Debouncing is like a timer that delays function execution until a specified time. It prevents rapid function calls and will drop all prior calls when calling it again within x time.
So how can we implement debouncing? The answer to get us out of this hell is React refs. The difference between refs and state is that refs are mutable and they don't trigger React to re-render when its value changes. With the help of the debounce
function from the lodash
library, we can create a hook that debounces a callback function, returning a debounce function with useful methods like .cancel()
to throw away any pending invocation and .flush()
to invoke any changes immediately.
Now we can use this hook to debounce state updates in the text input element. We need to keep track of the input state in the input component now that we are debouncing its value updates to the blog state. We use the input state copy to render the most updated text while we debounce the updates to the full blog state to minimize unnecessary re-renders. Another positive side effect is that the history state is only updated once when the debounce callback is called instead of storing a copy of the blog state on each character change.
With debouncing, the image below demonstrates that other elements are unaffected by the changing text before the full blog state gets updated after the debounce period. In production, I haven't noticed any lag after implementing debounced updates. The textfield.tsx
file is more like 1000+ lines of code in my code 💀, as there are a few caveats that require more complex cases, but the above code is good enough for this demonstration. The edge cases are with element shortcuts, in which I would flush the updates instead of waiting for the debounce time and with undo and redo functionality, where I use more refs and useEffects to keep the blog state in sync with the text input without race conditions.
There's still a lot of features that I want to implement in the builder but aren't worth the time at the moment I need to start job searching. Here's a few that I want to add in the future:
Multi-Element Select & Sort
IFrame Embeds
Nested Layouts