Building an AI Writer SaaS with Next.js and Supabase

Learn how to build an AI Writer SaaS with Next.js and Supabase - from writing SEO optimized blog posts to managing subscriptions and billing.

NB: this blog post is for Makerkit v1!

In this blog post, we will learn how to build an AI Writer SaaS with Next.js and Supabase, more specifically using the Makerkit SaaS Starter Kit.

You can use this tutorial to learn how to build a SaaS product with Next.js and Supabase, or you can use it as a starting point for your own AI Writer SaaS.

If you have Teams License, you can clone the starter kit from the GitHub repository. If you don't, I promise you will have a working AI Writer SaaS by the end of this tutorial, which should take us approximately 2 hours to complete.

What is the Makerkit SaaS Starter Kit?

The Makerkit SaaS Starter Kit is a boilerplate for building SaaS products. It can be used with various technologies, but in this specific blog post we will be using Next.js and Supabase.

What will we build?

The Makerkit SaaS Starter Kit comes with a pre-built UI, so we will be able to focus on building the actual features of our AI Writer SaaS.

We will build the following features:

  1. Writing Blog Posts: Ability to craft AI-generated blog posts using LLMs such as ChatGPT or any OpenAI API compatible LLMs
  2. Managing Blog Posts: Ability to manage the blog posts, including editing, deleting, and publishing them
  3. Subscribing to a Plan: Ability to subscribe to a plan to use the AI Writer SaaS
  4. Subscription Limits: Ability to limit the number of blog posts that can be generated based on the plan

Cloning the Starter Kit

If you have a valid Makerkit license, you can clone the starter kit from the GitHub repository. If you don't have a license, you can purchase one from the Makerkit website.

Using the Makerkit CLI, you can clone the starter kit by running the following command:

npx @makerkit/cli new

Choose Next.js/Supabase as the kit you want to use, and then enter the name of your project. We will use next-supabase-ai-blog-writer, but you can use any name you want. The CLI will automatically install the dependencies and initialize the project.

Now, open the project with your favorite code editor and let's get started!

Running the Project

To run the project, we need to start:

  1. the Supabase local development server
  2. the Next.js server.

Starting the Supabase Local Development Server

To start the Supabase local development server, we need Docker up and running on our machine.

If you don't have Docker installed, you can download it from the official website. You can also install lightweight Docker-compatible alternatives, such as OrbStack or Colima, so you may want to check them out as well.

Let's now run the following command (from terminal or your IDE):

npm run supabase:start

Once started, the terminal will display some information about the server, including the URL and the API key:

> next-supabase-ai-blog-writer@0.10.27 supabase:start
> supabase start --ignore-health-check
Started supabase local development setup.
API URL: http://127.0.0.1:54321
GraphQL URL: http://127.0.0.1:54321/graphql/v1
DB URL: postgresql://postgres:postgres@127.0.0.1:54322/postgres
Studio URL: http://127.0.0.1:54323
Inbucket URL: http://127.0.0.1:54324
JWT secret: super-secret-jwt-token-with-at-least-32-characters-long
anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU

We can now add these to the .env.development file.

.env.development
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
Accessing Supabase Studio

If it's the first time you run Supabase, take some time to explore the incredible Supabase Studio. You can access it by visiting http://localhost:54323.

Starting the Next.js Server

To start the Next.js server, instead, we can run the following command:

npm run dev

The Next.js server will start on port 3000, and you can access it by visiting http://localhost:3000.

🎉 Great - you now have a Makerkit SaaS Starter Kit project up and running!

Customizing the Starter Kit

Now that we have the project up and running, let's customize it to build our AI Writer SaaS. Among various things, we will:

  1. Update the project name and description in the package.json file
  2. Update the branding: logo, colors, fonts, etc.
  3. Update the Landing Page content

Afterward, we will start building the actual features of our AI Writer SaaS.

Updating the Project Name and Description

To update the project name and description, we can simply edit the package.json file:

package.json
{
"name": "next-supabase-ai-blog-writer",
"version": "0.10.27",
"description": "Build an AI Writer SaaS with Next.js and Supabase",
// ...
}

Choose a name and description that best fits your project.

Updating the Branding

To update the branding of our SaaS, we will need to customize the following files:

  1. Logo: we use an SVG in a React component located at src/app/core/ui/Logo/LogoImage.tsx
  2. Colors: we need to update the CSS variables at src/app/global.css, and the Tailwind configuration file located at tailwind.config.js
  3. Favicon: we need to replace the favicon images located at public/assets/images/favicon. We won't be doing this in the tutorial, but you can do it on your own once you have your own logo.

To update the logo, we can simply replace the SVG in the LogoImage.tsx file. I personally use Figma to create the logo, but you can use any tool you want. I then export the SVG and replace the content of the LogoImage.tsx file.

src/app/core/ui/Logo/LogoImage.tsx
const LogoImage: React.FCC<{
className?: string;
}> = ({ className }) => {
return (
<svg
className={`${className ?? 'w-[75px] sm:w-[105px]'}`}
viewBox="0 0 1004 140"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M119.849 138V73.209C119.849 67.551 117.848 62.79 113.846 58.926C109.982 55.062 105.221 53.13 99.5631 53.13C94.0431 53.13 89.2131 55.062 85.0731 58.926C81.0711 62.652 79.0701 67.344 79.0701 73.002V138H60.2331V73.002C60.2331 67.206 58.3011 62.514 54.4371 58.926C50.2971 55.062 45.3981 53.13 39.7401 53.13C34.2201 53.13 29.5281 55.062 25.6641 58.926C21.5241 63.066 19.4541 67.965 19.4541 73.623V138H0.824121V36.984H19.4541V44.643C21.8001 41.745 24.8361 39.33 28.5621 37.398C32.4261 35.466 36.1521 34.5 39.7401 34.5C45.8121 34.5 51.3321 35.742 56.3001 38.226C61.4061 40.572 65.8221 43.884 69.5481 48.162C73.2741 43.884 77.6901 40.572 82.7961 38.226C87.9021 35.742 93.4911 34.5 99.5631 34.5C104.945 34.5 109.982 35.466 114.674 37.398C119.366 39.33 123.506 42.09 127.094 45.678C130.682 49.266 133.442 53.475 135.374 58.305C137.444 62.997 138.479 67.965 138.479 73.209V138H119.849ZM242.941 138V122.268C238.525 127.374 233.419 131.445 227.623 134.481C221.827 137.517 215.686 139.035 209.2 139.035C202.024 139.035 195.262 137.724 188.914 135.102C182.704 132.48 177.184 128.754 172.354 123.924C167.524 119.232 163.729 113.712 160.969 107.364C158.347 100.878 157.036 94.116 157.036 87.078C157.036 80.04 158.347 73.347 160.969 66.999C163.729 60.513 167.524 54.855 172.354 50.025C177.184 45.195 182.704 41.469 188.914 38.847C195.262 36.225 202.024 34.914 209.2 34.914C215.824 34.914 222.034 36.294 227.83 39.054C233.764 41.814 238.801 45.678 242.941 50.646V36.984H261.571V138H242.941ZM209.2 53.337C204.646 53.337 200.23 54.234 195.952 56.028C191.812 57.684 188.224 60.03 185.188 63.066C182.152 66.102 179.737 69.759 177.943 74.037C176.287 78.177 175.459 82.524 175.459 87.078C175.459 91.632 176.287 95.979 177.943 100.119C179.737 104.259 182.152 107.847 185.188 110.883C188.224 113.919 191.812 116.334 195.952 118.128C200.23 119.784 204.646 120.612 209.2 120.612C213.754 120.612 218.101 119.784 222.241 118.128C226.381 116.334 229.969 113.919 233.005 110.883C236.041 107.847 238.387 104.259 240.043 100.119C241.837 95.979 242.734 91.632 242.734 87.078C242.734 82.524 241.837 78.177 240.043 74.037C238.387 69.759 236.041 66.102 233.005 63.066C229.969 60.03 226.381 57.684 222.241 56.028C218.101 54.234 213.754 53.337 209.2 53.337ZM331.895 138L300.431 99.705V138H282.215V0.344996H300.431V59.754L328.583 33.258H355.7L306.641 78.798L355.907 138H331.895ZM380.068 94.116C380.068 97.428 381.241 100.878 383.587 104.466C386.071 108.054 388.9 111.09 392.074 113.574C397.87 118.128 404.632 120.405 412.36 120.405C424.642 120.405 434.647 114.471 442.375 102.603L458.107 111.918C452.725 120.612 446.101 127.305 438.235 131.997C430.369 136.689 421.744 139.035 412.36 139.035C405.322 139.035 398.629 137.724 392.281 135.102C385.933 132.342 380.344 128.547 375.514 123.717C370.684 118.887 366.889 113.298 364.129 106.95C361.507 100.602 360.196 93.909 360.196 86.871C360.196 79.833 361.507 73.14 364.129 66.792C366.889 60.306 370.684 54.648 375.514 49.818C380.206 44.988 385.726 41.262 392.074 38.64C398.56 36.018 405.322 34.707 412.36 34.707C419.398 34.707 426.091 36.018 432.439 38.64C438.925 41.262 444.514 44.988 449.206 49.818C459.418 60.306 464.524 72.45 464.524 86.25C464.524 88.734 464.317 91.356 463.903 94.116H380.068ZM412.36 51.681C406.702 51.681 401.389 52.923 396.421 55.407C391.453 57.891 387.451 61.203 384.415 65.343C381.517 69.345 380.068 73.623 380.068 78.177H444.652C444.652 73.623 443.134 69.345 440.098 65.343C437.2 61.203 433.267 57.891 428.299 55.407C423.331 52.923 418.018 51.681 412.36 51.681ZM529.311 54.372C525.999 52.854 523.032 52.095 520.41 52.095C514.89 52.095 510.336 54.027 506.748 57.891C502.884 62.031 500.952 66.792 500.952 72.174V138H483.15V72.174C483.15 64.722 485.013 57.891 488.739 51.681C492.603 45.471 497.847 40.641 504.471 37.191C509.439 34.845 514.752 33.672 520.41 33.672C524.964 33.672 529.311 34.5 533.451 36.156C537.591 37.812 541.938 40.503 546.492 44.229L529.311 54.372Z"
fill="url(#paint0_linear_2597_2)"
/>
<path
d="M670.37 138H652.361L633.524 91.218L614.687 138H596.678L554.864 34.5H575.15L605.579 110.055L623.381 66.378L610.547 34.5H630.833L661.262 110.055L691.691 34.5H711.77L670.37 138ZM772.496 54.372C769.184 52.854 766.217 52.095 763.595 52.095C758.075 52.095 753.521 54.027 749.933 57.891C746.069 62.031 744.137 66.792 744.137 72.174V138H726.335V72.174C726.335 64.722 728.198 57.891 731.924 51.681C735.788 45.471 741.032 40.641 747.656 37.191C752.624 34.845 757.937 33.672 763.595 33.672C768.149 33.672 772.496 34.5 776.636 36.156C780.776 37.812 785.123 40.503 789.677 44.229L772.496 54.372ZM811.663 19.596C809.041 19.596 806.764 18.699 804.832 16.905C803.038 14.973 802.141 12.696 802.141 10.074C802.141 7.45199 803.038 5.24399 804.832 3.44999C806.764 1.51799 809.041 0.551993 811.663 0.551993C814.285 0.551993 816.493 1.51799 818.287 3.44999C820.219 5.24399 821.185 7.45199 821.185 10.074C821.185 12.696 820.219 14.973 818.287 16.905C816.493 18.699 814.285 19.596 811.663 19.596ZM803.176 138V34.5H820.564V138H803.176ZM873.014 53.13V138H855.419V53.13H839.894V34.5H855.419V0.344996H873.014V34.5H888.539V53.13H873.014ZM918.793 94.116C918.793 97.428 919.966 100.878 922.312 104.466C924.796 108.054 927.625 111.09 930.799 113.574C936.595 118.128 943.357 120.405 951.085 120.405C963.367 120.405 973.372 114.471 981.1 102.603L996.832 111.918C991.45 120.612 984.826 127.305 976.96 131.997C969.094 136.689 960.469 139.035 951.085 139.035C944.047 139.035 937.354 137.724 931.006 135.102C924.658 132.342 919.069 128.547 914.239 123.717C909.409 118.887 905.614 113.298 902.854 106.95C900.232 100.602 898.921 93.909 898.921 86.871C898.921 79.833 900.232 73.14 902.854 66.792C905.614 60.306 909.409 54.648 914.239 49.818C918.931 44.988 924.451 41.262 930.799 38.64C937.285 36.018 944.047 34.707 951.085 34.707C958.123 34.707 964.816 36.018 971.164 38.64C977.65 41.262 983.239 44.988 987.931 49.818C998.143 60.306 1003.25 72.45 1003.25 86.25C1003.25 88.734 1003.04 91.356 1002.63 94.116H918.793ZM951.085 51.681C945.427 51.681 940.114 52.923 935.146 55.407C930.178 57.891 926.176 61.203 923.14 65.343C920.242 69.345 918.793 73.623 918.793 78.177H983.377C983.377 73.623 981.859 69.345 978.823 65.343C975.925 61.203 971.992 57.891 967.024 55.407C962.056 52.923 956.743 51.681 951.085 51.681Z"
fill="url(#paint1_linear_2597_2)"
/>
<defs>
<linearGradient
id="paint0_linear_2597_2"
x1="527.5"
y1="-47.5"
x2="525"
y2="214.5"
gradientUnits="userSpaceOnUse"
>
<stop />
</linearGradient>
<linearGradient
id="paint1_linear_2597_2"
x1="527.5"
y1="-47.5"
x2="525"
y2="214.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#14B8A6" />
</linearGradient>
</defs>
</svg>
);
};
export default LogoImage;

