diff --git a/apps/website/app/blog/[slug]/page.js b/apps/website/app/blog/[slug]/page.js new file mode 100644 index 000000000..4c5a8bb1b --- /dev/null +++ b/apps/website/app/blog/[slug]/page.js @@ -0,0 +1,108 @@ +import { getAllPosts, getPostBySlug } from "@/lib/blogUtils"; +import { mdxComponents } from "@/components/blogComponents"; +import { MDXRemote } from "next-mdx-remote/rsc"; +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +export function generateStaticParams() { + return getAllPosts().map((post) => ({ slug: post.slug })); +} + +export async function generateMetadata({ params }) { + const { slug } = await params; + const post = getPostBySlug({ slug }); + if (!post) return { title: "Post Not Found" }; + + return { + title: post.title, + description: post.description, + openGraph: { + title: post.title, + description: post.description, + type: "article", + publishedTime: post.date, + authors: [post.author], + ...(post.image && { + images: [{ url: post.image }], + }), + }, + }; +} + +function formatDate(dateString) { + if (!dateString) return ""; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export default async function BlogPostPage({ params }) { + const { slug } = await params; + const post = getPostBySlug({ slug }); + if (!post) notFound(); + + return ( +
+
+ + + + + Back to blog + + +
+
+ {formatDate(post.date)} + + {post.author} +
+

+ {post.title} +

+ {post.description && ( +

+ {post.description} +

+ )} +
+ + {post.image && ( +
+ {post.title} +
+ )} + +
+ +
+ +
+
+
+ ); +} diff --git a/apps/website/app/blog/layout.js b/apps/website/app/blog/layout.js new file mode 100644 index 000000000..e6048c01a --- /dev/null +++ b/apps/website/app/blog/layout.js @@ -0,0 +1,30 @@ +import Navbar from "@/components/navbar"; +import Footer from "@/components/footer"; + +export default function BlogLayout({ children }) { + return ( +
+
+
+
+ +
+ +
+
+
+
+
+
+ + {children} + +
+
+
+
+ ); +} diff --git a/apps/website/app/blog/page.js b/apps/website/app/blog/page.js new file mode 100644 index 000000000..98382eb81 --- /dev/null +++ b/apps/website/app/blog/page.js @@ -0,0 +1,79 @@ +import { getAllPosts } from "@/lib/blogUtils"; +import Image from "next/image"; +import Link from "next/link"; + +export const metadata = { + title: "Blog", + description: + "Thoughts on billing infrastructure, usage-based pricing, and building for AI startups.", +}; + +function formatDate(dateString) { + if (!dateString) return ""; + return new Date(dateString).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export default function BlogListingPage() { + const posts = getAllPosts(); + + return ( +
+
+

+ From the + Blog +

+

+ Thoughts on billing infrastructure, usage-based pricing, and building + for AI startups. +

+ + {posts.length === 0 && ( +

+ No posts yet. Check back soon. +

+ )} + +
+ {posts.map((post) => ( + +
+
+ {formatDate(post.date)} + + {post.author} +
+

+ {post.title} +

+ {post.description && ( +

+ {post.description} +

+ )} +
+ {post.image && ( +
+ {post.title} +
+ )} + + ))} +
+
+
+ ); +} diff --git a/apps/website/app/globals.css b/apps/website/app/globals.css index 936b231a2..b3f5ef7c8 100644 --- a/apps/website/app/globals.css +++ b/apps/website/app/globals.css @@ -26,6 +26,7 @@ body { } */ @import "tailwindcss"; +@plugin "@tailwindcss/typography"; /* ========================================================================== RAW TOKENS — single source of truth @@ -236,3 +237,98 @@ body { .grid-overlay::after { right: 20%; } + +/* ========================================================================== + BLOG PROSE OVERRIDES — dark theme, matches landing-page tokens + ========================================================================== */ + +.prose.prose-invert { + --tw-prose-body: rgba(255, 255, 255, 0.6); + --tw-prose-headings: #ffffff; + --tw-prose-lead: rgba(255, 255, 255, 0.7); + --tw-prose-links: #9564ff; + --tw-prose-bold: #ffffff; + --tw-prose-counters: rgba(255, 255, 255, 0.6); + --tw-prose-bullets: rgba(255, 255, 255, 0.4); + --tw-prose-hr: #292929; + --tw-prose-quotes: rgba(255, 255, 255, 0.7); + --tw-prose-quote-borders: #9564ff; + --tw-prose-code: #e0e0e0; + --tw-prose-pre-code: rgba(255, 255, 255, 0.6); + --tw-prose-pre-bg: #141414; + --tw-prose-th-borders: #292929; + --tw-prose-td-borders: #292929; + font-family: var(--font-geist-sans); + font-weight: 300; +} + +.prose.prose-invert p { + font-size: 14px; + line-height: 1.7; + letter-spacing: -0.02em; +} + +@media (min-width: 768px) { + .prose.prose-invert p { + font-size: 16px; + } +} + +.prose.prose-invert h2 { + font-weight: 400; + font-size: 22px; + letter-spacing: -0.02em; + line-height: 1.2; + margin-top: 2.5em; +} + +@media (min-width: 768px) { + .prose.prose-invert h2 { + font-size: 28px; + } +} + +.prose.prose-invert h3 { + font-weight: 400; + font-size: 18px; + letter-spacing: -0.02em; + line-height: 1.25; + margin-top: 2em; +} + +@media (min-width: 768px) { + .prose.prose-invert h3 { + font-size: 22px; + } +} + +.prose.prose-invert strong { + color: #ffffff; + font-weight: 400; +} + +.prose.prose-invert li { + font-size: 14px; + line-height: 1.7; + letter-spacing: -0.02em; +} + +@media (min-width: 768px) { + .prose.prose-invert li { + font-size: 16px; + } +} + +.prose.prose-invert li::marker { + color: rgba(255, 255, 255, 0.4); +} + +.prose.prose-invert a { + text-decoration-color: rgba(149, 100, 255, 0.4); + transition: color 0.3s, text-decoration-color 0.3s; +} + +.prose.prose-invert a:hover { + color: #b08aff; + text-decoration-color: #b08aff; +} diff --git a/apps/website/components/blogComponents.jsx b/apps/website/components/blogComponents.jsx new file mode 100644 index 000000000..19ceab8f5 --- /dev/null +++ b/apps/website/components/blogComponents.jsx @@ -0,0 +1,89 @@ +import Link from "next/link"; + +function BlogHeading({ as: Tag, children, ...props }) { + return ( + + {children} + + ); +} + +export const mdxComponents = { + h1: (props) => , + h2: (props) => , + h3: (props) => , + h4: (props) => , + a: ({ href, children, ...props }) => { + const isExternal = href?.startsWith("http"); + if (isExternal) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + pre: ({ children, ...props }) => ( +
+      {children}
+    
+ ), + code: ({ children, ...props }) => { + const isInline = typeof children === "string"; + if (isInline && !props.className) { + return ( + + {children} + + ); + } + return {children}; + }, + blockquote: ({ children, ...props }) => ( +
+ {children} +
+ ), + hr: (props) =>
, + table: ({ children, ...props }) => ( +
+ + {children} +
+
+ ), + th: ({ children, ...props }) => ( + + {children} + + ), + td: ({ children, ...props }) => ( + + {children} + + ), +}; diff --git a/apps/website/components/footer.jsx b/apps/website/components/footer.jsx index 8d32cc159..ef07d6bd8 100644 --- a/apps/website/components/footer.jsx +++ b/apps/website/components/footer.jsx @@ -17,7 +17,7 @@ const footerColumns = [ links: [ { label: "OUR TEAM", href: "#" }, { label: "OUR VALUES", href: "/privacy" }, - { label: "BLOG", href: "https://useautumn.com/blog" }, + { label: "BLOG", href: "/blog" }, ], }, { diff --git a/apps/website/components/navbar.jsx b/apps/website/components/navbar.jsx index 7cd119407..d1152c0ac 100644 --- a/apps/website/components/navbar.jsx +++ b/apps/website/components/navbar.jsx @@ -26,7 +26,7 @@ import { DashboardIconPixel } from "./dashboard-icon-pixel"; const NAV_LINKS = [ { label: "Docs", href: "https://docs.useautumn.com/welcome", Icon: IconDocs }, - { label: "Blog", href: "https://useautumn.com/blog", Icon: IconBlog }, + { label: "Blog", href: "/blog", Icon: IconBlog }, { label: "Pricing", href: "#pricing", Icon: IconPricing }, { label: "Discord", @@ -375,11 +375,12 @@ export default function Navbar() {
{NAV_LINKS.map((item) => { const isAnchor = item.href.startsWith("#"); + const isExternal = item.href.startsWith("http"); return ( { e.preventDefault(); setMenuOpen(false); diff --git a/apps/website/components/pricing-models.jsx b/apps/website/components/pricing-models.jsx index a5c474e81..bc23290b0 100755 --- a/apps/website/components/pricing-models.jsx +++ b/apps/website/components/pricing-models.jsx @@ -110,6 +110,19 @@ export default function PricingModels() {
+
+

+ Any pricing model.{" "} + Seriously. +

+
+ + Configure in the dashboard or CLI. + {" "} + Rollout to all customers, or create custom plans for your largest customers. +
+
+
diff --git a/apps/website/components/problem.jsx b/apps/website/components/problem.jsx index 7e4aabd51..d1717aeaf 100755 --- a/apps/website/components/problem.jsx +++ b/apps/website/components/problem.jsx @@ -36,16 +36,16 @@ export default function Problem() {

- Your coding agents + Hard to ship, - can't build billing + harder to scale.

Maintaining payment logic, customer balances and feature access - across pricing and product changes is months of work. - Autumn replaces all of this. + across pricing and product changes is months of work and unreliable. + Autumn replaces all the billing code you're building yourself.

diff --git a/apps/website/components/production-scale.jsx b/apps/website/components/production-scale.jsx index d1585e082..8215551bb 100755 --- a/apps/website/components/production-scale.jsx +++ b/apps/website/components/production-scale.jsx @@ -160,11 +160,12 @@ export default function ProductionScale() {

- Autumn handles billions of billing events monthly. Open source core, - self-host ready,{" "} + Autumn is trusted by some of fastest-growing teams. Open source core, + self-host ready.{" "} - designed for teams that
{" "} - can't afford billing downtime. + We'll help you go live quickly +
{" "} + and get back to what's important.

diff --git a/apps/website/content/blog/attach.mdx b/apps/website/content/blog/attach.mdx new file mode 100644 index 000000000..c4b29b573 --- /dev/null +++ b/apps/website/content/blog/attach.mdx @@ -0,0 +1,95 @@ +--- +title: "Architecture to run 100 stripe cases in 1 endpoint" +description: "Handling stripes upgrades, downgrades, schedules, one off and subscriptions all in 1 endpoint." +date: "2025-06-11" +author: "Ayush, Autumn Co-Founder" +slug: "attach" +image: "/images/blog/attach.png" +featured: true +--- + +We’re building an engine to run software pricing models. Something we still struggle to conceptualize is the number of cases that need to be handled. Take some common examples and how you’d do it in Stripe: + +**Scenario** + +**Endpoint** + +Upgrading a subscription + +`POST /subscriptions/:subscription_id` + +Scheduling a downgrade + +`POST /subscription_schedules` + +Creating a checkout session + +`POST /checkout/sessions` + +Creating a new subscription (if card is on file) + +`POST /subscriptions` + +Creating a one off payment + +`POST /invoices/:invoice_id/pay` + +There are more complex scenarios that require updating individual items within a subscription (eg. decrease the price of a metered feature when upgrading). + +Stripe’s low-level design maps each action to a different function. When designing Autumn, we were pretty strong in our belief that all these cases should just be 1 endpoint: `POST /attach` + +Initially we just handled basic cases, so a set of if else statements was enough. As we’ve started handling more cases, the if else spaghetti was becoming a nightmare of bugs. We spent last week rewriting the architecture into 5 steps so we can handle it more logically. + +### Step 1: Input validation and parsing + +This is the body that the `/attach` request takes in, and handles all request related errors. We use Zod to parse the overall schema, then use it’s `refine` method for more granular error throwing (eg if conflicting fields are passed in). + +### Step 2: Building the AttachContext + +Using the inputs, we then make all the DB queries and calculations we need to gather the necessary data. These include: + +1. The product data → it’s prices and features it gives access to + +2. The customer data → Their current product, existing configuration, payment method details + +3. Data from the request body → eg. checkout session params + + +### Step 3: AttachBranch + +This step is a first order categorisation of the `/attach` function based on the request body and context. + +In our previous architecture, interpretability was a mess. We had our branching logic in multiple files and no concrete control / understanding of which path attach runs given the inputs. + +We realised that different branches could be run through the same function. For instance, add ons and new subscription products branches, while separate scenarios, can now both be routed to the same `addProduct` function (see step 5). However they can also be routed to `createCheckout` depending on the config params in the next step. + +![](https://framerusercontent.com/images/YcEatrdJWheMwGEWAyQB7Bw6hqY.png) + +### Step 4: AttachConfig + +These are a set of parameters that control the specific behavior of the product enablement. They have default values determined by the previous stages of the pipeline. + +For instance, we can control: + +- whether an upgrade is prorated, or charged in full + +- whether a new product should create a checkout session, charge a default payment method or just generate an invoice + +- whether any existing meter usage should carry over to the new product, or be reset + + +![](https://framerusercontent.com/images/z4LFqNDl2GGJLBbvNjvfbec7UQ.png) + +### Step 5: AttachFunction + +The last step of the attach call is to determine which function to run, based on all of the prior categorisations. We referred to this in the AttachBranch step. + +For instance, updating a custom product and upgrading to a new product technically can go through the same function, just with different configs (eg. proration behavior), and therefore can be routed to the same function — “UpdateProduct” + +![](https://framerusercontent.com/images/XJzahBCPpL01rgJQOvOD5k1hoc.png) + +Then within each attach function, we make all the relevant calls to Stripe to ensure the scenario occurs successfully. + +And then of course for each of these cases, you need to handle the downstream logic of actually giving the customer what they’ve paid for, which usually involves listening to webhooks, updating some permissions and maybe reseting usage limits. + +We made that into another endpoint (`check`)… but that’s a story for another day. diff --git a/apps/website/content/blog/backendless.mdx b/apps/website/content/blog/backendless.mdx new file mode 100644 index 000000000..1e6b846c5 --- /dev/null +++ b/apps/website/content/blog/backendless.mdx @@ -0,0 +1,76 @@ +--- +title: "We tried to make billing backendless" +description: "We wanted to make payments, billing, usage limits and tracking as easy as possible. We explored a billing integration that required no backend setup, but ultimately gave up." +date: "2025-05-13" +author: "Ayush, Autumn Co-Founder" +slug: "backendless" +image: "/images/blog/backendless.jpeg" +featured: true +--- + +[Read the updated version of this article](/blog/thanks-hn) + +We think handling payments on the frontend is a better developer experience. + +Typically, billing is a backend job and requires webhooks, state syncing, then passing the data to the frontend. We wanted to offer a more "out-of-the-box" experience when handling things like payment links, paywalls and up/downgrade flows, and spent a bunch of time thinking about how we can perform sensitive operations without needing to perform the "round trip" to the backend. + +This is a short write up of our exploration around the problem and why we ultimately are giving up. + +**Part 1: The Publishable Key** + +When we launched, we had a secret key that could be used securely from the backend just as [Stripe](/blog/what-if-stripe-builds-this) does. Many of our first users had actually never set up Stripe before, and immediately told us they wish they could just do it from the frontend. + +Our first solution was to create a "publishable key" which would let developers get payment links and check feature access (eg, does my user have any remaining credits) directly from the frontend, in an unprotected way. These functions alone can't really be abused. + +The initial response was good and people were happy to use it with their initial set up. But we quickly ran into a couple problems: + +1. It only worked with some endpoints (eg, tracking billable usage events had to be done via the secret key) and ended up confusing devs around which endpoints could be used with which keys. + +2. Most software billing flows allow you to automatically purchase something if you've made a purchase before. This automatic purchasing (eg for upgrades) definitely couldn't be done with a public key. + +3. Although it helped people spin up a sample integration fast, it quickly had to be ripped out anyway, so ended up being pretty pointless. + + +This still exists in our docs today and constantly trips people up. Can't wait to get rid of it. + +**Part 2: Server Actions** + +When we launched our Next.js library, we were excited to use server actions. The DX felt magical because users could: + +1. Call them from the frontend like any normal function + +2. The functions run on the server and can access our secret key stored as an ENV variable + +3. No route set up needed, and the request is secure — nice! + + +Unfortunately we soon discovered our approach was flawed. Server actions are public, unauthenticated routes, and our API calls updates resources based on a `customer_id` field (eg. upgrade / downgrade requests, tracking usage for a feature, etc). + +So if you got a hold of someone else’s customer ID, you could make requests to the public server actions as if you were that customer—making this method insecure. + +**Part 3: Server actions + encryption** + +We really really liked the DX of server actions though, and so we had to brainstorm a way to overcome the customer ID being expoed in server action routes. + +A few options came to mind, like using a middleware, or registering an authentication function, but the cleanest and simplest method we thought of was simply encrypting the customer ID: + +Here’s how it worked: + +1. Our Provider was a server component, and so it’d take in a customer ID/user ID (server side), encrypt it using their API key, and pass it to context on the client side (see image below) + +2. We wrap each server action with a client side function which grabs the `encryptedCustomerId` from context and passes it to the server action. These are all exported through a hook — `useAutumn` + +3. Each server action first decrypts the customer ID then calls the Autumn API + + +![](https://framerusercontent.com/images/lr6FGInFULdwXgxESSi825vE.png) + +Essentially, we baked our own layer of auth into the server actions, and this is how our Next.js library works today. I haven't seen this approach by anyone else but essentially lets us fully handle secure payments, upgrades, downgrades, usage-based billing events — all from the frontend. + +We’re still not fully satisfied since this only works with frameworks that support server actions and SPA / vite is kinda making a comeback. It also makes the implementation different across frameworks. + +**The future** + +Ultimately I think we'll reach a point where we give up on this approach, and move towards a more framework agnostic approach. Rather than trying to abandon the backend route setup, we'll just make it easy to do. Take better-auth and how they generate their backend routes in just a couple lines of code — they’ve standardised the backend and frontend installation, and is pretty hard to get wrong. + +Other companies like Polar have a similar approach and are heavily praised for it. We probably don't need to reinvent the wheel (as fun as it is). diff --git a/apps/website/content/blog/component-fail.mdx b/apps/website/content/blog/component-fail.mdx new file mode 100644 index 000000000..2eed4d44e --- /dev/null +++ b/apps/website/content/blog/component-fail.mdx @@ -0,0 +1,151 @@ +--- +title: "React vs shadcn, and trying to be too clever" +description: "Learnings about building a component library for a billing platform for AI companies" +date: "2025-06-30" +author: "Ayush, Autumn Co-Founder" +slug: "component-fail" +image: "/images/blog/component-fail.png" +featured: true +--- + +You know those moments where you have an idea so brilliant you feel like Steve Jobs? Launching our component library felt like that. Novel, high risk, and a radically different approach to what was on the market. If it worked out, it'd be incredible. + +Unfortunately, it didn't. We ended up with a weird mess and had to spend the last week rewriting everything. + +_For context: we're building Autumn, pricing and billing software. Part of our offering is frontend components for things like a pricing table, paywalls etc. You can read more about them_ [_here_](https://docs.useautumn.com/quickstart/shadcn)_._ + +### **V1 as a React library** + +We first attempted this as a standard npm React library: + +``` +import PricingPage from "autumn-js" +``` + +It's biggest issue was customisability**.** There are two ways to make npm components customisable: through props, or a no-code interface in our app. + +1. With props there were too many class variables. For example with the pricing page, each pricing card is composed of many headers, descriptions, buttons and more. And each card itself can have variants (recommended, discounts, annual etc). + +2. A low code interface is a bad experience for devs, who want full customisability and have 10x experience designing with tailwind/css. Even more so with the era of cursor. This is subjective but I’ve never had a good experience with one. + + +_Sidenote:_ If you ever try to make your npm component customisable via tailwind, all the best. That was one of the most frustrating days of our lives. + +Also, If you ever forgot what jsx styles look like, here’s a snippet of what we had to write… + +![](https://framerusercontent.com/images/AfIf7siCuQZQhQWWGgBmQQjoyjw.png) + +We made one measly post on linkedin about it, decided it was unusable and shelved it. + +### **V2 as shadcn/ui components** + +A few weeks later we started looking into it again with shadcn. + +``` +npx shadcn@latest add https://ui.useautumn.com/classic/pricing-table.json +``` + +This immediately felt like a more customizable and fluid DX, but had its own challenges: + +1. Because shadcn components install as a user file, we cannot fully control it via our SDK. For example, if we wanted to trigger a modal/popup to confirm a plan upgrade, the user needs to explicitly pass it into a function. This does take away slightly from that “magical DX”. + + ```jsx + //upgrading to Pro tier +