Building Yet Another Blog Engine
As is customary, as new techologies emerge, two things will be done:
- Create a blog engine using said techologies
- Create a first blog post describing how you build said engine
Then, the real challenge remains: adding any second post. We’ll take that on another time. But for today, time for the glorious how-I-built-this-blog blog post.
tl;dr - the source code on GitHub
What we’ll end up with
Ooooh fancy.
I’m going to use Convex and TanStack Start/Query/Router to make it. And I’m gonna use AI like crazy, of course.
Here were the major waypoints.
Getting the UI going with v0
The first thing I did was generate a UI I liked. I always like to focus on the user story first, and fill in the data later. As a bonus, it motivates me to finish by providing an exciting vision for the final application.
So I headed over to Vercel’s v0. I find that v0 is a great way to generate an initial user interface for any given problem.
By default, v0 will naturally generate a Next.js app. In particular, I’ve found a lot of TSX will end up inlined inside the page handlers. This makes sense for simple, standalone apps.
But most of the time, I actually want to just take advantage of React’s amazing modularity around components and have v0 generate a more abstract component tree. This is particularly useful when you’re going to use a different React framework and router, like Remix, TanStack or raw Vite.
“Raw” React components work anywhere, which is one of their virtues. I love composability!
Here’s how I coerce v0 to give me what I want:
1. Iterate on the Next.js app first
State your application requirements clearly and don’t worry about the code yet. v0’s ability to show you a preview of your app as you chat with it is its biggest asset. Click around and get excited about the app.
Keep giving v0 feedback until the app looks and feels right. But don’t be surprised if the code structure isn’t very flexible. It may be hard to use in a different way than v0 assumed, or to embed as an incremental feature in an existing app.
2. DON’T tell v0 where you’re actually going to use the UI
I’ve made the mistake several times of saying “I’m just using vite,” or “I’m going to use remix.” The issue is v0 will generate a bunch of code using react-router or something and then it will be unable to generate a preview. Its previews only work with Next.js.
The problem is you really don’t know if this application is what you want yet without the preview. And you’ll need to leave v0 to find out…
3. DO tell v0 to “separate the UX into a component directory”
What does work well is prompting v0 to keep the page handlers very simple, and break all the React components out to a clean set of .tsx files. You want those components just taking props and populated with sample data.
This makes it super easly to grab just those components and drop them anywhere.
And that was the first step with this blog!
I then made a few adjustments to my TanStack-based routes to load the right components on the right pages, but that only took a a few minutes. The props were still using test data, but that gave me a very clear place to inject server data while knowing my UI would stay fixed in a place I was happy with.
Changing to real database-backed blog posts with Cursor
Now, it was time to connect this codebase to real backend data.
Helpfully, v0 had generated some interface types like this for the component props:
export interface BlogPost {
title: string;
slug: string;
date: string;
excerpt: string;
contents: string;
}
I just needed to make equivalent records in my database and I’d be pretty
much set. So I opened the codebase in Cursor,
by far my favorite AI-enhanced editor. I highlighted the BlogPost
type, and prompted:
generate a Convex table definition for blog posts based on this type
Sure enough, Cursor helpfully spit out the following in my convex/schema.ts
file:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
posts: defineTable({
title: v.string(),
slug: v.string(),
date: v.string(),
excerpt: v.string(),
content: v.string(),
}),
});
I added a published
boolean field so I could work on some
posts that didn’t show up on the public site immediately. And
I added an index on that field, anticipating that I would want
an enumeration of all “published” posts on my main page.
After those modifications:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
posts: defineTable({
title: v.string(),
slug: v.string(),
date: v.string(),
excerpt: v.string(),
content: v.string(),
published: v.optional(v.boolean()),
})
.index("by_published", ["published"]),
});
Finally, I asked Cursor to make me a Convex query function to fetch
all published posts, and it created the following in posts.ts
that used the by_published
index:
export const listSummaries = query({
args: {},
handler: async (ctx) => {
// Only return published posts
const posts = await ctx.db
.query("posts")
.withIndex("by_published", (q) => q.eq("published", true))
.order("desc")
.collect();
return posts;
},
});
Using Cursor AI to generate seed data
Although my blog was correctly backed by real database records, it
was still empty. I had nothing in my posts
table!
Again, Cursor to the rescue.
Generate a
convex/testData.ts
with a mutation that inserts a few sample posts into my new posts table.
It obliged:
import { internalMutation } from "./_generated/server";
export const insertTestPosts = internalMutation({
args: {},
handler: async (ctx) => {
const posts = [
{
title: "Getting Started with React and Convex",
slug: "getting-started-with-react-and-convex",
date: "2024-03-15",
excerpt:
"Learn how to build modern web applications using React and Convex for real-time data synchronization.",
content:
"This is a sample blog post about React and Convex integration...",
published: true
},
{
title: "Building a Blog with TanStack Router",
slug: "building-blog-with-tanstack-router",
date: "2024-03-14",
excerpt:
"Discover how to create a performant blog using TanStack Router and modern React patterns.",
content: "Here's how you can build a blog using TanStack Router...",
published: true
},
{
title: "Styling with Tailwind CSS",
slug: "styling-with-tailwind",
date: "2024-03-13",
excerpt:
"A comprehensive guide to styling your React applications using Tailwind CSS.",
content: "Tailwind CSS provides a utility-first approach to styling...",
published: false
},
];
for (const post of posts) {
await ctx.db.insert("posts", post);
}
},
});
Somehow it even knew this blog would contain technical content about Convex and TanStack… that’s cool!
Since this is an internal mutation, it can’t be called from the public internet, or from my app, etc. That’s good, since I don’t want anyone re-injecting these dummy posts into my blog once I’m in production and millions of people are reading.
So I jumped into the Convex dashboard (with $ npx convex dashboard
)
and used the function runner there to call insertTestPosts
:
And immediately had a working blog with some database-backed posts:
Designing the editing experience
While I could just go click records in the Convex dashboard and edit the plain text content there, that didn’t feel like a particularly great UX.
This is what I wanted instead:
- The post bodies v0 had generated were simple UTF-8 text, and the UI displayed them as preformatted prose. I wanted rich formatting, images, etc–I wanted markdown!
- I wanted to be able to edit my markdown and see it immediately re-render using the full, final rendering engine that will ultimately deliver the post.
- I wanted to be able to have an in-progress draft that I could change privately in my editing UI and view diffs against the published version.
I coached cursor through a few more changes and we were off…
Adding the draft field and rendering markdown
I decided to have an optional draft field that contains the markdown that hasn’t been published yet:
export default defineSchema({
posts: defineTable({
title: v.string(),
slug: v.string(),
date: v.string(),
excerpt: v.string(),
content: v.string(),
published: v.optional(v.boolean()),
draft: v.optional(v.string()),
})
.index("by_slug", ["slug"])
.index("by_published", ["published"]),
});
Then, I updated my getBySlug
Convex query function to:
- Render the content as markdown
- Use the draft body when it’s requested and a draft exists
Here’s the new getBySlug
query function:
export const getBySlug = query({
args: { slug: v.string(), draft: v.optional(v.boolean()) },
handler: async (ctx, args): Promise<BlogPost | null> => {
// Get the post record from the database by slug.
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post) return null;
// Create a markdown renderer with syntax highlighting and embedded
// HTML enabled.
const md = markdownit({
html: true,
highlight: function (str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (__) {}
}
return ""; // use external default escaping
},
});
// Draft is true if a draft was requested and the
// draft exists and is different from the content
const isDraft = Boolean(
args.draft && post.draft && post.draft !== post.content
);
// Render the appropriate content.
const html = md.render(
isDraft ? (post.draft ?? post.content) : post.content
);
return {
title: post.title,
slug: post.slug,
date: post.date,
excerpt: post.excerpt,
html,
draft: isDraft,
};
},
});
As you can see, I’m using the markdown-it npm package to convert my markdown into HTML.
Editing the draft markdown in the browser
Now I just needed a markdown editor to place
side by side with my PostView
component that
renders the draft.
I installed the CodeMirror npm package and asked Cursor to make me a MarkdownEditor component based on it.
It generated the following into app/components/MarkdownEditor.tsx
:
import { EditorView, basicSetup } from 'codemirror';
import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { useEffect, useRef } from 'react';
interface MarkdownEditorProps {
initialValue?: string;
onChange?: (value: string) => void;
className?: string;
}
export default function MarkdownEditor({
initialValue = '',
onChange,
className = '',
}: MarkdownEditorProps) {
const editorRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView>();
useEffect(() => {
if (!editorRef.current) return;
const view = new EditorView({
doc: initialValue,
extensions: [
basicSetup,
markdown(),
oneDark,
EditorView.updateListener.of((update) => {
if (update.docChanged && onChange) {
onChange(update.state.doc.toString());
}
}),
],
parent: editorRef.current,
});
viewRef.current = view;
return () => {
view.destroy();
};
}, []);
return <div ref={editorRef} className={className} />;
}
Now I can wire this up to my Convex backend with TanStack Query:
function EditView({ postId }: { postId: string }) {
const secret = useAdminSecret();
const { data: post } = useSuspenseQuery(
convexQuery(api.admin.getRawPost, { slug: postId, secret })
);
const { mutate: mutateDraft } = useMutation({
mutationFn: useConvexMutation(api.admin.editPostDraft),
});
// Subscribe to the rendered draft from the server.
const { data: renderedPost } = useSuspenseQuery(
convexQuery(api.posts.getBySlug, { slug: postId, draft: true })
);
const debouncedUpdateDraft = useCallback(
debounce((newContent: string) => {
if (!post) return;
mutateDraft({
slug: post.slug,
md: newContent,
secret,
});
}, 1000),
[mutatePost, post]
);
// ...
return (
<>
<div className="grid grid-cols-2 gap-4">
<MarkdownEditor
initialValue={post?.draft ?? post?.content ?? ""}
onChange={debouncedUpdateDraft}
className="max-h-screen overflow-y-auto border rounded-md"
/>
//...
The initialValue
editor prop is populated from
post.draft
if it exists, otherwise, it uses
the published post.content
.
Then, the onChange
editor callback is given a function
that will update the draft in the Convex database. I’m
wrapping my Convex mutation with the debounce function
from lodash. That way
the UI waits for me to stop typing for 1000ms (1 second)
before updating the draft. It can be distracting to have
it constantly flickering new rendered content when you’re
in the middle of a sentence or idea.
I didn’t really need to do anything else.
Convex’s reactive database will automatically keep
the renderedPost
up to date whenever
the backend draft is updated. So the PostView
component
with my rendered draft preview will show me the final version.
Here’s what the interaction looks like on this post!
I put all the “privileged” mutations and queries for editing posts
into a module called convex/admin.ts
.
The server functions in that module should
authorize their invocation using a shared
secret that’s set in the environment of my Convex deployment.
To elimiate boilerplate, I used customMutation
from
convex-helpers
to create a wrapper that ensures these mutations check the secret
argument before proceeding.
In convex/admin.ts
:
import { v } from "convex/values";
import {
mutation as baseMutation,
} from "./_generated/server";
import {
customMutation,
} from "convex-helpers/server/customFunctions";
// Admin mutation factory.
const mutation = customMutation(baseMutation, {
args: { secret: v.string() },
input: async (ctx, args) => {
if (args.secret !== process.env.ADMIN_SECRET) {
throw new Error("Invalid secret");
}
return { ctx, args };
},
});
// Edit the draft, using the above mutation wrapper.
// Can only be called if provided the right secret argument!
export const editPostDraft = mutation({
args: {
slug: v.string(),
md: v.string(),
},
handler: async (ctx, args) => {
const post = await ctx.db
.query("posts")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!post) {
throw new Error("Post not found");
}
await ctx.db.patch(post._id, {
draft: args.md,
});
},
});
The useAdminSecret()
hook in the blog’s editing component attempts
to grab the secret value out of local storage in the browser.
So in order to “authenticate” myself, I go into Google Developer tools
and add the appropriate value to the adminSecret
key. It’s low
tech, but it works!
Thanks, AI
That’s all there is to it. In practice, I wrote very little of the code in the project, and mostly just coached v0 and Cursor through the process.
Features I might add soon:
- Add/remove posts (not using the Convex dashboard)
- Edit other post metadata (without using the Convex dashboard)
- Generate slugs programmatically (using AI?)
- Generate post summaries programmaticlly (using AI?)
- Pagination for the home page
- Proper dates for posts, not just text fields…
- “Liking” a post
- RSS/Atom feeds
- A sitemap
- Other pages, like about me, a timeline of my open source projects, etc.
Check out the full source on GitHub.