remark
Noun • /ri'maːk/
- [countable] something that you say or write which expresses an opinion, a thought, etc. about somebody or something.
- to make a remark.
- He made his opening remarks to the assembled press.
- [uncountable] (old fashioned or formal) the quality of being important or interesting enough to be noticed.
Giving users the ability to engage with your content opens the door for conversations that might never happen otherwise. Visitors who would normally leave quietly can now interact, respond, and even convert.
To build something like this, there are many choices. You could go the traditional route and build everything yourself: auth, user management, database integrations, security, performance...lots of moving parts, and lots of ways things can break. Or you can lean on modern backend-as-a-service (BaaS) tools. I chose the latter.
I had heard about the mighty duo Convex and Clerk, how easily they help you build scalable React apps. So I decided to put them to the test and safe to say, they live up to the hype.
In this article, I'll walk through some of the features and capabilities this stack enables. For setup instructions, check out this guide.
Real-Time Synchronization
One of Convex's standout features is real-time updates.
Traditionally, comment systems rely on polling or manual refreshes to stay in sync, but with Convex this happens automatically. Retries also happen automatically, when a network connection is restored after an offline period, Convex will reconnect immediately and update with fresh data when possible.
// Convex automatically handles real-time sync
const comments = useQuery(api.comments.getComments, { postId });State Persistence
Persistence is key to making your web app feel native and forgiving, your user shouldn't have to retype his 500 word meme all over again when he mistakenly refreshes the page. Almost every action is persisted to local-storage in Remark, from editor inputs to UI preferences.
// Comments are cached locally
const cachedComments = storageManager.getCachedComments(postId);
// Display cached data when offline
const comments = isOnline ? liveComments : cachedComments;
// Auto-saves every 500ms
useEditorPersistence(editor, {
editorId: `comment_draft_${postId}`,
debounceMs: 500,
});Rich Text Editing with Tiptap
TipTap powers the editor experience. It supports rich text formatting, links, and @mentions (mentions will be added in a later update, stay tuned).
import { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
const editor = new Editor({
extensions: [
StarterKit,
Link.configure({
validate: (url) => /^https?:\/\//.test(url),
}),
],
});Pagination and Limits
Uncontrolled fetches can hurt performance and memory usage. To avoid that, a batch load strategy was adopted, initial requests are capped at 40 top-level comments + all replies with a "load more" button to fetch the next 40. Currently "load more" is manual, but in the future, a cursor based automatic refetch feature will be implemented (cursor position or current position in the list will automatically trigger a fetch).
const COMMENTS_CONFIG = {
PAGE_SIZE: 40,
MAX_COMMENTS_PER_POST: 1200,
ARCHIVE_DISPLAY_THRESHOLD: 500,
};Auto-Lock System
Remark also includes an auto-lock system. When at 90% capacity, a warning appears and at full capacity (1000 comments per conversation), the conversation is automatically locked and becomes read-only. This keeps database size under control and prevents excessive requests.
When in read-only mode all interactive elements (reply, edit, reactions) are also disabled to protect system resources.
Security & Validation
Accepting user input always carries risk and/or misuse, so guardrails are important. Content is stored as JSON (to mitigate xss attacks), links are validated to block javascript: URLs and TipTap also sanitizes
output automatically.
Input Validations:
// Character limit enforcement
if (textLength > 3000) {
throw new Error("Comment too long");
}
// Link sanitization
validate: (url) => /^https?:\/\//.test(url);Performance & UI/UX
- Indexed queries: comments when posted are attached indexes (
postId,userId) which enables fast lookups on requests. - Optimistic UI patterns: previously known states (eg: signed-in status) are cached and used as default on load (or refresh) to provide instant visual feedback before server confirms.
// Single query gets comment + user data + replies
const commentsWithUsers = await Promise.all(
comments.map(async (comment) => ({
...comment,
user: await ctx.db.get(comment.userId),
})),
);
// Without index: O(n) - scans entire table
// With index: O(log n) - instant lookup
await ctx.db
.query("comments")
.withIndex("by_postId", (q) => q.eq("postId", postId))
.collect();What's Next?
Remark is still fresh out of the oven. Future enhancements include:
- Image uploads & emoji picker
- Search
- Analytics: track engagement metrics
- i18n / multi-language support
Ready to integrate this into your own project? Check out the setup guide to get started.
Loading comments...