JulieGibbs /
b2b-saaskit
B2B SaaS Starter Kit - create a B2B web app: quickly and for free
45/100 healthLoading repository data…
fogbender / repository
B2B SaaS Starter Kit - create a B2B web app: quickly and for free
A transparent discovery signal based on current public GitHub metadata.
This score does not audit code, security, maintainers, documentation quality, or suitability. Verify the repository and its current documentation before adoption.
The B2B SaaS Kit is an open-source starter toolkit for developers looking to quickly stand up a SaaS product where the customer can be a team of users (i.e., a business).
The kit uses TypeScript, Astro, React, Tailwind CSS, and a number of third-party services that take care of essential, yet peripheral requirements, such as secrets management, user authentication, a database, product analytics, customer support, payments, and deployment infrastructure.
The kit is designed with two primary goals in mind:
Start with a fully-functional, relatively complex application. Then, modify it to become your own product.
You should be able to build an app to validate your idea for the cost of a domain name - all the third-party services used by the kit offer meaningful free-forever starter plans.
"B2B" means "business-to-business". In the simplest terms, a B2B product is a product where post-signup, a user can create an organization, invite others, and do something as a team.
B2B companies are fairly common - for example, over 40% of Y Combinator-funded startups self-identify as B2B - but B2B-specific starter kits appear to be quite rare, hence this effort.
First, check out https://PromptsWithFriends.com - it's an example app built with this kit. Prompts with Friends is a way to collaborate on GPT prompts with others
Next, get your own copy of Prompts with Friends running locally on your machine
Then, learn how to deploy your version to production
Lastly, build your own product by modifying the app
Install prerequisites
Clone repo, start app
git clone https://github.com/fogbender/b2b-saaskit.git
cd b2b-saaskit
corepack enable
corepack prepare yarn@1.22.19 --activate
yarn
yarn dev
Open http://localhost:3000 in a browser tab - you should see a page titled "Welcome to Prompts with Friends"
You'll find detailed configuration instructions on http://localhost:3000/setup. Once you're here -
- you should be able to have a working copy of Prompts with Friends running on http://localhost:3000/app
Please refer to section 9. Production deployment to Vercel of the setup guide
We're using Drizzle ORM to manage database migrations in B2B SaaS Kit. For details on Drizzle, see https://orm.drizzle.team/kit-docs/overview.
Another popular ORM option is Prisma - if you'd like to use Prisma instead of Drizzle and need help, please get in touch with us.
First, you'd have to make a few changes in src/db/schema.ts. The changes you make will only affect your TypeScript code, not the actual table data.
export const example = pgTable("example", {
exampleId: text("id").primaryKey(),
value: integer("value").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
If you try to access the example table, you'll get a runtime error. To fix this, you have to run a "migration", which applies a set of updates - which may include some combination of schema and data changes - to the database.
Drizzle migrations happen in two steps: the first step generates a migration file, the second step applies the migration to the database.
To generate a migration file, run
doppler run yarn drizzle-kit generate
This will generate a file called something like src/db/migration/1234_xyz.sql. Under normal circumstances, you wouldn't have to worry about this file - it will contain an auto-generated set of SQL statements needed to apply the changes expressed in your schema.ts to the database. However, since we're using Supabase Postgres, we have to take care of Row Level Security policies when creating new tables.
To do this, open the migration file and add the following to end, making sure to change example to the table name you're using:
ALTER TABLE example ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service" ON "public"."example" AS PERMISSIVE FOR ALL TO service_role USING (true);
Finally, run the migration:
doppler run yarn migrate
If you open your Postgres console (e.g., Supabase or psql), you'll see the new table.
Migrating your development database will not migrate the production one. To migrate production, run migrate with production configuration:
doppler run yarn migrate --config prd
We settled on tRPC to take care of the "API" part of the app. tRPC's excellent integration with TanStack Query (formerly React Query) and Zod sealed the deal for us, because we considered ease of refactoring and typesafety very important for a codebase that's meant to be heavily modified. There are other reasons to like tRPC: it ships with a set of great features, like middlewares, serialization, input validation, and error handling.
Backend routing for tRPC starts with export const appRouter in the src/lib/trpc/root.ts file.
To add a new endpoint - say, a simple counter - add a counterRouter to appRouter:
+ import { counterRouter } from './routers/counter';
export const appRouter = createTRPCRouter({
hello: helloRouter,
auth: authRouter,
prompts: promptsRouter,
settings: settingsRouter,
surveys: surveysRouter,
+ counter: counterRouter
});
Next, create a router file called src/lib/trpc/routers/counter.ts:
import { createTRPCRouter, publicProcedure } from "../trpc";
let i = 0;
export const counterRouter = createTRPCRouter({
getCount: publicProcedure.query(async () => {
await new Promise((resolve) => setTimeout(resolve, 300));
return i;
}),
increment: publicProcedure.mutation(() => {
return ++i;
}),
});
What's happening here?
createTRPCRouter creates a new router mounted in appRouter. Routers can be nested ad infinitum.publicProcedure is a way to add a remote call that doesn't perform any checks in the middle (i.e., without any middlewares). For examples of procedure builders that do perform additional checks, see authProcedure or orgProcedure in src/lib/trpc/trpc.ts.ctx object, which is passed as input to all tRPC functions. Middleware are commonly used to perform access control checks or input validation.query and mutation correspond to useQuery and useMutation in TanStack Query, respectively. You can think of these as GET and POST requests.await new Promise((resolve) => setTimeout(resolve, 300)); to simulate network latency - useful for testing loading states during development.Now that we have the backend code in place, let's call it from a new page src/components/Counter.tsx:
import { trpc, TRPCProvider } from "./trpc";
export function Counter() {
return (
<TRPCProvider>
<CounterInternal />
</TRPCProvider>
);
}
const CounterInternal = () => {
const counterQuery = trpc.counter.getCount.useQuery();
const trpcUtils = trpc.useContext();
const incrementMutation = trpc.counter.increment.useMutation({
async onSettled() {
await trpcUtils.counter.getCount.invalidate();
},
});
return (
<div className="container mx-auto mt-8">
Count: {counterQuery.data ?? "loading..."}
<br />
<button
disabled={incrementMutation.isLoading}
className="rounded bg-blue-500 px-4 py-2 text-white disabled:bg-gray-400"
onClick={() => {
incrementMutation.mutate();
}}
>
Increase count
</button>
</div>
);
};
What's happening here?
TRPCProvider, otherwise useQuery and useMutation won't work. Usually, this is done higher up in the component tree.const counterQuery = trpc.counter.getCount.useQuery(); is how we call the backend endpoint. You can think of counter.getCount as a path to the endpoint that corresponds to counterRouter from the previous step. If you've used TanStack Query, you can think of trpc.[path].useQuery() as the equivalent of useQuery({ queryKey: [path], queryFn: () => fetch('http://localhost:3000/api/trpc/[path]') }).trpc.useContext is a tRPC wrapper around queryClient. Its main purpose is to update the cache (used for optimistic updates and re-fetch queries. In our example, we'd like to see the new value for the counter immediately after clicking the "Increase count" button.useMutation is similar to useQuery, but it must be called manually with incrementMutation.mutate() and it'll never auto re-fetch like useQuery. (By default, useQuery re-fetches on window focus.) Conceptually, calling useMutation is similar to making a "POST" request - in our example, calling it causes the counter value to increase.onSettled is called after the mutation completes, either successfully or with an error. Because we know that the counter value has changed on the server, we know the value in counterQuery.data is out of date. To get the current value, we invalide the getCounter cache, which immediately triggers a useQuery re-fetch.invalidate() returns a Promise that gets resolved once the new value of the counter is fetched. While we're waiting on this Promise in incrementMutation.onSettled, the value of incrementMutation.isLoading is set to true, until the new value of counterQuery is available.{incrementMutation.isLoading} to place the button in disabled state - thiSelected from shared topics, language and repository description—not editorial ratings.
JulieGibbs /
B2B SaaS Starter Kit - create a B2B web app: quickly and for free
45/100 health