Building a Notion Replacement with Convex and Next.js
A few months ago we decided to stop paying for Notion across three workspaces and build our own replacement. This post is about the architecture choices we made, which ones held up, and which ones we got wrong the first time.
The product is Nozio — an MDX-first workspace where AI generates interactive UI components inline in documents. This post is just about the infrastructure. The AI parts are covered separately.
Why Convex
The first question anyone building a collaborative document tool faces: how do you handle real-time sync?
Options we evaluated:
Supabase Realtime — Postgres with websocket subscriptions on top. We use Supabase for other projects and it is solid. The problem: subscriptions work at the table row level. You subscribe to changes on a row. For a document editor you are often tracking changes at a much finer granularity — individual blocks, property updates, cursor positions. Supabase Realtime can do this but you end up building significant coordination logic yourself.
PlanetScale + Pusher — Separate your database from your realtime layer. Fine, but you are now running two services and keeping them in sync. Any write that needs to broadcast has to go through two systems. This is the architecture most SaaS tools had in 2018.
Liveblocks / PartyKit — Dedicated collaboration infrastructure. Good products. But they abstract away the storage layer — you end up with your document content in Liveblocks and your metadata in Postgres and you are back to two-system coordination.
Convex — Reactive database. Queries are functions. When the data a query reads changes, every client subscribed to that query automatically gets the update. No separate websocket layer. No subscription management. You write a query function, the client subscribes to it, and it stays live.
The pitch that convinced us: we never wrote a single websocket handler. The entire real-time sync for Nozio — document content, property updates, presence, comments — runs through Convex query subscriptions.
What Convex looks like in practice
Convex uses TypeScript functions as the interface to your database. A mutation looks like this:
// convex/documents.ts
export const updateContent = mutation({
args: {
id: v.id('documents'),
content: v.string(),
updatedAt: v.number(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Not authenticated');
await ctx.db.patch(args.id, {
content: args.content,
updatedAt: args.updatedAt,
});
},
});
And a query that clients subscribe to:
export const getDocument = query({
args: { id: v.id('documents') },
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
},
});
On the client side:
// app/editor/[id]/page.tsx
const document = useQuery(api.documents.getDocument, { id: documentId });
const updateContent = useMutation(api.documents.updateContent);
useQuery is reactive. When anyone calls updateContent for this document, every client subscribed to getDocument for the same ID gets the update automatically. Convex handles the websocket connection, the subscription bookkeeping, and the fan-out.
The tradeoff: Convex is not Postgres. It is a document database with a query model that looks like functions rather than SQL. If your app needs complex joins or aggregations, you feel that. For document workspaces — which are mostly "get this document and its children" queries — the query model maps well.
The schema
Convex schema is TypeScript:
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
documents: defineTable({
title: v.string(),
content: v.string(), // MDX source
authorId: v.string(),
workspaceId: v.id('workspaces'),
parentId: v.optional(v.id('documents')),
propertySchema: v.optional(v.string()), // JSON schema for custom properties
properties: v.optional(v.string()), // JSON property values
viewType: v.optional(v.string()), // kanban | list | table | document
viewConfig: v.optional(v.string()), // JSON view configuration
series: v.optional(v.string()),
publishedAt: v.optional(v.number()),
updatedAt: v.number(),
createdAt: v.number(),
})
.index('by_workspace', ['workspaceId'])
.index('by_parent', ['parentId'])
.index('by_author', ['authorId']),
workspaces: defineTable({
name: v.string(),
ownerId: v.string(),
plan: v.string(),
createdAt: v.number(),
}),
components: defineTable({
name: v.string(),
description: v.string(),
propsSchema: v.string(), // JSON schema for props
source: v.string(), // TSX source
workspaceId: v.id('workspaces'),
addedAt: v.number(),
})
.index('by_workspace', ['workspaceId']),
});
The content field is MDX stored as a string. The properties and propertySchema fields are JSON stored as strings — we will come back to why that decision was slightly wrong.
The editor: TipTap
TipTap is a headless rich text editor built on ProseMirror. We picked it because:
- It has a proper extension system. Adding a custom block type is a first-class operation.
- It serializes to JSON (ProseMirror document model) and to HTML, and you can write custom serializers.
- It does not force you into a specific UI. We could style it however we wanted.
The part we got wrong: we initially tried to make TipTap work with MDX directly — have the editor produce MDX as its output format. This was a mistake. TipTap's internal format is a node tree. MDX is text with embedded JSX. Keeping them in sync is doable but painful. Every serialization edge case (code blocks with backticks inside MDX blocks, JSX attributes with special characters) becomes a bug.
We switched to treating TipTap as the editing interface and MDX as the storage format. The editor works on the TipTap node tree. On save, a serializer converts to MDX. On load, a parser converts MDX back to TipTap nodes. The serializer and parser are the seam, and we contain bugs there instead of spreading them through the editor.
The TipTap extension for component blocks:
// lib/editor/extensions/component-block.ts
import { Node, mergeAttributes } from '@tiptap/core';
import { ReactNodeViewRenderer } from '@tiptap/react';
import { ComponentBlockView } from './component-block-view';
export const ComponentBlock = Node.create({
name: 'componentBlock',
group: 'block',
atom: true,
addAttributes() {
return {
componentType: { default: null },
props: { default: '{}' },
};
},
parseHTML() {
return [{ tag: 'div[data-component-block]' }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes, { 'data-component-block': '' })];
},
addNodeView() {
return ReactNodeViewRenderer(ComponentBlockView);
},
});
The ComponentBlockView is a React component that renders the actual live component from the registry. It reads componentType and props from the TipTap node attributes, looks up the component in the registry, and renders it.
MDX rendering
MDX documents render on the Next.js side. We use next-mdx-remote for parsing and @mdx-js/mdx for compilation:
// lib/mdx/renderer.tsx
import { MDXRemote } from 'next-mdx-remote/rsc';
import { componentRegistry } from '@/lib/components/registry';
interface MDXRendererProps {
source: string;
}
export function MDXRenderer({ source }: MDXRendererProps) {
// Build the components map from the registry
const components = Object.fromEntries(
Object.entries(componentRegistry).map(([name, { component }]) => [name, component])
);
return (
<MDXRemote
source={source}
components={components}
/>
);
}
When the MDX contains a component tag like <KanbanBoard filter="status=open" groupBy="owner" />, MDX Remote resolves it against the components map and renders the registered component. This is standard MDX — the non-standard part is that the components in the map are the same components the AI uses from the registry.
One gotcha: next-mdx-remote/rsc compiles MDX on the server. This is fast for static rendering but means components that need client interactivity must be client components imported into the MDX renderer. We mark all registry components as "use client" and import them explicitly.
Where we got the schema wrong
The properties and propertySchema fields in the Convex schema are stored as JSON strings. That was wrong.
The reasoning at the time: Convex does not support arbitrary nested objects in its schema validator without using v.any(), and we did not want to use v.any() everywhere because it disables runtime type checking.
The problem: string-serialized JSON is opaque to Convex's query system. You cannot filter documents by a property value, sort by a custom date property, or aggregate across documents with a shared property. Every operation that involves properties has to deserialize the JSON in JavaScript.
For read-heavy operations that is fine. For queries like "show me all tasks where status is 'open'" across a workspace — which is a core feature — it means pulling all documents into JavaScript and filtering them there. That does not scale.
The fix we are working toward: use v.record(v.string(), v.union(v.string(), v.number(), v.boolean(), v.null())) for properties with scalar values, and handle complex types (relations, rich text) as separate tables. This lets Convex index property values properly.
The tradeoffs in practice
What worked:
- Convex real-time sync held up. We have had zero WebSocket-related incidents. Presence, collaborative editing, live updates — all just work.
- TipTap as editing interface / MDX as storage format is clean. The seam is well-defined.
- Next.js 15 App Router with Server Components gives us fast initial renders for read-heavy pages.
What we would do differently:
- Properties as proper typed fields from the start, not JSON strings.
- TipTap collaborative editing via Yjs from the start rather than adding it later. Adding Yjs CRDTs to an existing TipTap implementation requires touching almost every part of the editor setup.
- Keep the component registry schema simple. We over-engineered the schema validation for component props early on. The simpler version — just document the props in a string description and let the model read that — works just as well and is easier to maintain.
Nozio is in invite beta at nozio.vercel.app.
Matthew J. Whitney is a co-founder at BeddaTech.