Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/www/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { getAllPosts, getPost, getPostSlugs } from "../../../lib/blog";

interface Props {
params: { slug: string };
}

export async function generateStaticParams() {
const slugs = await getPostSlugs();
return slugs.map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
const posts = await getAllPosts();
const post = posts.find((p) => p.slug === params.slug);
if (!post) return { title: "Post not found" };
return {
title: post.title,
description: post.description,
openGraph: { title: post.title, description: post.description, type: "article" },
};
}

function formatDate(date: string) {
return new Date(`${date}T12:00:00Z`).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}

export default async function BlogPostPage({ params }: Props) {
let post;
try {
post = await getPost(params.slug);
} catch {
notFound();
}

const { content, meta } = post;

return (
<div className="py-16 sm:py-24">
<article className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<Link
href="/blog"
className="inline-flex items-center gap-1.5 text-sm font-semibold text-teal-600 hover:text-teal-700 mb-8"
>
<ArrowLeft className="w-4 h-4" /> All posts
</Link>

<header className="mb-10">
<time className="text-sm text-gray-400">{formatDate(meta.date)}</time>
<h1 className="mt-2 text-3xl sm:text-4xl font-bold font-heading text-gray-900 tracking-tight">
{meta.title}
</h1>
{meta.author && <p className="mt-3 text-sm text-gray-500">By {meta.author}</p>}
</header>

<div className="prose prose-gray prose-headings:font-heading prose-a:text-teal-600 hover:prose-a:text-teal-700 prose-code:before:content-none prose-code:after:content-none max-w-none">
{content}
</div>

<div className="mt-14 rounded-2xl border border-teal-100 bg-teal-50/40 p-8 text-center">
<h2 className="text-xl font-semibold font-heading text-gray-900 mb-2">
Try OpenVPM
</h2>
<p className="text-gray-600 mb-6">
Open-source veterinary practice management. Live demo, no signup.
</p>
<a
href="https://demo.openvpm.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-lg bg-teal-600 px-6 py-3 text-sm font-semibold text-white hover:bg-teal-700 transition-colors"
>
Open the demo
</a>
</div>
</article>
</div>
);
}
65 changes: 65 additions & 0 deletions apps/www/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { getAllPosts } from "../../lib/blog";

export const metadata: Metadata = {
title: "Blog",
description:
"Notes from building OpenVPM — the open-source veterinary practice management system. Practice ownership, open data, and what we're learning along the way.",
};

function formatDate(date: string) {
return new Date(`${date}T12:00:00Z`).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}

export default async function BlogIndexPage() {
const posts = await getAllPosts();

return (
<div className="py-16 sm:py-24">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-14">
<h1 className="text-4xl sm:text-5xl font-bold font-heading text-gray-900 tracking-tight mb-4">
Blog
</h1>
<p className="text-lg text-gray-600 max-w-xl mx-auto">
Notes from building an open-source PIMS, in the open, with the
veterinary community.
</p>
</div>

{posts.length === 0 ? (
<p className="text-center text-gray-500">First post coming soon.</p>
) : (
<div className="space-y-8">
{posts.map((post) => (
<article
key={post.slug}
className="rounded-2xl border border-gray-100 bg-white p-6 sm:p-8 shadow-sm hover:border-teal-200 transition-colors"
>
<time className="text-sm text-gray-400">{formatDate(post.date)}</time>
<h2 className="mt-2 text-xl font-semibold font-heading text-gray-900">
<Link href={`/blog/${post.slug}`} className="hover:text-teal-600 transition-colors">
{post.title}
</Link>
</h2>
<p className="mt-3 text-gray-600 leading-relaxed">{post.description}</p>
<Link
href={`/blog/${post.slug}`}
className="mt-4 inline-flex items-center gap-1.5 text-sm font-semibold text-teal-600 hover:text-teal-700"
>
Read post <ArrowRight className="w-4 h-4" />
</Link>
</article>
))}
</div>
)}
</div>
</div>
);
}
11 changes: 10 additions & 1 deletion apps/www/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
import type { MetadataRoute } from "next";
import { getAllPosts } from "../lib/blog";

const baseUrl = "https://openvpm.com";

export default function sitemap(): MetadataRoute.Sitemap {
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const now = new Date();
const posts = await getAllPosts();
return [
{ url: `${baseUrl}/`, lastModified: now, changeFrequency: "weekly", priority: 1.0 },
{ url: `${baseUrl}/features`, lastModified: now, changeFrequency: "weekly", priority: 0.8 },
{ url: `${baseUrl}/why`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${baseUrl}/install`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${baseUrl}/updates`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
{ url: `${baseUrl}/blog`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
...posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(`${post.date}T12:00:00Z`),
changeFrequency: "monthly" as const,
priority: 0.6,
})),
{ url: `${baseUrl}/feedback`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
];
}
59 changes: 4 additions & 55 deletions apps/www/app/updates/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight, Github } from "lucide-react";
import updatesData from "../../content/updates.json";