Updating the Colors

To update the colors in a Makerkit project, we need to update the CSS variables in the global.css file, and the Tailwind configuration file.

Updating the CSS Variables

To update the CSS variables, we can simply edit the global.css file.

Since Makerkit uses ShadcnUI for the UI components, we can use the ShadcnUI Themes to choose the colors for our project.

Assuming you want to use another Tailwind CSS color, please refer to this useful snippet on Github so that you can use the relative HSL color in the CSS variables.

The CSS variables in the global.css file should look like this (assuming you're happy with it, otherwise you can change it). I personally chose the teal color from the Tailwind CSS color palette and updated the primary color with the relative hsL color.

src/app/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 224 71.4% 4.1%;
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: 173.4 80.4% 40%;
--primary-foreground: 210 20% 98%;
--secondary: 220 14.3% 95.9%;
--secondary-foreground: 220.9 39.3% 11%;
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: 220 14.3% 95.9%;
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.75rem;
--container-padding: 1.25rem;
}
.dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 173.4 80.4% 40%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground antialiased;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
Updating the Tailwind Configuration

To update the Tailwind configuration, we can simply edit the tailwind.config.js file.

tailwind.config.js
// ..
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
...colors.teal,
},

To update the color in the Tailwind palette, update the custom color primary with colors.teal (or any other color you want). If this color is not part of the Tailwind themes, you will need to provide the palette from 50 to 950.

Updating the Landing Page Content

To update the Landing Page content, we need to update home page route at src/app/(site)/page.tsx.

You will find that most of the placeholder content will need to be replaced - but for simplicity we will only update the title and the description.

<img src="/assets/images/posts/ai-writer-home-page.webp" width={"1920"} height={"1440"} />

Perfect! Our branding is now complete - and we can start building the actual features of our AI Writer SaaS.

This is where the fun begins - let's get started!

Developing the AI Blog Writer SaaS

Once we have customized the branding of our AI Writer SaaS, we can start building the actual features of our AI Blog Writer SaaS.

When signing in to the application, you will be redirected to the Dashboard. The dashboard is the Makerkit's application entry point, and it's where we will build our features.

<img src="/assets/images/posts/ai-writer-dashboard.webp" width={"1920"} height={"1440"} />

Adding a new Feature in our SaaS App

Adding a new feature into a Makerkit SaaS is simple, but we may want to follow a few steps to make sure we're doing it right.

As a general rule of thumb, we should:

  1. i18n: add a new i18n namespace for the feature, so that we can translate the content of the feature and store it into a single file. We can name this file posts.json at public/assets/locales/en/posts.json. You can skip this if you're not planning to translate your app.
  2. Navigation: we should add a new navigation item in the src/navigation.config.tsx file, so that we can navigate to the feature from the main Sidebar
  3. Page: we should add a new page and its components in the src/app/dashboard/[organization]/<feature> folder. Replace <feature> with the name of the feature - for example, posts for the Blog Posts feature.

i18n: Adding a new Namespace

To add a new i18n namespace, we need to create a new JSON file in the public/assets/locales/en folder. We can name this file posts.json.

public/assets/locales/en/posts.json
{}

For now it's empty, but we will add the content later on.

Then, we add the namespace to the settings file at src/i18n/i18n.settings.ts.

src/i18n/i18n.settings.ts
export const defaultI18nNamespaces = [
'common',
'auth',
'organization',
'profile',
'subscription',
'onboarding',
'posts' // <-- add the namespace here
];

To add a new navigation item to the Sidebar, we need to edit the src/navigation.config.tsx file.

Next to the dashboard navigation item, we can add a new navigation item for the posts feature.

src/navigation.config.tsx
import {
DocumentTextIcon,
// ...
} from '@heroicons/react/24/outline';
// ...
{
label: 'posts:postsTabLabel',
path: getPath(organization, 'posts'),
Icon: ({ className }: { className: string }) => {
return <DocumentTextIcon className={className} />;
},
}

The above defines the following navigation item:

  1. Label: the label of the navigation item. We can use the posts:postsTabLabel i18n key.
  2. Path: the path of the navigation item. We can use the getPath function to generate the path of the navigation item. We will add the posts route later on.
  3. Icon: the icon of the navigation item. We can use the DocumentTextIcon icon from Heroicons.

The label is not yet translated, so we need to add the posts:postsTabLabel key to the posts.json file.

public/assets/locales/en/posts.json
{
"postsTabLabel": "Blog Posts"
}

After saving the file, the navigation item should now be translated.

If you navigate to the posts route, you will see that the navigation item is now visible in the Sidebar.

<img src="/assets/images/posts/ai-writer-sidebar.webp" width={"1268"} height={"776"} />

Of course - we haven't yet added any route to our application, so this page is empty and will return a 404 error. Let's fix this by adding the posts route in the next section.

Adding the "Blog Posts" Page

The first feature we will build is the "Blog Posts" page. This page will allow us to create, edit and delete our blog posts. We will be creating this page under the organization scope.

Understanding the Organization Scope

The organization scope is defined at src/app/dashboard/[organization]. If your feature is organization-specific, you should create it under this folder.

The organization parameter is the UUID of the organization. We can use this parameter to fetch the organization data from Supabase, and filter our data based on the organization UUID.

Instead - if your feature is user-specific, you should create it under the src/app/dashboard folder. You can fetch the user ID from the session, and use it to filter data based on the user ID.

Since Blog Posts are organization-wide, we will create the posts feature under the src/app/dashboard/[organization] folder - and fetch them from the Database based on the organization UUID passed as a parameter to the route.

Creating the "Blog Posts" Page

To create the "Blog Posts" page, we need to create a new file at src/app/dashboard/[organization]/posts/page.tsx:

src/app/dashboard/[organization]/posts/page.tsx
import { PlusCircleIcon } from '@heroicons/react/24/outline';
import { withI18n } from '~/i18n/with-i18n';
import { PageBody, PageHeader } from '~/core/ui/Page';
import Trans from '~/core/ui/Trans';
import { Button } from '~/core/ui/Button';
function PostsPage() {
return (
<>
<PageHeader
title={<Trans i18nKey="posts:postsTabLabel" />}
description={<Trans i18nKey="posts:postsTabDescription" />}
>
<Button href={'posts/new'}>
<PlusCircleIcon className="w-5 h-5 mr-2" />
<span><Trans i18nKey="posts:createPostButtonLabel" /></span>
</Button>
</PageHeader>
<PageBody>
</PageBody>
</>
);
}
export default withI18n(PostsPage);

The above is the most basic page we can create. It includes the following components:

  1. PageHeader: the page header, which includes the title and the description of the page
  2. PageBody: the page body, which includes the content of the page
  3. The Button component, which allows us to navigate to the posts/new route (we will create this route later on)
  4. The withI18n HOC, which allows us to translate the content of the page in RSC components

Nice! We now have a basic page, but it's still empty. Let's add some content to it. But what content? Let's take a look at the design of our database to understand what data we need to display.

Database Design

Before we start building the features of our AI Writer SaaS, we need to design the database. This will help us understand what data we need to display in our pages, and how to structure our data.

In this app, we use the fantastic Supabase, an open source PaaS that builds upon Postgres. At its core, Supabase is just Postgres. But it also provides a set of tools that make it easier to build applications on top of Postgres, such as:

  1. Authentication
  2. Realtime Subscriptions from Postgres
  3. Storage
  4. Functions
  5. CLI
  6. Studio UI
  7. and more...

In this section, we focus on the database design:

  1. Tables: we will create the tables we need to store our data
  2. RLS: we will create the RLS policies to secure our data
  3. Plans: we will create the plans to monetize our SaaS and restrict access to our features based on the plan

Creating the Tables

Supabase allows us to define the database schema using the Supabase Studio UI. But we can also define the schema using SQL. In this tutorial, we will use SQL to define the schema.

Creating a migration

The first step to creating the database schema is to create a migration. A migration is a SQL file that defines the schema of our database. We can create a migration by running the following command:

