Overview
I built Plainform to give developers a connected starting point for their SaaS: reusable UI, authentication, billing, data access, email, content, and documentation.
The main challenge was designing a clean, understandable architecture that developers could easily build on. Bringing together reusable UI, backend services, content tools, and application infrastructure was only part of the work. Developers needed clear conventions for extending the foundation, connecting their own features, and understanding how the existing integrations fit together.
Users estimate that building the connected foundation themselves would take 60+ hours. Configuring Plainform takes roughly one hour in my experience, with most of that time spent collecting credentials from the different providers.
The challenge
I built Plainform because starting a SaaS product repeatedly meant spending time on authentication, payments, email, and other shared infrastructure before reaching the features that made the product useful. I wanted developers to start with those foundations connected, so their effort could go toward their own application.
A starter kit becomes someone else's codebase. A shortcut that is manageable when I know the implementation can become a recurring obstacle for a developer encountering it for the first time. I paid particular attention to naming, responsibility boundaries, and the path from an interface component to the underlying operation.
I wanted developers to have enough structure to follow an existing pattern without introducing a framework they had to learn before building anything. That meant choosing conventions and applying them consistently, while accepting that no single approach would match every developer's preferred style.
I spent about a year building and refining the kit, with particular attention to how its integrations behave and how developers would extend the foundation.
Documentation created a second responsibility. A working integration is difficult to adopt if its setup is unclear or the instructions describe an older version. Maintaining the kit meant keeping code, examples, configuration, and guidance aligned as the foundation evolved.
Product experience
A developer enters their GitHub username during purchase. The product site validates the account and automatically sends an invitation to the private starter repository after payment processing. Once they accept it, they can use the template to create their own repository or clone the code and configure the required services.
The application already contains authentication screens, pricing and checkout flows, transactional email templates, and content pages. Those provide a starting experience the developer can inspect and customize.
The editable copy is grouped in locales/en.json, while site identity and metadata have their own configuration. Someone changing a headline or button label can work through the content without first tracing the component tree. The template wording also prompts them to explain what their product does and who it serves.
For implementation tasks, the documentation includes recipes such as customizing the hero, adding a Stripe product, testing payments locally, and deploying the application. Each recipe gives the task a goal, lists prerequisites, walks through the steps, and explains how to check the result.
Recipes connect common customization tasks to the files and fields developers need to edit.
Architecture
The starter follows the Next.js App Router. Pages compose interface components, while named helpers provide access to content and external services. For example, the pricing section calls getProducts() and getCoupons(), then passes the returned data into pricing cards. Checkout and webhook routes handle the server-side operations behind that interface.
Integration code follows a consistent organization under lib/: Stripe, Resend, Supabase, Prisma, and S3 each have dedicated client modules. Service-specific helpers and handlers are separated from pages and interface components, making it easier to find and extend the code for a particular integration. The local user record links the authentication identity to the billing customer, while shared types and validation schemas support the boundaries between these parts.
Blog posts, documentation, and legal content use separate Fumadocs MDX collections. Content can be maintained alongside the code, with schema-defined frontmatter and reusable rendering components.
This gives developers places to extend the application without putting every operation into a page or component. It remains a concrete Next.js application with provider-specific code, rather than a universal architecture that hides every dependency.
Selected starter files: the pricing interface, retrieval helpers, and server routes have separate responsibilities.
Built with
- React
- TypeScript
- Next.js
- PostgreSQL
- Prisma
- AWS S3
- AWS CloudFront
- Stripe
- Tailwind CSS
- Shadcn UI
- GitHub
- Supabase
- Clerk
- Resend
- PostHog
- MDX
Engineering decisions
Give product copy a predictable home
I centralized the starter's editable copy in en.json because replacing template content is one of the first things a developer needs to do. Keeping it grouped by page and component makes the relationship between the content and its destination easier to follow.
For example, the hero reads its headline, description, and calls to action from the same section:
{
"homePage": {
"heroSection": {
"title": "Your headline here",
"description": "Describe what your product does, who it's for, and why it matters.",
"cta": {
"primary": {
"text": "Get Started",
"href": "/#buy"
}
}
}
}
}import locale from '@/locales/en.json';
// Inside Hero():
const heroLocale = locale?.homePage?.heroSection;
// The primary call to action:
<Link href={heroLocale?.cta?.primary?.href}>
<Button>{heroLocale?.cta?.primary?.text}</Button>
</Link>This also gives an AI agent a clear editing target when adapting the kit to a new product. The repository guidance points to the locale file, reducing the need to search through components for individual strings. It provides a useful starting point for additional languages, although language selection and routing still need their own implementation.
Keep billing state separate from product rules
Different products sell different things. A subscription might unlock projects, seats, credits, or a collection of features. I kept the shared billing foundation focused on the customer relationship and subscription state, with explicit places for developers to add their own rules.
The subscription synchronization helper stores the plan identifier, status, current period end, and cancellation state on the local user. Event-specific handlers can then be extended with behavior such as an upgrade notification. This keeps the foundation useful across products without assuming that every subscription grants the same access.
The trade-off is that the developer still has to define and enforce what each plan unlocks. The kit supplies the billing connection and state, while the product determines the permissions.
Connect purchase to repository access
I built the delivery flow on the Plainform product site so customers could get the code without waiting for me to grant access manually. Checkout collects their GitHub username, and the webhook validates the account and checks for existing access before capturing payment and requesting the repository invitation.
Payment and access can fail at different points. An invalid username cancels the authorized payment. If payment succeeds but the GitHub invitation fails, the customer receives an email explaining that access needs support attention. The order records the delivery status, so completing checkout is not treated as proof that the repository invitation succeeded.
Document tasks as complete workflows
Reference pages explain the components and conventions, but recipes help developers accomplish a particular change. I wanted the documentation to answer both “where does this go?” and “how do I know it worked?”
The local payment-testing recipe, for example, covers webhook forwarding, configuration, checkout, and checking the received events. The subscription guide explains the dependency between authentication, the local user record, and the Stripe customer. That context helps developers understand why a setup step matters rather than simply copying configuration.
Writing at that level takes more maintenance than listing available features. I chose it because configuration and integration are part of the developer experience, not work that ends when the code is committed.
Selected pages from the Plainform documentation site: architecture references explain the conventions, while recipes guide specific changes.
Make documentation reusable as development context
I also made the documentation accessible to developers working with coding assistants. Each page includes a Copy Markdown action and shortcuts to open it in supported chat tools. Developers can bring a specific guide into their workflow without manually gathering its instructions from the rendered page.
I added the documentation to Context7 so agents connected through its MCP server can retrieve Plainform-specific guidance when working with the kit. That gives them context for the project's conventions, integrations, and setup steps alongside the source code. The same maintained documentation serves both someone reading a recipe directly and an agent helping them implement it.
Problems & iterations
Expanding beyond one-time payments
The first release supported one-time purchases. Soon after launch, I added subscription support, which introduced a continuing billing lifecycle beyond checkout completion.
I made the webhook events clearer by separating one-time and subscription checkout handlers, then giving subscription creation, updates, and deletion their own handlers. Checkout-level work stays separate from subscription synchronization. A shared helper updates the durable billing state, and the Billing Portal route lets customers manage their subscription.
I also added persistent processing records to recognize duplicate events and allow failed processing to be retried. That gives developers a clearer basis for extending the flow without putting every event and side effect into one large webhook handler.
export async function handleCheckoutSessionCompleted(
session: Stripe.Checkout.Session,
stripeEventId: string
) {
if (session.mode === 'payment') {
return await handleOneTimeCheckoutSessionCompleted(session, stripeEventId);
}
if (session.mode === 'subscription') {
return await handleSubscriptionCheckoutSessionCompleted(
session,
stripeEventId
);
}
return NextResponse.json({ message: 'Checkout session mode not handled' });
}Checkout delegates by payment type. Subscription lifecycle events use their own handlers to synchronize billing state.
Maintaining a foundation other people build on
Shipping updates is an ongoing part of the product. A change to an integration can also affect its setup guide, example code, environment requirements, or the instructions developers follow when extending it.
I maintain the kit with those connections in mind. Naming and boundaries help make changes understandable, but they do not remove the need to revisit earlier choices. The goal is to keep the foundation approachable as it grows, rather than claim that the architecture is finished or suits every style of development.
Results & next steps
Plainform shipped as a reusable foundation and is now used by nine users. It also became the starting point for ReddMatch, where I built the project-specific dashboard and indexing system on top of it. That gave me a direct opportunity to apply the foundation to a substantially different product.
The next step is broader integration coverage, including alternatives such as Polar for developers who prefer it to Stripe. That involves making each option understandable and documenting its setup, alongside continuing to maintain the existing integrations. The aim is to support more developers without making the starting code harder to navigate.
