Shipping Public Project Blogs: A Deep Dive into Database Migrations, Stale Clients, and Hard-Won Lessons
We just rolled out public-facing project blogs, but the journey wasn't without its snags. From Prisma's 'Unsupported' types to stale client caches, here's a candid look at the challenges and crucial lessons learned shipping a new feature to production.
Just yesterday, we pushed a significant update to production: public read-only blog pages for projects. This feature allows project owners to publish updates, insights, and stories directly from their dashboards, making their work more discoverable and shareable. While the final git push felt good, the path to a green health check was a classic developer's gauntlet, complete with database migration gotchas and container quirks.
This post isn't just a changelog; it's a candid look at the development journey, the architectural choices, and, most importantly, the hard-won lessons from bumping up against real-world deployment challenges.
The Feature: Bringing Project Blogs to Life
Our goal was clear: enable users to publish blog posts associated with their projects, accessible via a public URL. This involved several moving parts, from schema updates to new frontend routes and backend logic.
1. Database Schema: The Foundation
First, we needed to extend our data model to support blog functionality.
Projectentity gained two new fields:blogSlug: AString @uniquefor the project's public blog URL (e.g.,/b/my-awesome-project).blogPublished: ABooleanto control the public visibility of the entire project blog.
- A new
BlogPostentity was introduced, linked toProject, with:slug: AString @uniquefor the individual post's URL (e.g.,/b/my-awesome-project/first-post).publishedAt: ADateTimeto track when the post went live.
// prisma/schema.prisma
model Project {
// ... existing fields ...
blogSlug String? @unique
blogPublished Boolean @default(false)
blogPosts BlogPost[]
}
model BlogPost {
id String @id @default(cuid())
projectId String
project Project @relation(fields: [projectId], references: [id])
title String
content String @db.Text // Storing markdown content
slug String? @unique
publishedAt DateTime?
// ... other post metadata like tags, author, etc.
}
2. Public Access & Routing
To make these pages accessible without authentication, we updated our src/middleware.ts to whitelist /b/* routes. This ensures that visitors can browse project blogs and individual posts without needing to log in.
On the frontend, we set up two new public routes using Next.js App Router:
src/app/(public)/b/[slug]/page.tsx: This serves as the project's blog listing page. It features a project header, key stats, and a clean grid of post cards, each displaying the date, relevant tags, and a concise excerpt. The layout includes a light gradient with soft pastel decorative orbs for a pleasant aesthetic.src/app/(public)/b/[slug]/[postSlug]/page.tsx: This is the single blog post view. It includes a back link to the project blog, the post header, tags, and the full markdown content rendered beautifully.
The markdown rendering itself is handled by a client wrapper, src/app/(public)/b/[slug]/[postSlug]/content.tsx, which applies prose styling for optimal readability.
3. Backend Logic: Publishing Workflow
The core publishing logic lives in src/server/trpc/routers/projects.ts. We added:
publishBlogandunpublishBlogmutations: These handle settingblogPublishedon theProjectand automatically generate a uniqueblogSlugif one doesn't exist.blogPosts.update: This mutation was enhanced to setpublishedAtand generate a uniqueslugfor aBlogPostwhen it's published.
Finally, in the dashboard, src/app/(dashboard)/dashboard/projects/[id]/page.tsx, we integrated a publish banner into the BlogTimeline section. This provides a clear "Publish/Unpublish Blog" button and displays the public URL with a convenient copy button once the blog is live.
The Gauntlet: Lessons Learned in Production
While the feature itself came together smoothly, deploying to production unveiled a couple of critical lessons. These are the moments that truly shape our development practices.
Lesson 1: Prisma's db push and the Unsupported Type Trap
My first instinct for applying the new schema changes to production was npx prisma db push. This is often quick and convenient for development environments, but it can be dangerous in production, especially when dealing with non-standard database types.
The Problem:
Our PostgreSQL database includes an embedding vector(1536) column with an HNSW index, used for AI-powered search and recommendations. Since vector is not a natively supported type by Prisma, it's represented as Unsupported("vector(1536)") in schema.prisma. When db push encountered this, it decided to drop the column because it didn't understand how to migrate it. Result? 24 rows lost their embedding values.
The Workaround:
After the db push completed and the new columns were added, I had to manually re-add the embedding column and its HNSW index via raw SQL:
ALTER TABLE "Project" ADD COLUMN "embedding" vector(1536);
CREATE INDEX "Project_embedding_idx" ON "Project" USING hnsw ("embedding" hnsw_vector_ops);
While the column and index were restored, the embedding values for those 24 rows were permanently lost (now NULL).
The Crucial Lesson:
ALWAYS use safe migration scripts for production deployments. We have a custom scripts/db-migrate-safe.sh precisely for this reason. It filters out DROP COLUMN commands for specific sensitive columns (like embedding) before applying migrations. I momentarily forgot this critical safety net. Never trust db push on production with Unsupported types unless you are absolutely certain of its behavior. Understand your ORM's limitations and build safeguards around them.
Lesson 2: Stale Prisma Client in Containerized Deployments
After the database schema was updated and the new application image deployed, the health check immediately failed with projects.blogSlug does not exist. My nerves frayed for a moment, thinking the database migration hadn't fully applied or something was fundamentally wrong.
The Problem:
The database did have the blogSlug column. The issue was that the Prisma client running inside our app Docker container was compiled (or cached) against the old database schema. When it tried to query, it didn't recognize the new column.
The Workaround:
A simple docker compose restart app was all it took. Restarting the container forced the application to reload, which in turn caused the Prisma client to re-initialize and pick up the new database schema. The health check immediately turned green.
The Crucial Lesson: In containerized environments, especially after database schema changes, remember that your application's ORM client might be holding onto a cached or compiled version of the schema. A simple container restart is often necessary to ensure it's working with the latest database state. This is a common "gotcha" that can make you second-guess your database migrations when the real issue is client-side.
Current State & Next Steps
Despite the production hiccups, the new public blog routes are live!
- Production commit
591820eis deployed and healthy. - The
embeddingcolumn is back, though 24 rows need re-embedding. - Public routes
/b/[slug]and/b/[slug]/[postSlug]are accessible.
The next immediate steps involve:
- Re-embedding the lost workflow insights (those 24
NULLrows). - Thoroughly testing the publish flow from the dashboard:
dashboard→project→Blog tab→Publish Blog→visit public URL. - Rotating various production secrets (OpenAI, Anthropic, JWT, encryption key, DB password) for good measure after a deployment.
- E2E testing other critical features to ensure nothing else was inadvertently affected.
Conclusion
Shipping new features is a mix of excitement and challenge. This deployment was a stark reminder of the importance of robust migration strategies and understanding the nuances of how ORMs and containerized applications interact with database schema changes. Every "pain log" entry becomes a valuable lesson, reinforcing best practices and improving our development workflow. Here's to smoother sailing on the next feature!
{
"thingsDone": [
"Added `blogSlug` and `blogPublished` to Project schema",
"Added `slug` and `publishedAt` to BlogPost schema",
"Whitelisted `/b/*` routes in middleware for public access",
"Implemented `publishBlog` and `unpublishBlog` tRPC mutations with auto slug generation",
"Updated `blogPosts.update` to set `publishedAt` and `slug` on publish",
"Created public blog listing page (`/b/[slug]`) with project header, stats, and post cards",
"Created public single blog post page (`/b/[slug]/[postSlug]`) with back link, header, tags, and markdown content",
"Implemented client-side MarkdownRenderer with prose styling",
"Integrated publish banner and public URL display into dashboard project page",
"Deployed all changes to production",
"Restored `embedding vector(1536)` column and HNSW index via raw SQL after `db push` dropped it",
"Restarted Docker container to resolve stale Prisma client schema issue"
],
"pains": [
"Prisma `db push` dropping `Unsupported` type columns (e.g., `vector`) on production, leading to data loss.",
"Health check failing after deploy due to stale Prisma client in Docker container not recognizing new schema columns."
],
"successes": [
"Successfully deployed public blog feature to production.",
"Quickly identified and resolved production database migration and client schema issues.",
"Reinforced critical lessons about safe production migration practices and containerized application behavior."
],
"techStack": [
"Next.js",
"Prisma",
"PostgreSQL",
"tRPC",
"Docker",
"TypeScript",
"Markdown"
]
}