supabase migration new posts

The command above will create a new migration file at migrations. The file name is the timestamp of the migration, followed by the name of the migration, in this case posts.

Next, we will edit the migration file to define the schema of our database.

Creating the Posts schema

Generally speaking, we need to create one table: the posts table. This table will store the blog posts of our users.

This table will connect to the organizations table, since it's the organization that owns the blog posts. Should you want to create a user-specific blog post, you can connect the posts table to the users table instead.

create table posts (
id uuid primary key default gen_random_uuid(),
organization_id bigint references public.organizations on delete cascade not null,
title text not null,
content text not null,
created_at timestamptz default now() not null
);

The above defines the following columns:

  1. id: the UUID of the blog post - this is the primary key of the table and automatically generated
  2. organization_id: the UUID of the organization that owns the blog post
  3. title: the title of the blog post
  4. content: the content of the blog post
  5. created_at: the timestamp of when the blog post was created

This is a basic table - but it's enough for our needs. Should you want to add more columns, you can do so by editing the migration file.

Enable RLS on the Posts table

RLS stands for Row Level Security. It's a feature of Postgres that allows us to restrict access to the rows of a table based on the user that is accessing the table.

Enabling RLS is important to secure our data. Without RLS, any user could access any blog post. With RLS, we can restrict access to the blog posts of the organization that the user belongs to.

To enable RLS, we will add the following lines to the migration file:

alter table posts enable row level security;

Now, all access to the posts table will be restricted - and we need to explicitly grant access to the rows of the table.

Granting access to the Posts table

To grant access to the posts table, we need to create a policy. A policy is a set of rules that define who can access the rows of a table.

The Makerkit migration files already include utilities that allow us to verify a user is a member of an organization using the function current_user_is_member_of_organization: we can reuse this function to create the policy for the posts table.

To allow read access to the posts table, we define the following policy:

create policy "Can read blog posts if user is a member of the organization"
on posts
for select
to authenticated
using (
current_user_is_member_of_organization(organization_id)
);

The above policy allows authenticated users to read the blog posts of the organization they belong to.

To allow write access to the posts table, we define the following policy:

create policy "Can write blog posts if user is a member of the organization"
on posts
for insert
to authenticated
with check (
current_user_is_member_of_organization(organization_id)
);

The above policy allows authenticated users to write the blog posts of the organization they belong to.

To allow update access to the posts table, we define the following policy:

create policy "Can update blog posts if user is a member of the organization"
on posts
for update
to authenticated
using (
current_user_is_member_of_organization(organization_id)
)
with check (
current_user_is_member_of_organization(organization_id)
);

The above policy allows authenticated users to update the blog posts of the organization they belong to.

Finally, to allow delete access to the posts table, we define the following policy:

create policy "Can delete blog posts if user is a member of the organization"
on posts
for delete
to authenticated
using (
current_user_is_member_of_organization(organization_id)
);

The above policy allows authenticated users to delete the blog posts of the organization they belong to.

Yay, we now have a posts table with RLS enabled!

PS: you can define all of them at once using the all keyword instead of select, insert, update and delete - but I prefer to define them separately so that I can understand what each policy does.

Reflecting the changes in the database

When you add a new migration, you need to apply the migration to the database. To do so, we can run the following command:

npm run supabase:db:reset

The above command will apply the migration to the database. If you navigate to the Supabase UI, you will see that the posts table has been created.

Creating the Blog Post creation page

We now have a posts table in our database, but we don't have any page to create blog posts - and therefore we cannot even list them in our UI. Let's fix this by creating the Blog Post creation page - which will unlock the Blog Post listing page.

We will create the Blog Post creation page under the src/app/dashboard/[organization]/posts/new folder - and we will name the file page.tsx.

src/app/dashboard/[organization]/posts/new/page.tsx
import { withI18n } from '~/i18n/with-i18n';
import { PageBody, PageHeader } from '~/core/ui/Page';
import Trans from '~/core/ui/Trans';
function NewPostPage() {
return (
<>
<PageHeader
title={<Trans i18nKey="posts:newPostTabLabel" />}
description={<Trans i18nKey="posts:newPostTabDescription" />}
/>
<PageBody>
</PageBody>
</>
);
}
export default withI18n(NewPostPage);

You can now click the button in the "Blog Posts" page to navigate to the "New Blog Post" page. You should be able to see an empty page, which will be the page we will use to create new blog posts.

PS: feel free to add the translation strings to the posts.json file. We don't show them here for brevity, but you can find them in the source code.

Blog Post Creator Component: Overview

The bulk of this SaaS is, no doubt, the Blog Post creator.

This is the page that allows us to create blog posts using AI - and will be a multi-step process that allows us to:

  1. Topic: define the topic of the Blog Post, which will be used to generate the outline of the Blog Post
  2. Outline: from the topic, the AI will generate an outline of the Blog Post. User will be able to edit the outline.
  3. Bullet Points: from the outline, the AI will generate bullet points. User will be able to edit the bullet points.
  4. Blog Post: from the bullet points, the AI will generate the full Blog Post.

This is a complex process (especially to explain!), but we will break it down into smaller steps to make it easier to understand.

The Blog Post creator will be a rather complex component, so we will split it into multiple components. Each Step of the Blog Post creator will be a component, and the Blog Post creator will be a component that will render the Steps.

To get this done, we will use the following libraries:

  1. React Hook Form: to manage the form state and validation
  2. SWR: to fetch the data from Supabase and our API, which in turn will fetch data from the LLM

Creating the Blog Post wizard component

Let's create the main component at src/app/dashboard/[organization]/posts/new/components/BlogPostWizard.tsx.

This is going to be a very large component, so we will split it into multiple sections to make it easier to understand.

I'll do my best to explain the code, but if you have any questions feel free to reach out - as I understand there will be a lot of code to digest.

NB: as a Teams customer - you'll get access to the full source code of this component, so you can use it as a reference for your own projects.

Defining the Types and the Context

To start, we need to define the types and the context of the component.

The types will allow us to define the types of the data we will be using in the component, and the context will allow us to define some global state that will be shared across the component.

src/app/dashboard/[organization]/posts/new/components/BlogPostWizard.tsx
enum BlogPostCreatorStep {
Details = 0,
Outline = 1,
BulletPoints = 2,
Finish = 3,
}
type OutlineData = {
outline: Array<{
heading: string;
sections: Array<{
value: string;
bulletPoints: Array<{
value: string;
}>;
}>;
}>;
};
const FormContext = createContext<{
step: BlogPostCreatorStep;
setStep: (
step: BlogPostCreatorStep | ((step: BlogPostCreatorStep) => number),
) => void;
}>({
step: BlogPostCreatorStep.Details,
setStep: (
step: BlogPostCreatorStep | ((step: BlogPostCreatorStep) => number),
) => {},
});

The above defines the following:

  1. BlogPostCreatorStep: an enum that defines the steps of the Blog Post creator. We will use this enum to define the current step of the Blog Post creator
  2. OutlineData: the data of the outline. This is the data that will be used to generate the outline of the Blog Post
  3. FormContext: the context of the component. This context will be used to share the current step of the Blog Post creator across the component

As you can see, we will define 4 steps for the Blog Post creator:

  1. Details: the first step of the Blog Post creator, which will allow us to define the topic and the title of the Blog Post
  2. Outline: the second step of the Blog Post creator, which will allow us to define the outline of the Blog Post
  3. BulletPoints: the third step of the Blog Post creator, which will allow us to define the bullet points of the Blog Post
  4. Finish: the final step of the Blog Post creator, which will allow us to generate the Blog Post

Let's continue by defining the BlogPostWizard component.

Defining the BlogPostWizard component

The BlogPostWizard component will be the main component of the Blog Post creator. It will render the current step of the Blog Post creator, and will allow us to navigate between the steps.

src/app/dashboard/[organization]/posts/new/components/BlogPostWizard.tsx
function BlogPostWizard() {
const [step, setStep] = useState(BlogPostCreatorStep.Details);
return (
<FormContext.Provider value={{ step, setStep }}>
<div className={'flex flex-col space-y-16'}>
<BlogPostWizardSteps />
<BlogPostWizardFormContainer />
</div>
</FormContext.Provider>
);
}
function BlogPostWizardFormContainer() {
// we leave this empty for now as it's going to be a very large component
}
function BlogPostWizardSteps() {
const { step } = useFormContext();
const steps = [
'posts:detailsStepLabel',
'posts:outlineStepLabel',
'posts:bulletPointsStepLabel',
'posts:finishStepLabel',
];
return <Stepper currentStep={step} steps={steps} />;
}
function useFormContext() {
return useContext(FormContext);
}
export default BlogPostWizard;

The above defines the following:

  1. BlogPostWizard: the main component of the Blog Post creator. It renders the current step of the Blog Post creator, and allows us to navigate between the steps
  2. BlogPostWizardFormContainer: the container of the Blog Post creator form. This component will be used to render the form of the Blog Post creator
  3. BlogPostWizardSteps: the steps of the Blog Post creator. This component will be used to render the steps of the Blog Post creator
  4. Stepper: the Stepper component from Makerkit. This component will be used to render the steps of the Blog Post creator

As you can see, the BlogPostWizard component renders the BlogPostWizardSteps component and the BlogPostWizardFormContainer component.

This component is the container that gets exported from the BlogPostWizard component. It will be used to render the Blog Post creator in the NewPostPage component.

BlogPostWizardDetailsForm - the first step of the Blog Post creator

The BlogPostWizardDetailsForm component will be the first step of the Blog Post creator. It will allow us to define the topic and the title of the Blog Post.

Calling the API to generate the outline

Before building the component, let's define the API that will be used to generate the outline of the Blog Post.

To do so, we will make an API request using the useApiRequest hook from Makerkit. This hook allows us to make API requests to our API, and will handle the authentication for us.

NB: we will define the API route in the next sections.

function useFetchOutlineFromTopic() {
type Params = {
topic?: string;
title: string;
};
const key = ['posts/outline'];
const request = useApiRequest<
{
data: OutlineData['outline'];
},
Params
>();
const fetcher = (body: Params) => {
return request({
path: `/api/posts/outline`,
body,
});
};
return useMutation(key, (_, { arg }: { arg: Params }) => {
return fetcher(arg);
});
}

Perfect, we can now use the useFetchOutlineFromTopic hook to fetch the outline of the Blog Post.

Defining the BlogPostWizardDetailsForm component

Let's define the BlogPostWizardDetailsForm component. This component will collect the topic and the title of the Blog Post, and will call the API to generate the outline of the Blog Post.

Once generated the outline, it will call the onOutlineFetched callback to pass the outline to the parent component.

The parent component (which we will define later on) will then pass the outline to the BlogPostWizardOutlineForm component, which will allow us to edit the outline.

