A blog is one of the simplest ways to attract organic traffic, announce product updates, share expertise, and educate users. Oolvay includes a complete blogging platform so you can publish content without integrating a separate CMS. It includes a Tiptap rich-text editor with autosave, cursor-based infinite scroll, per-post and per-author OG images, JSON-LD structured data, ISR, and role-gated authoring. No configuration is required to use it.
In this guide you’ll learn how the data model and role system are structured, how to write and publish a post from start to finish, how the post settings modal works in detail, how the feed and post pages are built, how OG images and SEO metadata are generated, and how to extend the notification system with a new type.
| Route | File | Description |
|---|---|---|
/blog | app/blog/(index)/page.tsx | Public feed with infinite scroll |
/blog/[slug] | app/blog/[slug]/page.tsx | Single post view |
/blog/author/[username] | app/blog/author/[username]/page.tsx | Author profile and their posts |
/blog/category/[slug] | app/blog/category/[slug]/page.tsx | Posts filtered by category |
/blog/categories | app/blog/categories/page.tsx | Category index (admin-visible) |
/blog/drafts | app/blog/drafts/page.tsx | Draft management (admin only) |
/blog/edit/[id] | app/blog/edit/[id]/page.tsx | Post editor (admin only) |
All routes share app/blog/layout.tsx, which wraps children with <Navbar>, <Footer>, and <LoginModalProvider>.
Defined in db/schemas/blog-schema.ts.
post| Column | Type | Notes |
|---|---|---|
0 / 2,000 characters
id |
text |
| Primary key |
title | text | Required |
logline | text | Optional subtitle, shown below the title on the post page |
slug | text | Unique URL segment. Drafts get a draft- prefix until published |
content | text | Raw Markdown string produced by the Tiptap editor |
excerpt | text | Used for SEO meta description and post card preview |
coverImage | text | URL. Used in the hero and OG image |
published | boolean | false until explicitly published |
notificationType | enum | Controls what notification fires on publish |
authorId | text | FK to user.id. set null on user deletion |
categoryId | text | FK to category.id. set null on category deletion |
createdAt | timestamp | Set on insert |
updatedAt | timestamp | Auto-updated on every write |
| Column | Type | Notes |
|---|---|---|
id | text | Primary key |
name | text | Unique display name |
slug | text | Unique URL segment |
description | text | Optional |
Defined in lib/auth/permissions.ts and lib/blog-utils.ts.
| Role | Can view posts | Can create posts | Can edit others’ posts | Can see others’ drafts |
|---|---|---|---|---|
| User | ✓ | ✗ | ✗ | ✗ |
| Admin | ✓ | ✓ | ✗ | ✗ |
| Super admin | ✓ | ✓ | ✓ | ✓ |
Published posts are public and can be viewed by any visitor. The permissions above apply only to creating, editing, publishing, and managing drafts.
canEditPost(authorId) checks the session at runtime. It returns true for super_admin unconditionally, and for admin only when session.user.id === authorId.
Navigate to /blog. If you hold an admin or super_admin role, an Add
Post button appears in the page header. Click it to create a new draft and
open the editor.
In the editor, fill in the Title and optionally a Logline (subtitle). Both fields include a title-case button that automatically converts the current text to title case.
Write the post body in the Tiptap editor. The toolbar exposes headings, bold, italic, strikethrough, highlight, superscript, subscript, text alignment, and image insertion. Content is stored as Markdown internally.
Click Settings to open the post settings modal. Set the slug, excerpt, category, and cover image here. The slug must be set before the post can be published. It must contain only lowercase letters, numbers, and hyphens.
Click Publish. The editor validates that a non-default title and a valid
slug are present. On success it calls updatePost with published: true
and redirects to /blog/[slug].
The editor autosaves automatically while you type. The status indicator in the bottom-right corner displays an amber dot and “Saving…” while an autosave is in progress, and a green dot and “Saved” when the latest changes have been persisted successfully.
The autosave debounce interval is configured in config/site.ts.
blog: {
pageHeading: `${BRANDNAME} Blog`,
pageSubHeading:
"Dispatches on product, performance, and scaling at the edge.",
autoSaveIntervalMs: 1500,
},Values ending in Ms are measured in milliseconds. One second equals 1,000 milliseconds, so the default value of 1500 causes autosave to trigger 1.5 seconds after the user stops typing or making changes.
Adjust the interval to match your application’s traffic volume, authoring workflow, and risk tolerance. A shorter interval minimizes the amount of work a user could lose after an unexpected browser crash or tab closure, while a longer interval reduces database load and operating costs.
/blog/drafts lists all unpublished posts accessible to the current user. super_admin sees every draft across all authors. admin sees only their own.
Each draft card shows the title, logline or excerpt, and last-edited timestamp. From the card you can do the following.
The Unpublish button appears on /blog/[slug] when the viewer has edit access to that post. It calls updatePost with published: false and redirects to /blog. The post moves back to the drafts list and is no longer publicly accessible.
Categories are managed at /blog/categories (admins and super admins only). Each category has a name, a slug, and an optional description.
Validation rules from lib/validations/blog-schema.ts are as follows.
| Field | Rule |
|---|---|
name | 2–100 characters. Letters, numbers, spaces, hyphens, apostrophes, ampersands only |
slug | 2–50 characters. Lowercase letters, numbers, hyphens only |
description | Max 200 characters |
When a category is deleted, its categoryId is set to null on all associated posts. The posts remain published and accessible.
/blog renders <BlogFeed>, a client component that loads posts in pages of PAGE_SIZE (defined in lib/blog-pagination.ts). It uses an IntersectionObserver on a sentinel element at the bottom of the list to trigger the next page load automatically as the user scrolls. Each page fetches via the getPosts server action with a cursor derived from createdAt.
Duplicate posts are filtered client-side using a Set of post IDs before appending. A "You’ve reached the end." message renders when hasMore is false and at least one post is visible.
The feed revalidates every 3,600 seconds (1 hour) via export const revalidate = 3600 on the index page.
The feed includes a floating Back to Top button that appears after the user scrolls approximately 400 pixels down the page. Clicking it smoothly scrolls the viewport back to the top of the feed.
The component is implemented in components/back-to-top.tsx and uses a scroll listener to toggle visibility based on the current scroll position.
/blog/[slug] renders only published posts. Requesting a slug that does not exist or is unpublished triggers notFound().
The page includes the following.
<MarkdownRenderer> inside a prose containerapplication/ld+json scripts (Article and BreadcrumbList structured data)The page revalidates every 3,600 seconds or 1 hour.
/blog/author/[username] shows the author’s name, bio, and a paginated feed of their published posts. The page calls getPostsByAuthor, which resolves the author by username and fetches their posts with the same cursor-based pagination as the main feed.
If no author is found for the given username, notFound() is called.
The page includes a ProfilePage JSON-LD block and a BreadcrumbList block. If the author has a Twitter handle in their profile, it is included in the sameAs array of the structured data.
Both the post page and the author page generate dynamic OG images using Next.js ImageResponse.
#0a0a0a. If the post has a cover image, it fills the frame behind a left-to-right gradient scrim.webp cover images are excluded due to ImageResponse compatibility#111827 to #1e293b)buildPostMetadata in lib/seo/metadata/build-post-metadata.ts produces title, description, canonical, openGraph, and twitter metadata. The description falls back from excerpt to logline to an empty string.
The OG image URL is set to /blog/[slug]/opengraph-image rather than the cover image directly, so the dynamically generated image is always used for social sharing.
buildAuthorMetadata in lib/seo/metadata/build-author-metadata.ts produces metadata for the author page. It accepts authorName, username, siteUrl, and an optional twitterHandle.
| Page | Schema type |
|---|---|
/blog/[slug] | Article + BreadcrumbList |
/blog/author/[username] | ProfilePage + BreadcrumbList |
Both schemas reference the site’s Organization node via the "@id" field set to "${siteConfig.brand.url}/#organization".
lib/validations/blog-schema.ts exports two Zod schemas.
postSchema (used when publishing a post):
| Field | Rule |
|---|---|
title | 5–100 characters |
logline | Max 200 characters |
excerpt | 10–200 characters |
slug | 2–75 characters. Lowercase letters, numbers, hyphens only |
content | Non-empty |
categoryId | Valid UUID |
coverImage | Valid URL or empty string |
categorySchema (used when creating or editing a category). See Categories for field rules.
The blog index heading and subheading are set in config/site.ts.
blog: {
pageHeading: `${BRANDNAME} Blog`,
pageSubHeading:
"Dispatches on product, performance, and scaling at the edge.",
autoSaveIntervalMs: 1500,
},Both values render directly in the <h1> and <h2> of /blog. Update them to match your product’s voice.
When a post is saved, resolveExcerpt in lib/blog-utils.ts determines the stored excerpt in this order.
excerpt field if non-emptylogline if non-emptycontent after stripping Markdown syntax charactersThis value is used for the SEO meta description and the post card preview text. Setting a hand-written excerpt is always preferred.
The settings modal opens when the author clicks Settings in the editor toolbar. It is also opened automatically when the author clicks Publish without a valid slug set. It is implemented in app/blog/components/post-settings-modal.tsx.
The slug field renders a read-only prefix showing {siteConfig.brand.url}/blog/ followed by an editable input. While typing, the value is coerced in real time: spaces become hyphens, and any character that is not a lowercase letter, number, or hyphen is stripped. The coercion runs on onChange so the field always reflects a valid slug candidate.
The field commits the value to parent state onBlur. If the user clears the field and blurs, it reverts to the last committed valid value. The maximum length is 75 characters, enforced via the maxLength attribute and reflected in a live character counter.
The slug is required before publishing. Clicking Publish inside the modal with an empty slug shows a toast error and aborts.
A <Textarea> pre-populated with the current excerpt. If no excerpt has been written yet, it falls back to the logline as a placeholder value. The maximum length is 200 characters, tracked with a live counter. The excerpt is used as the SEO meta description and the post card preview.
A <Select> populated by getCategories(), which is called once when the editor mounts and passed down as a prop. Categories are sorted alphabetically. Selecting a category sets the categoryId on the post. The field is optional. A post can be published without a category.
A <Select> that controls which notification is dispatched to subscribers when the post is published. The options are populated from NOTIFICATION_TYPE_META in db/types/notification-types.ts. The default value is No Notifications, which stores null and fires nothing on publish.
Available notification types are as follows.
| Value | Label | Default channels |
|---|---|---|
security_alerts | Security Alerts | Email + In-app (locked) |
product_updates | Product Updates | In-app only |
marketing | Marketing | None |
devlog | Devlog | None |
announcements | Announcements | In-app only |
Locked channels cannot be disabled by the subscriber. security_alerts locks both email and in-app delivery. All other types respect the subscriber’s preferences.
The notification fires once, immediately after updatePost sets published: true for the first time. Re-publishing a post that was unpublished and re-published does not re-fire the notification.
Handled by <CoverImageField>, which accepts a URL string and calls onCoverImageChange when updated. The cover image is used in the post hero and in the OG image generation.
The editor supports inline images inside the post body as well as a separate cover image for the post hero and OG image.
Uploaded images are stored in AWS S3 and served through CloudFront. The editor automatically inserts the resulting URL into your Markdown content after the upload completes.
Before image uploads will work, you must deploy and configure the required AWS infrastructure.
The modal has its own Publish button that calls the same handlePublish function as the editor toolbar button. The Update button closes the modal and commits all field changes to autosave state without publishing.
Defined in actions/update-post.ts. Called by autosave, by the publish flow, by <UnpublishButton>, and by <DraftCard>.
It accepts a partial update object. Only fields that are explicitly passed are written. Fields omitted from the input are not touched. The action re-derives excerpt whenever excerpt, logline, or content is included in the input, using resolveExcerpt.
On publish (published: true when the existing row has published: false), updatePost calls dispatchPostNotification, which reads the post’s notificationType and fires the appropriate notification pipeline.
After any write to a published post, the following paths are revalidated.
/blog/blog/[slug]/blog/drafts/blog/author/[username] (if author exists)/blog/category/[old-category-slug] (if category changed)/blog/category/[new-category-slug] (if category changed)updatePost returns { success: true, slug } or { success: false, error }. Duplicate slug errors (Postgres code 23505) are caught and returned as a readable message rather than thrown.
Defined in actions/create-post.ts. Called when an admin clicks Add Post on the blog index. Creates a new row with published: false and a draft- prefixed slug, then redirects to /blog/edit/[id].
Only admin and super_admin roles can call this action. The guardAction wrapper handles session validation before the role check runs.
Defined in actions/get-categories.ts. Returns all categories sorted alphabetically by name. Called once when the Tiptap editor mounts. Returns CategoryOption[] with id, name, slug, and description. Returns an empty array on error rather than throwing.
The notification system is decoupled from the blog. The post stores a single notificationType value. On publish, dispatchPostNotification reads that value and routes to the appropriate notification handler.
Setting notificationType to null (the "No Notifications" option) skips dispatch entirely. This is the correct choice for internal posts, corrections, or minor updates that do not warrant alerting subscribers.
To add a new notification type, add its key to NOTIFICATION_TYPES, its metadata to NOTIFICATION_TYPE_META, and its value to NOTIFICATION_TYPE_VALUES in db/types/notification-types.ts. It will appear automatically in the modal’s dropdown.
export const NOTIFICATION_TYPES = {
// existing entries ...
YOUR_TYPE: "your_type",
} as const
export const NOTIFICATION_TYPE_META = {
// existing entries ...
[NOTIFICATION_TYPES.YOUR_TYPE]: {
label: "Your Label",
description: "What this notification is for",
lockedChannels: [] as NotificationChannel[],
defaults: {
email: false,
inApp: true,
},
},
}You must also add "your_type" to the notificationTypeEnum in your Drizzle enums schema and run bun db:generate && bun db:migrate.