export const metadata: Metadata = {
title: "Updates",
Expand All @@ -23,61 +24,9 @@ const tagStyles: Record<Update["tag"], string> = {
Direction: "bg-teal-50 text-teal-700 border-teal-200",
};

// Newest first. Update this list as work ships.
const updates: Update[] = [
{
date: "June 2, 2026",
tag: "Platform",
title: "A calendar you can trust",
body: "Scheduling got a correctness overhaul. Back-to-back appointments no longer trip a false 'conflict' (a real bug — the old check counted touching time slots as overlaps), and the calendar now catches room double-bookings, not just doctor clashes. Appointments can be rescheduled — moved to a new time, doctor, or room — with the same conflict checks applied, so dragging an appointment can't quietly create a clash.",
points: [
"Strict overlap detection across both doctor and room.",
"Reschedule with automatic conflict checking, excluding the appointment being moved.",
],
},
{
date: "June 2, 2026",
tag: "Clinical",
title: "Deeper clinical tooling: vitals, treatment plans, wellness plans",
body: "Closing the gap with the established systems. Per-visit vital signs now have a dedicated tab on the patient record, treatment plans link to a patient's problem list with progress tracking, and practices can offer wellness/membership plans with recurring billing schedules. Plus self-service online booking landed in the client portal.",
},
{
date: "June 1, 2026",
tag: "Direction",
title: "Don't switch — connect. A second PIMS that you own.",
body: "We heard it from every practice we talked to: changing your PIMS is a massive lift, and you won't do it on a whim. So we changed the ask. Instead of rip-and-replace, OpenVPM is becoming the open data layer that connects to the system you already run — giving you a live, exportable copy of your own data in a system you control.",
points: [
"New positioning: run OpenVPM alongside your current PIMS, read-only until you want more.",
"Everything stays behind a documented, exportable API. No lock-in, ever.",
],
},
{
date: "June 1, 2026",
tag: "AI",
title: "Meet the OpenVPM Agent",
body: "OpenVPM now ships with an AI agent that operates on your practice data through real, typed tools — not guesses. It can find clients and patients, pull a clinical summary, list overdue vaccinations for recall, calculate a weight-based drug dose, and book appointments. Every write is gated for review, and the agent is scoped so it can only ever touch your own practice's data.",
points: [
"Bring your own model key. The agent is open source and fully inspectable.",
"Foundation for an agent-operated clinic — the busywork runs itself.",
],
},
{
date: "June 1, 2026",
tag: "Platform",
title: "Public REST API (v1) + scoped API keys",
body: "A versioned, public REST API over your core records — clients, patients, and appointments — authenticated with scoped, per-practice API keys. Built so integrators and AI agents can plug in without touching the internal app, with response shapes frozen independently of the database so integrations never break on an internal change.",
points: [
"Scoped keys (clients:read, patients:read, appointments:write, …) with per-key rate limits.",
"Appointment creation fires real webhooks. See docs/api for the full reference.",
],
},
{
date: "June 1, 2026",
tag: "Clinical",
title: "Dosing calculator + vital signs tracking",
body: "Two clinical-depth features that bring OpenVPM closer to parity with the leading systems. A weight-based drug dosing calculator with a curated formulary, species-specific reference ranges, tablet-split suggestions, and verify-before-prescribing guard rails — plus full vital-signs capture per visit (temperature, heart rate, respiration, weight, body condition, pain score, and more).",
},
];
// Newest first. Entries live in content/updates.json so ship-log updates are a
// data change, not a code change.
const updates = updatesData as Update[];

export default function UpdatesPage() {
return (
Expand Down
3 changes: 3 additions & 0 deletions apps/www/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function MarketingFooter() {
<Link href="/updates" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Updates
</Link>
<Link href="/blog" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Blog
</Link>
<Link href="/feedback" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Feedback
</Link>
Expand Down
1 change: 1 addition & 0 deletions apps/www/components/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const navLinks = [
{ label: "Install", href: "/install" },
{ label: "Why Open Source", href: "/why" },
{ label: "Updates", href: "/updates" },
{ label: "Blog", href: "/blog" },
{ label: "Feedback", href: "/feedback" },
];

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
title: "Why your practice data should belong to you"
description: "Most veterinary practices pay $200-600 a month for software that locks them out of their own records. Here's why we built OpenVPM open source, and what owning your data actually changes."
date: "2026-06-12"
author: "Evan Gauer"
tags: ["open-source", "practice-ownership", "pims"]
---

Ask a practice owner what happens if they want to leave their PIMS. The honest answer is usually: a painful export (if the vendor allows one at all), a conversion project measured in months, and records that arrive mangled on the other side.

That's not a software problem. That's a leverage problem. When switching is that painful, your vendor doesn't have to earn your business every month. The lock-in does it for them.

## The model we think is broken

Commercial veterinary practice management systems typically run $200-600 per month, per practice. For that you get:

- Your own clinical records, stored in a format you can't read
- An export process designed to discourage exporting
- Feature requests that go into a queue you'll never see
- Integrations only with partners your vendor has a deal with

The software itself is often fine. The deal is what's broken.

## What we did instead

We built OpenVPM and released it MIT-licensed. The whole system — patient records, scheduling, EMR with SOAP notes, billing, inventory, controlled substance tracking, a client portal — is public code you can run yourself for free.

Three things matter most in how it's built:

**Everything sits behind an open REST API.** Your clients, patients, and appointments are reachable through documented, versioned endpoints with scoped API keys. Any integrator, any tool, any AI agent can plug in. Nothing about your data is private to us, because we never hold it.

**You don't have to switch to start.** Rip-and-replace is a massive lift, and we stopped asking practices to do it. OpenVPM can run alongside the system you already use, as a live, exportable copy of your own data, read-only until you want more.

**The changelog is public.** Every feature we ship is visible in the open repository. No roadmap theater. You can watch the work happen, file issues, or contribute.

## What ownership changes day to day

Owning your data isn't an abstract principle. It changes practical things:

- A new tool comes out that could help your front desk? Connect it through the API today, no vendor partnership required.
- Want a report your PIMS doesn't offer? Your data is in PostgreSQL. Query it.
- Decide our software isn't for you? Take everything and go. The export is the database.

## Where this goes

We think the next decade of practice software gets rebuilt around open data and agents that do the busywork. OpenVPM already ships with an AI agent that operates on practice data through typed tools: find a patient, pull a clinical summary, list overdue vaccinations, calculate a weight-based dose, book the appointment. Every write gated for review.

That future only works if practices control their own records. So that's where we started.

Try the live demo at [demo.openvpm.com](https://demo.openvpm.com) (one-click logins, no signup), star the repo on [GitHub](https://github.com/evangauer/openvpm), or join the managed-hosting waitlist on the [homepage](/#waitlist).
54 changes: 54 additions & 0 deletions apps/www/content/updates.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
[
{
"date": "June 2, 2026",
"tag": "Platform",
"title": "A calendar you can trust",
"body": "Scheduling got a correctness overhaul. Back-to-back appointments no longer trip a false 'conflict' (a real bug — the old check counted touching time slots as overlaps), and the calendar now catches room double-bookings, not just doctor clashes. Appointments can be rescheduled — moved to a new time, doctor, or room — with the same conflict checks applied, so dragging an appointment can't quietly create a clash.",
"points": [
"Strict overlap detection across both doctor and room.",
"Reschedule with automatic conflict checking, excluding the appointment being moved."
]
},
{
"date": "June 2, 2026",
"tag": "Clinical",
"title": "Deeper clinical tooling: vitals, treatment plans, wellness plans",
"body": "Closing the gap with the established systems. Per-visit vital signs now have a dedicated tab on the patient record, treatment plans link to a patient's problem list with progress tracking, and practices can offer wellness/membership plans with recurring billing schedules. Plus self-service online booking landed in the client portal."
},
{
"date": "June 1, 2026",
"tag": "Direction",
"title": "Don't switch — connect. A second PIMS that you own.",
"body": "We heard it from every practice we talked to: changing your PIMS is a massive lift, and you won't do it on a whim. So we changed the ask. Instead of rip-and-replace, OpenVPM is becoming the open data layer that connects to the system you already run — giving you a live, exportable copy of your own data in a system you control.",
"points": [
"New positioning: run OpenVPM alongside your current PIMS, read-only until you want more.",
"Everything stays behind a documented, exportable API. No lock-in, ever."
]
},
{
"date": "June 1, 2026",
"tag": "AI",
"title": "Meet the OpenVPM Agent",
"body": "OpenVPM now ships with an AI agent that operates on your practice data through real, typed tools — not guesses. It can find clients and patients, pull a clinical summary, list overdue vaccinations for recall, calculate a weight-based drug dose, and book appointments. Every write is gated for review, and the agent is scoped so it can only ever touch your own practice's data.",
"points": [
"Bring your own model key. The agent is open source and fully inspectable.",
"Foundation for an agent-operated clinic — the busywork runs itself."
]
},
{
"date": "June 1, 2026",
"tag": "Platform",
"title": "Public REST API (v1) + scoped API keys",
"body": "A versioned, public REST API over your core records — clients, patients, and appointments — authenticated with scoped, per-practice API keys. Built so integrators and AI agents can plug in without touching the internal app, with response shapes frozen independently of the database so integrations never break on an internal change.",
"points": [
"Scoped keys (clients:read, patients:read, appointments:write, …) with per-key rate limits.",
"Appointment creation fires real webhooks. See docs/api for the full reference."
]
},
{
"date": "June 1, 2026",
"tag": "Clinical",
"title": "Dosing calculator + vital signs tracking",
"body": "Two clinical-depth features that bring OpenVPM closer to parity with the leading systems. A weight-based drug dosing calculator with a curated formulary, species-specific reference ranges, tablet-split suggestions, and verify-before-prescribing guard rails — plus full vital-signs capture per visit (temperature, heart rate, respiration, weight, body condition, pain score, and more)."
}
]
Loading
Loading