src/app/dashboard/[organization]/posts/new/components/BlogPostWizard.tsx
function BlogPostWizardDetailsForm({
form,
onOutlineFetched,
}: {
form: ReturnType<
typeof useForm<{
title: string;
topic?: string;
}>
>;
onOutlineFetched: (
outline: OutlineData['outline'],
) => void;
}) {
const fetchOutline = useFetchOutlineFromTopic();
if (fetchOutline.isMutating) {
return (
<LoadingOverlay fullPage={false}>
<Trans i18nKey={'posts:generatingOutline'} />
</LoadingOverlay>
);
}
return (
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(async (values) => {
const response = await fetchOutline.trigger({
title: values.title,
topic: values.topic || undefined,
});
if (!response) {
return;
}
onOutlineFetched(response.data);
})}
>
<Heading type={3}>
<Trans i18nKey={'posts:detailsStepLabel'} />
</Heading>
<div className={'flex flex-col space-y-4'}>
<TextFieldLabel>
<Trans i18nKey={'posts:titleInputLabel'} />
<TextFieldInput
{...form.register('title', {
required: true,
minLength: 10,
maxLength: 200,
})}
/>
<TextFieldHint>
<Trans i18nKey={'posts:titleInputHint'} />
</TextFieldHint>
</TextFieldLabel>
<TextFieldLabel>
<Trans i18nKey={'posts:topicInputLabel'} /> (
<Trans i18nKey={'posts:optional'} />)
<Textarea
{...form.register('topic', {
required: false,
minLength: 10,
maxLength: 500,
})}
/>
<TextFieldHint>
<Trans i18nKey={'posts:topicInputHint'} />
</TextFieldHint>
</TextFieldLabel>
<div>
<NextStepButton />
</div>
</div>
</form>
);
}
function NextStepButton(props: React.PropsWithChildren) {
return (
<Button>
{props.children ?? <Trans i18nKey={'posts:nextStepButtonLabel'} />}
</Button>
);
}
function PreviousStepButton(props: React.PropsWithChildren) {
const { setStep } = useFormContext();
return (
<Button
variant={'outline'}
type={'button'}
onClick={() => {
setStep((step) => step - 1);
}}
>
{props.children ?? <Trans i18nKey={'posts:backStepButtonLabel'} />}
</Button>
);
}

In addition to the component, we defined two helper components:

  1. NextStepButton: this component will render the "Next Step" button. It will be used to navigate to the next step of the Blog Post creator
  2. PreviousStepButton: this component will render the "Previous Step" button. It will be used to navigate to the previous step of the Blog Post creator

Thanks to the useFormContext hook, we can access the step and setStep properties of the context. This will allow us to navigate between the steps of the Blog Post creator.

BlogPostWizardOutlineForm - the second step of the Blog Post creator

We will now define the BlogPostWizardOutlineForm component. This component will allow us to edit the outline of the Blog Post.

The BlogPostWizardOutlineForm component accepts the outline as a prop, and will allow us to edit the outline and the bullet points (which will be generated from the outline):

It accepts the following props:

  1. form: the form of the component
  2. title: the title of the Blog Post
  3. heading: the heading of the step
  4. displayBulletPoints: whether to display the bullet points or not (this is true in the second step, and false in the first step)
  5. onFinish: the callback that will be called when the user clicks the "Finish" button

The BlogPostWizardOutlineForm is used in both step 2 and step 3: the only difference is that in step 2 we don't display the bullet points, while in step 3 we display the bullet points. Basically, we split fetching the outline and fetching the bullet points into two steps - so the user has less things to edit at once.

function BlogPostWizardOutlineForm({
form,
title,
heading,
displayBulletPoints = false,
onSubmit,
}: {
title: string;
heading?: string | React.ReactNode;
form: ReturnType<typeof useForm<OutlineData>>;
onSubmit: (data: OutlineData) => void;
displayBulletPoints?: boolean;
}) {
const fetchBulletPoints = useFetchBulletPointsFromOutline();
const fieldArray = useFieldArray({
control: form.control,
name: 'outline',
});
const { t } = useTranslation('posts');
function BulletPointsRenderer({
index,
subHeadingIndex,
}: {
index: number;
subHeadingIndex: number;
}) {
const bulletPointsFieldArray = useFieldArray({
control: form.control,
name: `outline.${index}.sections.${subHeadingIndex}.bulletPoints`,
});
return bulletPointsFieldArray.fields.map((field, bulletPointIndex) => {
const control = form.register(
`outline.${index}.sections.${subHeadingIndex}.bulletPoints.${bulletPointIndex}.value`,
{
required: true,
},
);
return (
<div className={'pl-4'} key={field.id}>
<div className={'flex flex-col space-y-1.5'}>
<div className="relative group flex justify-between items-center space-x-2">
<span className={'text-sm'}>•</span>
<input
{...control}
required
placeholder={
t('bulletPointPlaceholder')
}
className={
'bg-transparent outline-none focus:ring-2 focus:ring-primary w-full text-sm p-0.5 hover:bg-gray-50 dark:hover:bg-dark-900 invalid:ring-red-500'
}
/>
<div className={'hidden group-hover:block'}>
<ItemActions
onAdd={() =>
bulletPointsFieldArray.insert(
bulletPointIndex + 1,
{ value: '' },
{
shouldFocus: true,
},
)
}
onRemove={() =>
bulletPointsFieldArray.remove(bulletPointIndex)
}
/>
</div>
</div>
</div>
</div>
);
});
}
function SubHeadingRenderer({ index }: { index: number }) {
const headingFieldArray = useFieldArray({
control: form.control,
name: `outline.${index}.sections`,
});
return headingFieldArray.fields.map((field, headingIndex) => {
const control = form.register(
`outline.${index}.sections.${headingIndex}.value`,
{
required: true,
},
);
return (
<div className={'pl-4 py-1'} key={field.id}>
<div className={'flex flex-col space-y-2.5'}>
<div
className={
'relative group flex justify-between items-center space-x-2'
}
>
<span className={'font-medium'}>{headingIndex + 1}.</span>
<input
{...control}
required
placeholder={t('subHeadingPlaceholder')}
className={
'bg-transparent outline-none focus:ring-2 focus:ring-primary w-full text-base p-1 hover:bg-gray-50 dark:hover:bg-dark-900 invalid:ring-red-500'
}
/>
<div className={'hidden group-hover:block'}>
<ItemActions
onAdd={() =>
headingFieldArray.insert(
headingIndex + 1,
{ value: '', bulletPoints: [] },
{
shouldFocus: true,
},
)
}
onRemove={() => headingFieldArray.remove(headingIndex)}
/>
</div>
</div>
<If condition={displayBulletPoints}>
<BulletPointsRenderer
subHeadingIndex={headingIndex}
index={index}
/>
<div>
<Button
size={'small'}
variant={'link'}
onClick={() => {
headingFieldArray.update(headingIndex, {
...field,
bulletPoints: [
...(field.bulletPoints || []),
{
value: '',
},
],
});
}}
>
<PlusCircleIcon className={'w-4 mr-2'} />
<span>
<Trans i18nKey={'posts:addBulletPointButtonLabel'} />
</span>
</Button>
</div>
</If>
</div>
</div>
);
});
}
if (fetchBulletPoints.isMutating) {
return (
<LoadingOverlay fullPage={false}>
<Trans i18nKey={'posts:generatingBulletPoints'} />
</LoadingOverlay>
);
}
return (
<form
className={'flex flex-col space-y-4'}
onSubmit={form.handleSubmit(async ({ outline }) => {
if (!displayBulletPoints) {
const response = await fetchBulletPoints.trigger({ outline, title });
if (!response) {
return;
}
return onSubmit({ outline: response.data });
}
onSubmit({ outline });
})}
>
<Heading type={3}>{heading}</Heading>
<div className={'flex flex-col space-y-8'}>
<div className={'flex flex-col space-y-4'}>
<SectionPlaceholder>
<Trans i18nKey={'posts:outlineIntroductionPlaceholder'} />
</SectionPlaceholder>
{fieldArray.fields.map((field, index) => {
const control = form.register(`outline.${index}.heading`, {
required: true,
});
return (
<div key={field.id} className={'flex flex-col space-y-1'}>
<div
className={
'flex space-x-2 justify-between w-full group items-center'
}
>
<span className={'font-medium text-xl'}>{index + 1}.</span>
<input
{...control}
required
placeholder={t('addHeadingButtonLabel')}
className={
'text-xl w-full font-semibold bg-transparent outline-none focus:ring-2 focus:ring-primary p-1 hover:bg-gray-50 dark:hover:bg-dark-900 invalid:ring-red-500'
}
/>
<div className={'hidden group-hover:block'}>
<ItemActions
onAdd={() => {
fieldArray.insert(
index + 1,
{ heading: '', sections: [] },
{
shouldFocus: true,
},
);
}}
onRemove={() => fieldArray.remove(index)}
/>
</div>
</div>
<div
className={
'flex flex-col divide-y divide-gray-50 dark:divide-dark-900'
}
>
<SubHeadingRenderer index={index} />
<div className={'flex space-x-2.5'}>
<Button
size={'small'}
variant={'link'}
onClick={() => {
fieldArray.update(index, {
...field,
sections: [
...field.sections,
{
value: '',
bulletPoints: [],
},
],
});
}}
>
<PlusCircleIcon className={'w-4 mr-2'} />
<span>
<Trans i18nKey={'posts:addSubHeadingButtonLabel'} />
</span>
</Button>
</div>
</div>
</div>
);
})}
<SectionPlaceholder>
<Trans i18nKey={'posts:outlineConclusionPlaceholder'} />
</SectionPlaceholder>
</div>
<div className={'flex space-x-2'}>
<PreviousStepButton />
<NextStepButton />
</div>
</div>
</form>
);
}
function ItemActions(props: { onRemove: () => void; onAdd: () => void }) {
return (
<div className={'flex space-x-2.5 items-center'}>
<Tooltip>
<TooltipTrigger asChild>
<button type={'button'} onClick={props.onRemove}>
<XMarkIcon className={'w-5'} />
</button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey={'posts:removeSectionButtonLabel'} />
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button type={'button'} onClick={props.onAdd}>
<PlusCircleIcon className={'w-5'} />
</button>
</TooltipTrigger>
<TooltipContent>
<Trans i18nKey={'posts:addSectionButtonLabel'} />
</TooltipContent>
</Tooltip>
</div>
);
}

BlogPostWizardFinishForm - the last step of the Blog Post creator

The last step of the Blog Post creator is the BlogPostWizardFinishForm component. We simply display a loading overlay to indicate that the Blog Post is being generated.

function BlogPostWizardFinishForm() {
return (
<div className={'flex flex-col space-y-4'}>
<CreatingPostLoadingOverlay />
</div>
);
}
function CreatingPostLoadingOverlay() {
return (
<LoadingOverlay fullPage={false}>
<Trans i18nKey={'posts:generatingPost'} />
</LoadingOverlay>
);
}

Great, in the next step, we put all together to create the BlogPostWizardFormContainer component.

Defining the BlogPostWizardFormContainer component

The BlogPostWizardFormContainer component will be the container of the Blog Post creator form. It will render the form of the Blog Post creator, and will allow us to navigate between the steps.

NB: the components in this section are not yet implemented, so we will leave them empty for now.

src/app/dashboard/[organization]/posts/new/components/BlogPostWizard.tsx
function BlogPostWizardFormContainer() {
const { step, setStep } = useFormContext();
const [, startTransition] = useTransition();
const detailsForm = useForm<{
title: string;
topic?: string;
}>({
defaultValues: {
title: '',
topic: undefined,
},
});
const outlineForm = useForm<OutlineData>({
defaultValues: {
outline: [],
},
});
const onCreatePostRequested = useCallback(() => {
startTransition(async () => {
await generatePost({
title: detailsForm.getValues('title'),
outline: outlineForm.getValues('outline'),
});
});
}, [detailsForm, outlineForm]);
const title = detailsForm.getValues('title');
switch (step) {
case BlogPostCreatorStep.Details:
return (
<BlogPostWizardDetailsForm
form={detailsForm}
onOutlineFetched={(data) => {
outlineForm.setValue('outline', data);
setStep((currentStep) => currentStep + 1);
}}
/>
);
case BlogPostCreatorStep.Outline:
return (
<BlogPostWizardOutlineForm
title={title}
form={outlineForm}
heading={<Trans i18nKey={'posts:outlineStepLabel'} />}
onFinish={(data) => {
outlineForm.setValue('outline', data.outline);
setStep((currentStep) => currentStep + 1);
}}
/>
);
case BlogPostCreatorStep.BulletPoints:
return (
<BlogPostWizardOutlineForm
title={title}
form={outlineForm}
heading={`Bullet Points`}
displayBulletPoints
onFinish={(data) => {
outlineForm.setValue('outline', data.outline);
setStep((currentStep) => currentStep + 1);
onCreatePostRequested();
}}
/>
);
case BlogPostCreatorStep.Finish:
return <BlogPostWizardFinishForm />;
default:
return null;
}
}

In this component, we display the right form based on the current step of the Blog Post creator.

The only missing part is the Server Action used to generate the Blog Post. We will define this in the next section.

Creating the API to fetch the data from the LLM

To fetch the data from the LLM, we need to create the required services to handle the API calls to the LLM and return the data to the UI.

This involves two main tasks:

  1. Requesting data from the LLM: we need to create a service that allows us to generate the required API calls to the OpenAI-compliant LLM and return the data to our service
  2. Exposing an API to the UI: we need to create an API that allows us to fetch the data from the LLM and return it to the UI

Requesting data from the LLM

To request data from the LLM, we need to create a service that allows us to generate the required API calls to the OpenAI-compliant LLM and return the data to our service.

We will call this service PostsLlmService, and we will define it at src/lib/ai/posts-llm.service.ts.

Exposing an API to the UI

To expose an API to the UI, we need to create an API that allows us to fetch the data from the LLM and return it to the UI.

We will expose three API endpoints:

  1. Generate Outline: this endpoint will generate the outline of the Blog Post from the topic at POST /api/posts/outline
  2. Generate Bullet Points: this endpoint will generate the bullet points of the Blog Post from the outline at POST /api/posts/bullet-points
  3. Generate Blog Post: this route will generate the full Blog Post from the bullet points. We will use a Server Action to generate the Blog Post, so we don't really need to create an API endpoint for this.

To generate the Blog Post, we will use the PostsLlmService service. This service will be used to generate the Blog Post from the title, the outline and the bullet points.

Defining the PostsLlmService service

The PostsLlmService service will be used to generate the Blog Post from the title, the outline and the bullet points. We define this service at src/lib/ai/posts-llm.service.ts.

src/lib/ai/posts-llm.service.ts
import { z } from 'zod';
import getOpenAIClient from '~/core/openai-client';
import getLogger from '~/core/logger';
import configuration from '~/configuration';
import OpenAI from 'openai';
export class PostsLlmService {
private readonly client = getOpenAIClient();
private readonly logger = getLogger();
private readonly debug = !configuration.production;
async generateOutline(params: { title: string; topic?: string }) {
this.logger.info(params, `Generating outline...`);
const response = await this.client.chat.completions.create({
...this.getBaseParams(),
messages: [
{
role: 'user',
content: `As an expert content writer, you are tasked with writing an outline for a blog post titled "${params.title}"; ${params.topic ? 'Please follow the instructions as outlined: "${params.topic}".' : '.'} The outline should include an H2 heading and at least 3 H3 headings for all the sections of the blog post. Return a JSON object using the schema such as: "{"outline": [{ "heading": string, "sections": Array<string> }]}". The "heading" is an H2 and the "sections" are H3s. Please never include introduction and conclusion in the outline. Create at least 3 sections and at least 2 subsections for each section.`,
},
],
});
if (this.debug) {
this.logger.info(
`Response from generateOutline`,
JSON.stringify(response, null, 2),
);
}
const data = response.choices[0].message.content ?? '';
const tokens = this.getTokenCountFromResponse(response);
const json = z
.object({
outline: z.array(
z.object({
heading: z.string().min(1),
sections: z.array(z.string().min(1)),
}),
),
})
.safeParse(JSON.parse(data));
if (!json.success) {
throw new Error(
`Failed to parse response: ${JSON.stringify(json.error)}`,
);
}
this.logger.info(`Outline successfully generated.`);
if (this.debug) {
this.logger.info(`Outline`, JSON.stringify(json.data, null, 2));
}
const content = json.data.outline.map((section) => {
const sections = section.sections.map((value) => ({ value }));
return {
heading: section.heading,
sections,
};
});
return {
content,
tokens,
};
}
async generateBulletPoints(
title: string,
outline: Array<{
heading: string;
sections: Array<{
value: string;
}>;
}>,
) {
let tokens = 0;
const requests = outline.map(async (item) => {
const sections = item.sections.map(async (section) => {
const response = await this.client.chat.completions.create({
...this.getBaseParams(),
messages: [
{
role: 'system',
content: `Return a JSON object using the schema: { bulletPoints: Array<string> }`,
},
{
role: 'user',
content: `As an expert content writer, you are tasked with creating a list of talking points of the blog post "${title}" for the following heading: ${section.value}. The talking points should be short and concise. You must provide between 2 and 5 talking points.`,
},
],
});
if (this.debug) {
this.logger.info(
`Response from generateBulletPoints: ${JSON.stringify(response, null, 2)}`,
);
}
const data = response.choices[0].message.content ?? '';
tokens += this.getTokenCountFromResponse(response);
const json = z
.object({
bulletPoints: z.array(z.string().min(1)),
})
.safeParse(JSON.parse(data));
if (!json.success) {
throw new Error(
`Failed to parse response: ${JSON.stringify(json.error)}`,
);
}
if (this.debug) {
this.logger.info(`Bullet points: ${JSON.stringify(json.data, null, 2)}`);
}
const bulletPoints = json.data.bulletPoints.map((value) => ({ value }));
return {
value: section.value,
bulletPoints,
};
});
return {
heading: item.heading,
sections: await Promise.all(sections),
};
});
const content = await Promise.all(requests);
return {
content,
tokens,
};
}
async generatePost(
title: string,
params: Array<{
heading: string;
sections: Array<{
value: string;
bulletPoints: Array<{
value: string;
}>;
}>;
}>,
) {
try {
const responses = await Promise.all([
this.generateIntroduction(title),
this.generateBody(title, params),
this.generateConclusion(title),
]);
const tokens = responses.reduce((acc, response) => {
return acc + response.tokens;
}, 0)
const introduction = responses[0].content;
const body = responses[1].content;
const conclusion = responses[2].content;
const content = [introduction, body, conclusion].join('\n\n');
if (this.debug) {
this.logger.info(`Generated post: ${content}`);
}
return {
content,
tokens,
success: true,
};
} catch (error) {
this.logger.error(`Failed to generate post: ${error}`);
return {
content: '',
tokens: 0,
success: false,
};
}
}
private async generateBody(
title: string,
params: Array<{
heading: string;
sections: Array<{
value: string;
bulletPoints: Array<{
value: string;
}>;
}>;
}>,
) {
const requests = params.map(async (section) => {
const paragraphsRequests = section.sections.map(async (section) => {
return this.generateParagraph(
title,
section.value,
section.bulletPoints,
);
});
const paragraphs = await Promise.all(paragraphsRequests);
const paragraphText = paragraphs.map((paragraph) => paragraph.content).join('\n\n').trim();
const tokens = paragraphs.reduce((acc, paragraph) => paragraph.tokens + acc, 0);
const content = `## ${section.heading}\n\n${paragraphText}`;
return {
content,
tokens,
};
});
const data = await Promise.all(requests);
const content = data.map((item) => item.content).join('\n\n').trim();
const tokens = data.reduce((acc, item) => item.tokens + acc, 0);
return {
content,
tokens,
};
}
private async generateParagraph(
title: string,
heading: string,
bulletPoints: Array<{
value: string;
}>,
) {
// remove JSON format from params
const { response_format, ...params } = this.getBaseParams();
const response = await this.client.chat.completions.create({
...params,
messages: [
{
role: 'user',
content: `
As an expert content writer, you are tasked with creating a paragraph for the blog post "${title}" (h3) for the following heading: ${JSON.stringify(heading)}.
Expand the following bullet points into well-written paragraphs: ${JSON.stringify(bulletPoints)}.
${this.getWritingRulesPrompt()}
- Do not write the title or heading in the section. Only the body content.
Content:
`.trim(),
},
],
});
const data = response.choices[0].message.content?.trim();
const tokens = this.getTokenCountFromResponse(response);
if (this.debug) {
this.logger.info(`Paragraph: ${data}`);
}
const content = `### ${heading}\n\n${data}`;
return {
content,
tokens,
};
}
private async generateIntroduction(title: string) {
const { response_format, ...params } = this.getBaseParams();
const response = await this.client.chat.completions.create({
...params,
messages: [
{
role: 'system',
content: `Return a string using Markdown syntax`,
},
{
role: 'user',
content: `As an expert content writer, you are tasked with writing an introduction for a blog post titled "${title}". The introduction should be 1-2 paragraphs long.
${this.getWritingRulesPrompt()}
- Only write the body content and nothing else.
- Do not use any headings.
Introduction:`,
},
],
});
if (this.debug) {
this.logger.info(
`Response from generateIntroduction`,
JSON.stringify(response, null, 2),
);
}
const content = response.choices[0].message.content?.trim();
const tokens = this.getTokenCountFromResponse(response);
return {
content,
tokens,
};
}
private async generateConclusion(title: string) {
const { response_format, ...params } = this.getBaseParams();
const response = await this.client.chat.completions.create({
...params,
messages: [
{
role: 'system',
content: `Return a string using Markdown syntax`,
},
{
role: 'user',
content: `As an expert content writer, you are tasked with writing a conclusion for a blog post titled "${title}". The conclusion should be 1-2 paragraphs long.
${this.getWritingRulesPrompt()}
- Only write the body content and nothing else
- Use an h2 heading that is meaningful and relevant to the blog post, not generic like "Conclusion"
Conclusion:`,
},
],
});
if (this.debug) {
this.logger.info(
`Response from generateConclusion`,
JSON.stringify(response, null, 2),
);
}
const content = response.choices[0].message.content?.trim();
const tokens = this.getTokenCountFromResponse(response);
return {
content,
tokens,
};
}
private getBaseParams() {
return {
model: this.getModelName(),
response_format: {
type: 'json_object' as const,
},
temperature: 0.8,
max_tokens: 1600,
};
}
private getModelName() {
const llm = process.env.LLM_MODEL_NAME;
return llm || 'gpt-3.5-turbo-1106';
}
private getWritingRulesPrompt() {
return `
Writing rules that you must follow:
- You are a world-class expert content writer
- Output text using valid Markdown
- Write professional text while balancing simplicity and complexity in your language and sentence structures.
- Repeat the main keywords and phrases often for SEO
- Use h4 headings for subheadings to split up the content into smaller sections
- Avoid bullet points and numbered lists
- The section must be below 200 words`.trim();
}
private getTokenCountFromResponse(response: OpenAI.ChatCompletion) {
return response.usage?.total_tokens ?? 0;
}
}

Let's drill down into what we've done here. The class exposes three methods:

  1. generateOutline: this method generates the outline of the Blog Post from the title and the topic
  2. generateBulletPoints: this method generates the bullet points of the Blog Post from the outline
  3. generatePost: this method generates the full Blog Post from the title, the outline and the bullet points

JSON mode

We make use of JSON mode (using the model gpt-3.5-turbo-1106) to ensure that the data is returned in a JSON format. This allows us to easily parse the data and use it in our application.

NB, if you want to use a non-OpenAI LLM: ensure JSON mode is supported. If it's not supported, you will need to parse the data manually and likely tweak the prompts to make sure they generate valid JSON.

💡 Anyscale provides API endpoints with open-source LLMs (such as Mixtral-8x7B-Instruct-v0.1) that support JSON mode, so you can use them if you want to. It costs $0.50/million tokens, which is approximately 350 blog posts for $1. It's a great alternative to OpenAI if you don't want to spend too much money on LLMs.

How good is the generated content?

The prompts have been tested to generate okay content, but you may want to tweak them to generate better content.

I didn't spend too much time on it - so I'm sure you'll be able to improve the prompts and get much better results than me.

The generated content will follow this structure:

  1. A list of h2 headings generated using the generateOutline method
  2. A list of h3 headings generated using the generateBulletPoints method, with each a list of bullet points that will be expanded into paragraphs using the generateParagraph method
  3. An introduction generated using the generateIntroduction method
  4. A conclusion generated using the generateConclusion method

The generatePost puts everything together and returns the full Blog Post as a string.

Generate Outline API endpoint

The generateOutline endpoint will generate the outline of the Blog Post from the topic.

We define this endpoint at src/app/api/posts/outline/route.ts, which means it will be available at POST /api/posts/outline.

src/app/api/posts/outline/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { PostsLlmService } from '~/lib/ai/posts-llm.service';
export async function POST(req: NextRequest) {
const service = new PostsLlmService();
const schema = z.object({
title: z.string().min(1),
topic: z.string().min(1).optional(),
})
const body = schema.parse(await req.json());
const { content, tokens } = await service.generateOutline(body);
return NextResponse.json({ data: content });
}

The above endpoint will receive the title and the topic of the Blog Post, and will return the outline of the Blog Post.

NB: we don't use tokens yet - we will when we enforce token quotas.

Generate Bullet Points API endpoint

The generateBulletPoints endpoint will generate the bullet points of the Blog Post from the outline.

We define this endpoint at src/app/api/posts/bullet-points/route.ts, which means it will be available at POST /api/posts/bullet-points.

src/app/api/posts/bullet-points/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { PostsLlmService } from '~/lib/ai/posts-llm.service';
export async function POST(req: NextRequest) {
const service = new PostsLlmService();
const schema = z.object({
title: z.string().min(1),
outline: z.array(z.object({
heading: z.string().min(1),
sections: z.array(z.string().min(1)),
}))
});
const { outline, title } = schema.parse(await req.json());
const { tokens, content }
= await service.generateBulletPoints(title, outline);
return NextResponse.json(bulletPoints);
}

The above endpoint will receive the title and the outline of the Blog Post, and will return the bullet points of the Blog Post.

NB: we don't use tokens yet - we will when we enforce token quotas.

Defining the Server Action to generate the Blog Post

To call the API to generate the Blog Post, we need to define a Server Action that will be used to generate the Blog Post.

As you may already know, Server Actions allows us to call APIs from the server like simple JS functions - so we don't need to write an API endpoint to generate the Blog Post.

src/app/dashboard/[organization]/posts/new/actions.server.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { PostsLlmService } from '~/lib/ai/posts-llm.service';
import getSupabaseServerActionClient from '~/core/supabase/action-client';
import experimental_getSdk from '~/lib/sdk';
import getLogger from '~/core/logger';
const generatePostSchema = z.object({
title: z.string().min(1),
outline: z.array(
z.object({
heading: z.string().min(1),
sections: z.array(
z.object({
value: z.string().min(1),
bulletPoints: z.array(
z.object({
value: z.string().min(1),
}),
),
}),
),
})
)
});
export async function generatePost(body: z.infer<typeof generatePostSchema>) {
const service = new PostsLlmService();
const logger = getLogger();
const client = getSupabaseServerActionClient();
const sdk = experimental_getSdk(client);
const organization = await sdk.organization.getCurrent();
if (!organization) {
throw new Error(`No organization found.`);
}
logger.info({
organization: organization.id,
}, `Generating post...`);
const { outline, title } = generatePostSchema.parse(body);
const content = await service.generatePost(title, outline);
logger.info({
organization: organization.id,
}, `Post successfully generated`);
const insertPostResponse = await client
.from('posts')
.insert({
title,
content,
organization_id: organization.id,
})
.select('id')
.single();
if (insertPostResponse.error) {
throw new Error(insertPostResponse.error.message);
}
const id = insertPostResponse.data.id;
return redirect(`/dashboard/${organization.uuid}/posts/${id}`);
}

The Server Action will receive the title and the outline of the Blog Post, and will generate the Blog Post.

More specifically, it will:

  1. Validate the input: it will validate the input using the generatePostSchema Zod schema
  2. Generate the Blog Post: it will generate the Blog Post using the PostsLlmService service
  3. Insert the Blog Post: it will insert the Blog Post in the database using the Supabase client
  4. Redirect to the Blog Post page: it will redirect to the Blog Post page, where the user will be able to edit the Blog Post

Yay! We can now generate the Blog Post from the Blog Post creator. Of course, we still have no way to see the list of Blog Posts, so let's implement this in the next section.

NB: we don't enforce token quotas yet - we will do it later on.

Creating the Blog Post list page

Now that we can create Blog Posts, we want to make sure we can see the list of Blog Posts.

We need to go back to the Posts page we defined at src/app/dashboard/[organization]/posts/page.tsx and add a list of Blog Posts.

To do so, we will use the Data Loader SDK, a library built here at Makerkit to facilitate fetching data from the Supabase database.

To install the library, run the following command:

npm install @makerkit/data-loader-supabase-nextjs

Now, let's update the PostsPage component to fetch the list of Blog Posts from the database.

src/app/dashboard/[organization]/posts/page.tsx
import { PlusCircleIcon } from '@heroicons/react/24/outline';
import { ServerDataLoader } from '@makerkit/data-loader-supabase-nextjs';
import { withI18n } from '~/i18n/with-i18n';
import { PageBody, PageHeader } from '~/core/ui/Page';
import Trans from '~/core/ui/Trans';
import { Button } from '~/core/ui/Button';
import getSupabaseServerComponentClient from '~/core/supabase/server-component-client';
import loadAppData from '~/lib/server/loaders/load-app-data';
import PostsTable from './components/PostsTable';
interface PostPageProps {
searchParams: {
page?: string;
};
params: {
organization: string;
};
}
async function PostsPage({ searchParams, params }: PostPageProps) {
const client = getSupabaseServerComponentClient();
const page = Number(searchParams.page ?? '1');
const appData = await loadAppData(params.organization);
return (
<>
<PageHeader
title={<Trans i18nKey="posts:postsTabLabel" />}
description={<Trans i18nKey="posts:postsTabDescription" />}
>
<Button href={'posts/new'}>
<PlusCircleIcon className="w-5 h-5 mr-2" />
<span>
<Trans i18nKey="posts:createPostButtonLabel" />
</span>
</Button>
</PageHeader>
<PageBody>
<ServerDataLoader
client={client}
table={'posts'}
select={['id', 'title']}
page={page}
where={{
organization_id: {
eq: appData.organization?.id,
},
}}
>
{({ data, pageSize, pageCount }) => {
return (
<PostsTable
data={data}
page={page}
pageSize={pageSize}
pageCount={pageCount}
/>
);
}}
</ServerDataLoader>
</PageBody>
</>
);
}
export default withI18n(PostsPage);

The ServerDataLoader component will fetch the data from the database and pass it to the PostsTable component. We don't yet have defined the PostsTable, so let's do it now.

src/app/dashboard/[organization]/posts/components/PostsTable.tsx
'use client';
import Link from 'next/link';
import DataTable from '~/core/ui/DataTable';
function PostsTable({
data,
page,
pageSize,
pageCount,
}: {
data: Array<{
id: string;
title: string;
}>;
page: number;
pageSize: number;
pageCount: number;
}) {
return (
<DataTable
data={data}
pageIndex={page - 1}
pageSize={pageSize}
pageCount={pageCount}
columns={[
{
id: 'title',
header: 'Title',
cell: ({ row }) => {
return <Link href={`posts/${row.original.id}`}>{row.original.title}</Link>;
}
},
]}
/>
);
}
export default PostsTable;

The table is super simple and only displays the title of the Blog Post. We also add a link to the Blog Post page, so the user can click on the title and see the Blog Post.

Viewing a Blog Post

Now that we can see the list of Blog Posts, we want to make sure we can see the Blog Post page. This means we need to add a page at src/app/dashboard/[organization]/posts/[id]/page.tsx.

Before proceeding, let's install a package that will help us render Markdown content in the UI.

npm i markdown-to-jsx

We can now add this component as a generic component with some styles:

src/core/ui/MarkdownRenderer/MarkdownRenderer.tsx
import { memo } from 'react';
import Markdown from 'markdown-to-jsx';
import classNames from 'clsx';
import MarkdownStyles from './MarkdownRenderer.module.css';
const MemoizedReactMarkdown = memo(
Markdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className,
);
export default function MarkdownRenderer(
props: React.PropsWithChildren<{ className?: string; children: string }>,
) {
return (
<MemoizedReactMarkdown
className={classNames(props.className, MarkdownStyles.MarkdownRenderer, `MarkdownRenderer`)}
>
{props.children}
</MemoizedReactMarkdown>
);
}

Now, let's also add some styles to make it look pretty:

src/core/ui/MarkdownRenderer/MarkdownRenderer.module.css
.MarkdownRenderer h1 {
@apply text-3xl font-extrabold my-4;
}
.MarkdownRenderer h2 {
@apply text-2xl font-bold my-4;
}
.MarkdownRenderer h3 {
@apply text-xl font-semibold my-4;
}
.MarkdownRenderer h4 {
@apply text-lg font-semibold my-4;
}
.MarkdownRenderer h5 {
@apply text-base font-semibold my-4;
}
.MarkdownRenderer h6 {
@apply text-base font-medium my-4;
}
.MarkdownRenderer p {
@apply my-2.5;
}
.MarkdownRenderer ol {
@apply list-decimal list-inside pl-2 my-2.5;
}
.MarkdownRenderer ul {
@apply list-disc list-inside pl-2 my-2.5;
}
.MarkdownRenderer pre {
@apply px-2 py-4 bg-gray-50 break-words rounded-md my-4 whitespace-break-spaces text-sm border;
}
:global(.dark) .MarkdownRenderer pre {
@apply bg-dark-800;
}
.MarkdownRenderer .document-link {
@apply p-4 border rounded block my-2 font-medium text-xs bg-gray-50 hover:bg-gray-100/50 transition-colors;
}
:global(.dark) .MarkdownRenderer .document-link {
@apply bg-dark-700 hover:bg-dark-600/50;
}

Now, we can use the MarkdownRenderer component to render Markdown content in the UI.

src/app/dashboard/[organization]/posts/[id]/page.tsx
import { notFound } from 'next/navigation';
import { fetchDataFromSupabase } from '@makerkit/data-loader-supabase-core';
import { withI18n } from '~/i18n/with-i18n';
import { PageBody, PageHeader } from '~/core/ui/Page';
import getSupabaseServerComponentClient from '~/core/supabase/server-component-client';
import loadAppData from '~/lib/server/loaders/load-app-data';
import MarkdownRenderer from '~/core/ui/markdown/MarkdownRenderer';
interface PostPageProps {
params: {
organization: string;
id: string;
};
}
async function PostPage({ params }: PostPageProps) {
const client = getSupabaseServerComponentClient();
const appData = await loadAppData(params.organization);
const post = await fetchDataFromSupabase({
client,
table: 'posts',
select: ['id', 'title', 'content'],
single: true,
where: {
id: {
eq: params.id,
},
organization_id: {
eq: appData.organization?.id,
},
},
});
if (!post) {
return notFound();
}
return (
<>
<PageHeader
title={
post.data.title
}
/>
<PageBody>
<div className={'max-w-3xl mx-auto py-4'}>
<MarkdownRenderer>
{post.data.content}
</MarkdownRenderer>
</div>
</PageBody>
</>
);
}
export default withI18n(PostPage);

Let's drill down into what we've done here.

The component will:

  1. Fetch the Blog Post: it will fetch the Blog Post from the database using the fetchDataFromSupabase function (this comes from the Data Loader SDK)
  2. Render the Blog Post: it will render the Blog Post using the MarkdownRenderer component

Awesome, we can now see the Blog Post page.

Enforcing Subscription Limits

Now that we have a working Blog Post creator, we want to make sure we enforce the subscription limits. This means we need to make sure the user can't create more Blog Posts than the subscription allows.

We will go back to the various components we defined in the previous sections and add the required checks to enforce the subscription limits.

Database schema

We're going to introduce a new table in the database to store the subscription limits. This table will be called organization_usage and will store the usage of the organization. In addition, we add a plans table to store the subscription plans.

create table plans (
name text not null,
price_id text not null,
tokens bigint not null,
primary key (price_id)
);
create table organization_usage (
id bigint generated by default as identity primary key,
organization_id bigint not null references public.organizations on delete cascade,
tokens_quota bigint default 500000 not null
);

Now, we want to add the required RLS policies to make sure the user can only see the data that belongs to their organization.

alter table plans enable row level security;
alter table organization_usage enable row level security;
create policy "Users can read message counts in their Organization"
on organization_usage
for select
to authenticated
using (
current_user_is_member_of_organization(organization_id)
);
create policy "Users can read plans"
on plans
for select
to authenticated
using (
true
);

We want to make sure that every organization has a row in the organization_usage table. To do so, we will create a trigger that will create a row in the organization_usage table when an organization is created.

-- insert usage row for organizations on creation
create function public.handle_new_organization()
returns trigger
language plpgsql
security definer set search_path = public
as $$
begin
insert into public.organization_usage (organization_id)
values (new.id);
return new;
end;
$$;
-- trigger the function every time a user is created
create trigger on_organization_created
after insert on public.organizations
for each row execute procedure public.handle_new_organization();

By default, we assign new users 500,000 tokens. This allows customers to try the product without having to pay for it.

To run the migration, we need to run the following command:

npm run supabase:db:reset

Now, we need to make sure to:

  1. Decrease usage when making API calls to the LLM
  2. Enforce limits when creating Blog Posts

Problem: race condition when decreasing usage

Problem: since tokens are decreased after the Blog Post is created, the user can create more Blog Posts than the subscription allows if they were to call the API multiple times in parallel.

This is a common race condition that can happen when you have multiple API calls happening in parallel. There are various ways to work this around - but we will opt for a naive, yet simple solution.

Solution: decrease usage before requesting data from the LLM

Solution: we need to make sure we decrease the usage before creating the Blog Post. This means we need to make sure we decrease the usage when calling the generatePost Server Action using an approximate count of the tokens used.

Once generated, we check the actual number of tokens used and update the usage accordingly. If the generation used less tokens than expected, we increase the usage by the difference.

SQL functions to manage usage

To manage the usage, we need to create a few SQL functions that will allow us to:

  1. Decrease usage: this function will decrease the usage by a given amount, and return the current updated usage
  2. Get usage: this function will return the current usage

We can now update the posts migration to add the required functions:

create or replace function get_remaining_tokens(org_id bigint)
returns bigint as $$
declare
tokens_left bigint;
begin
select tokens_quota from organization_usage where organization_id = org_id into tokens_left;
return tokens_left;
end; $$
language plpgsql;
create or replace function subtract_tokens(org_id bigint, tokens bigint)
returns bigint as $$
declare
remaining_tokens bigint;
begin
update organization_usage set tokens_quota = tokens_quota - tokens where organization_id = org_id returning tokens_quota into remaining_tokens;
return remaining_tokens;
end; $$
language plpgsql;

To make sure the changes are applied, we need to run the following command:

npm run supabase:db:reset

and update the typescript types:

npm run typegen

Great - we can now use the functions to manage the usage and reject requests when the usage is exceeded.

Enforcing Token Quotas in the Blog Post creator

Now that we have the functions to manage the usage, we need to make sure we enforce the token quotas in the Blog Post creator.

The concept is simple: before requesting data from the LLM, we will:

  1. check the remaining tokens are enough to perform a request (using an estimated count of the tokens)
  2. decrease the usage by the estimated count of the tokens before requesting data from the LLM
  3. check the actual number of tokens used and update the usage accordingly

This means we need to update the API Routes to enforce the token quotas and update the usage.

Queries

Let's create a few queries to manage the usage at src/lib/posts/queries.ts:

src/lib/posts/queries.ts
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '~/database.types';
type Client = SupabaseClient<Database>;
export async function getOrganizationRemainingTokens(
client: Client,
organizationId: number,
) {
const { data, error } = await client.rpc('get_remaining_tokens', {
org_id: organizationId,
});
if (error) {
throw new Error(error.message);
}
return data;
}
export async function assertUserHasEnoughTokens(
client: Client,
organizationId: number,
amount: number,
) {
const tokens = await getOrganizationRemainingTokens(client, organizationId);
if (tokens < amount) {
throw new Error(`Not enough tokens.`);
}
return tokens;
}

Mutations

Let's create a few mutations to manage the usage at src/lib/posts/mutations.ts:

src/lib/posts/mutations.ts
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '~/database.types';
type Client = SupabaseClient<Database>;
export async function subtractOrganizationTokens(
client: Client,
organizationId: number,
tokens: number,
) {
const { data: remainingTokens, error } = await client.rpc('subtract_tokens', {
org_id: organizationId,
tokens,
});
if (error) {
throw new Error(error.message);
}
return remainingTokens;
}
export async function setOrganizationTokens(
adminClient: Client,
organizationId: number,
tokens: number,
) {
const { error } = await adminClient
.from('organization_usage')
.update({
tokens_quota: tokens,
})
.match({
organization_id: organizationId,
});
if (error) {
throw new Error(error.message);
}
}

The above mutations will allow us to decrease the usage and update the usage. Now we need to put everything together.

A service to manage the usage

Let's create a class to abstract the usage management logic named TokenUsageTrackerService at src/lib/posts/token-usage-tracker.service.ts to encapsulate the functions we just created:

src/lib/posts/token-usage-tracker.service.ts
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '~/database.types';
import { estimateTokensCount } from '~/lib/posts/utils';
import {
setOrganizationTokens,
subtractOrganizationTokens,
} from '~/lib/posts/mutations';
import { assertUserHasEnoughTokens } from '~/lib/posts/queries';
type Data = Array<{
sections: Array<{
value: string;
bulletPoints: Array<{
value: string;
}>;
}>;
}>;
export class TokenUsageTrackerService {
constructor(
private readonly adminClient: SupabaseClient<Database>,
private readonly organizationId: number,
) {}
estimateTokensCountFromData(data: Data) {
return estimateTokensCount(data);
}
async subtractOrganizationTokens(estimatedTokensUsage: number) {
// assert that the user has enough tokens to generate the post
await assertUserHasEnoughTokens(
this.adminClient,
this.organizationId,
estimatedTokensUsage,
);
// subtract the estimated tokens usage from the organization's tokens count
return subtractOrganizationTokens(
this.adminClient,
this.organizationId,
estimatedTokensUsage,
);
}
async updateOrganizationTokens(params: {
tokensUsed: number;
remainingTokens: number;
estimatedTokensUsage: number;
}) {
const actualTokensCountDifference = params.estimatedTokensUsage - params.tokensUsed;
const newTokensCount = params.remainingTokens + actualTokensCountDifference;
return setOrganizationTokens(this.adminClient, this.organizationId, newTokensCount);
}
async rollbackTokensCount(remainingTokens: number, estimatedTokensUsage: number) {
const tokens = remainingTokens + estimatedTokensUsage;
return setOrganizationTokens(this.adminClient, this.organizationId, tokens);
}
}

Updating the API Routes to enforce token quotas

Whenever we request data from an LLM, we want to manage token quotas. This is done in 3 places:

  1. the API route to generate the outline
  2. the API route to generate the bullet points
  3. the Server Action to generate the Blog Post

Let's do it!

Updating the API route to generate the outline

Let's update the API route to generate the outline at src/app/api/posts/outline/route.ts.

We will use a conservative estimate of 1000 tokens to generate the outline. This means that we will decrease the usage by 1000 tokens before generating the outline, and update the usage according to the actual tokens usage.

src/app/api/posts/outline/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { PostsLlmService } from '~/lib/ai/posts-llm.service';
import experimental_getSdk from '~/lib/sdk';
import { TokenUsageTrackerService } from '~/lib/posts/token-usage-tracker.service';
import getSupabaseRouteHandlerClient from '~/core/supabase/route-handler-client';
const schema = z.object({
title: z.string().min(1),
topic: z.string().min(1).optional(),
});
const ESTIMATED_TOKENS_USAGE = 1000;
export async function POST(req: NextRequest) {
const service = new PostsLlmService();
const body = schema.parse(await req.json());
const client = getSupabaseRouteHandlerClient();
const sdk = experimental_getSdk(client);
const organization = await sdk.organization.getCurrent();
if (!organization) {
throw new Error(`No organization found.`);
}
const tokensTracker = new TokenUsageTrackerService(
getSupabaseRouteHandlerClient({ admin: true }),
organization.id,
);
const remainingTokens =
await tokensTracker.subtractOrganizationTokens(
ESTIMATED_TOKENS_USAGE,
);
try {
const { content, tokens } = await service.generateOutline(body);
await tokensTracker.updateOrganizationTokens({
tokensUsed: tokens,
remainingTokens,
estimatedTokensUsage: ESTIMATED_TOKENS_USAGE,
});
return NextResponse.json({ data: content });
} catch (e) {
await tokensTracker.rollbackTokensCount(remainingTokens, ESTIMATED_TOKENS_USAGE);
return NextResponse.error();
}
}

Let's drill down into what we've done here.

  1. Create a "TokenUsageTrackerService" service: we create a TokenUsageTrackerService to manage the usage
  2. Decrease the usage: we decrease the usage by the estimated tokens usage
  3. Generate the outline: we generate the outline
  4. Update the usage: we update the usage according to the actual tokens usage
  5. Rollback the usage: if the generation fails, we rollback the usage

Updating the API route to generate the bullet points

Now, we also need to update the API route to generate the bullet points at src/app/api/posts/bullet-points/route.ts.

Again, we will use a conservative estimate of 1000 tokens to generate the bullet points. This means that we will decrease the usage by 1000 tokens before generating the bullet points, and update the usage according to the actual tokens usage.

src/app/api/posts/bullet-points/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { PostsLlmService } from '~/lib/ai/posts-llm.service';
import { TokenUsageTrackerService } from '~/lib/posts/token-usage-tracker.service';
import getSupabaseRouteHandlerClient from '~/core/supabase/route-handler-client';
import experimental_getSdk from '~/lib/sdk';
const schema = z.object({
title: z.string().min(1),
outline: z.array(
z.object({
heading: z.string().min(1),
sections: z.array(
z.object({
value: z.string().min(1),
}),
),
}),
),
});
const ESTIMATED_TOKENS_USAGE = 1000;
export async function POST(req: NextRequest) {
const service = new PostsLlmService();
const { outline, title } = schema.parse(await req.json());
const client = getSupabaseRouteHandlerClient();
const sdk = experimental_getSdk(client);
const organization = await sdk.organization.getCurrent();
if (!organization) {
throw new Error(`No organization found.`);
}
const tokensTracker = new TokenUsageTrackerService(
getSupabaseRouteHandlerClient({ admin: true }),
organization.id,
);
const remainingTokens = await tokensTracker.subtractOrganizationTokens(
ESTIMATED_TOKENS_USAGE
);
try {
const { tokens, content } = await service.generateBulletPoints(title, outline);
await tokensTracker.updateOrganizationTokens({
tokensUsed: tokens,
remainingTokens,
estimatedTokensUsage: ESTIMATED_TOKENS_USAGE,
});
return NextResponse.json({ data: content });
} catch (e) {
await tokensTracker.rollbackTokensCount(remainingTokens, ESTIMATED_TOKENS_USAGE);
return NextResponse.error();
}
}

We followed the same process as before.

Updating the Server Action to generate the Blog Post

Finally, we need to update the Server Action to generate the Blog Post at src/app/dashboard/[organization]/posts/new/actions.server.ts:

src/app/dashboard/[organization]/posts/new/actions.server.ts
'use server';
import { z } from 'zod';
import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { PostsLlmService } from '~/lib/ai/posts-llm.service';
import getSupabaseServerActionClient from '~/core/supabase/action-client';
import experimental_getSdk from '~/lib/sdk';
import getLogger from '~/core/logger';
import { TokenUsageTrackerService } from '~/lib/posts/token-usage-tracker.service';
const generatePostSchema = z.object({
title: z.string().min(1),
outline: z.array(
z.object({
heading: z.string().min(1),
sections: z.array(
z.object({
value: z.string().min(1),
bulletPoints: z.array(
z.object({
value: z.string().min(1),
}),
),
}),
),
}),
),
});
export async function generatePost(body: z.infer<typeof generatePostSchema>) {
const service = new PostsLlmService();
const logger = getLogger();
const client = getSupabaseServerActionClient();
const adminClient = getSupabaseServerActionClient({ admin: true });
logger.info(
`Generating post...`,
);
const sdk = experimental_getSdk(client);
const organization = await sdk.organization.getCurrent();
if (!organization) {
throw new Error(`No organization found.`);
}
const { outline, title } = generatePostSchema.parse(body);
const tokensTracker = new TokenUsageTrackerService(adminClient, organization.id);
// subtract the estimated tokens usage from the organization's tokens count
const remainingTokens = await tokensTracker.subtractOrganizationTokens(
tokensTracker.estimateTokensCountFromData(outline),
);
// generate the post using the LLM
const { content, tokens, success } = await service.generatePost(title, outline);
if (success) {
logger.info(
{
tokens,
},
`Post successfully generated`,
);
} else {
logger.error(
{
tokens,
},
`Failed to generate post. Reverse the tokens count...`,
);
// if the post generation failed, we need to reverse the tokens count
// by adding the estimated tokens usage back to the organization's tokens count
await tokensTracker.rollbackTokensCount(
remainingTokens,
tokensTracker.estimateTokensCountFromData(outline),
);
throw new Error(`Failed to generate post.`);
}
const insertPostResponse = await client
.from('posts')
.insert({
title,
content,
organization_id: organization.id,
})
.select('id')
.single();
if (insertPostResponse.error) {
throw new Error(insertPostResponse.error.message);
}
// once the post is generated, we can update the organization's tokens
// count with the actual amount used
try {
logger.info(
{
organization: organization.id,
tokens,
},
`Updating organization's tokens count...`,
);
await tokensTracker.updateOrganizationTokens({
tokensUsed: tokens,
remainingTokens,
estimatedTokensUsage: tokensTracker.estimateTokensCountFromData(outline),
});
logger.info(
{
organization: organization.id,
tokens,
},
`Organization's tokens count successfully updated`,
);
} catch (e) {
logger.error(
{
organization: organization.id,
error: e,
},
`Failed to update organization's tokens count`,
);
}
const id = insertPostResponse.data.id;
return redirect(`/dashboard/${organization.uuid}/posts/${id}`);
}

Similarly to the API routes, we followed the same process as before.

Refilling the tokens quota when the subscription renews

Now that we have a working Blog Post creator, we want to make sure we refill the tokens quota when the subscription renews.

To get this done, we need to:

  1. Listen to the webhook events from Stripe to know when the subscription renews
  2. Update the tokens quota when the subscription renews according to the subscription plan

Adding the webhook handler

To listen to the webhook events from Stripe, we need to add a webhook handler at src/app/api/stripe/webhook.ts.

First, let's add a new event to our enums at src/core/stripe/stripe-webhooks.enum.ts:

enum StripeWebhooks {
AsyncPaymentSuccess = 'checkout.session.async_payment_succeeded',
Completed = 'checkout.session.completed',
AsyncPaymentFailed = 'checkout.session.async_payment_failed',
SubscriptionDeleted = 'customer.subscription.deleted',
SubscriptionUpdated = 'customer.subscription.updated',
InvoicePaid = 'invoice.paid',
}
export default StripeWebhooks;

We need to add a new case to the switch statement in our webhooks handler for the event we just added:

src/app/api/stripe/webhook.ts
case StripeWebhooks.InvoicePaid: {
const subscriptionId = event.data.object.subscription as string;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
// await updateMessagesCountQuota(client, subscription);
}

In the next section, we will implement the updateMessagesCountQuota function to update the tokens quota when the subscription renews.

Updating the tokens quota

The function updateMessagesCountQuota will take care of updating the tokens quota when the subscription renews.

src/app/api/stripe/webhook.ts
async function updateMessagesCountQuota(
client: SupabaseClient<Database>,
subscription: Stripe.Subscription,
) {
const { price_id } = subscriptionMapper(subscription);
// get the max messages for the price based on the price ID
const plan = await client
.from('plans')
.select('tokens')
.eq('price_id', price_id)
.single();
if (plan.error) {
throw plan.error;
}
const { tokens } = plan.data;
// get the organization ID from the subscription metadata
// we added this when we created the subscription
const organizationId = Number(subscription.metadata.organizationId);
// upsert the message count for the organization
// and set the period start and end dates (from the subscription)
const response = await client
.from('organization_usage')
.update(
{
tokens_quota: tokens,
},
)
.eq('organization_id', organizationId);
if (response.error) {
throw response.error;
}
}

You can now comment in the function updateMessagesCountQuota in the webhook.ts file.

NB: we need to export the function subscriptionMapper from src/lib/subscriptions/mutations.ts, which the core kit doesn't do by default.

That's it, we can now update the tokens quota when the subscription renews!

Conclusion

We have finished! We now have a SaaS built on top of the Next.js Supabase SaaS Starter Kit that allows users to generate Blog Posts from a title and a topic. Yay! 🎉

In this tutorial, we've learned how to:

  1. Create a Blog Post creator: we've created a Blog Post creator that allows users to generate Blog Posts from a title and a topic
  2. Enforce subscription limits: we've enforced subscription limits to make sure users can't generate more Blog Posts than their subscription allows
  3. Refill the tokens quota when the subscription renews: we've refilled the tokens quota when the subscription renews

This result of this blog post is also a Premium Codebase available for customers with a valid Teams license.

This is a very long post, so it may contain some errors. If you find any, please let me know by email - I'll be happy to fix them.