
Migrating from ShipFast to MakerKit
Transform your simple ShipFast SaaS into a full-featured multi-tenant B2B application with MakerKit's organization-first architecture.
Prerequisites
- •Strong Next.js App Router experience
- •Understanding of multi-tenant architecture patterns
- •Familiarity with RBAC (Role-Based Access Control)
Migrating from ShipFast to MakerKit
MakerKit is designed for B2B SaaS with complex organizational structures. If your ShipFast app is evolving from a simple consumer tool into an enterprise product with teams, roles, and multiple workspaces, this migration guide is for you.
Why Migrate to MakerKit?#
Migrate if you need:
- True multi-tenancy with organization isolation
- Complex role hierarchies (Admin, Member, Custom roles)
- Per-organization billing and seat-based pricing
- Enterprise features like audit logs and SSO readiness
- White-labeling capabilities
Stay with ShipFast if:
- You're building a B2C product with individual users
- Simple team sharing is sufficient
- You don't need granular permissions
Architecture Comparison#
| Feature | ShipFast | MakerKit |
|---|---|---|
| User Model | User-centric | Organization-centric |
| Teams | Optional add-on | Core architecture |
| Permissions | Basic or none | Full RBAC system |
| Billing | Per-user | Per-organization + seats |
| Data Isolation | By user | By organization |
| Admin Panel | Basic | Super-admin + org-admin |
Step 1: Understand the Paradigm Shift#
The biggest change is moving from user-centric to organization-centric thinking:
// ShipFast: User owns everything
const userProjects = await prisma.project.findMany({
where: { userId: session.user.id },
});
// MakerKit: Organization owns everything, users have access
const orgProjects = await prisma.project.findMany({
where: { organizationId: session.user.organizationId },
});
Data Model Transformation#
ShipFast Structure:
User → Projects
User → Subscriptions
User → ApiKeys
MakerKit Structure:
User → Memberships → Organization → Projects
↓
Subscriptions
ApiKeys
TeamMembers
Step 2: Set Up MakerKit#
# Clone MakerKit to a new directory
git clone [your-makerkit-repo] my-b2b-app
cd my-b2b-app
# Install dependencies
npm install
# Set up environment
cp .env.example .env.local
# Configure database, auth, and payment credentials
Step 3: Design Your Migration Strategy#
Phase 1: Data Migration#
Create organizations from existing users:
// scripts/migrate-to-makerkit.ts
import { PrismaClient as OldDB } from './old-client';
import { PrismaClient as NewDB } from '@prisma/client';
async function migrateToMultiTenant() {
const oldDb = new OldDB();
const newDb = new NewDB();
// Get all users from ShipFast
const users = await oldDb.user.findMany({
include: {
projects: true,
subscription: true,
},
});
for (const user of users) {
// 1. Create user in MakerKit
const newUser = await newDb.user.create({
data: {
id: user.id,
email: user.email,
name: user.name,
image: user.image,
emailVerified: user.emailVerified,
},
});
// 2. Create personal organization
const org = await newDb.organization.create({
data: {
name: `${user.name || 'My'} Workspace`,
slug: generateSlug(user.email),
},
});
// 3. Create owner membership
await newDb.membership.create({
data: {
userId: newUser.id,
organizationId: org.id,
role: 'OWNER',
},
});
// 4. Migrate user's projects to organization
for (const project of user.projects) {
await newDb.project.create({
data: {
...project,
userId: undefined, // Remove user reference
organizationId: org.id, // Add org reference
},
});
}
// 5. Migrate subscription to organization
if (user.subscription) {
await newDb.subscription.create({
data: {
organizationId: org.id,
stripeCustomerId: user.subscription.stripeCustomerId,
stripePriceId: user.subscription.stripePriceId,
status: user.subscription.status,
quantity: 1, // Seat count
currentPeriodEnd: user.subscription.currentPeriodEnd,
},
});
}
}
}
Phase 2: Update Schema References#
Every table that had userId now needs organizationId:
// Before (ShipFast)
model Project {
id String @id @default(cuid())
name String
userId String
user User @relation(fields: [userId], references: [id])
}
// After (MakerKit)
model Project {
id String @id @default(cuid())
name String
organizationId String
organization Organization @relation(fields: [organizationId], references: [id])
createdById String? // Optional: track who created it
}
Step 4: Migrate Authentication#
MakerKit extends NextAuth with organization context:
// lib/auth.ts (MakerKit pattern)
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth-options';
export async function getSession() {
const session = await getServerSession(authOptions);
if (!session?.user) {
return null;
}
// MakerKit adds organization context
const membership = await prisma.membership.findFirst({
where: {
userId: session.user.id,
// Get from cookie or default to first org
},
include: {
organization: true,
},
});
return {
...session,
user: {
...session.user,
organizationId: membership?.organizationId,
organizationRole: membership?.role,
},
};
}
Organization Switching#
MakerKit supports multiple organizations per user:
// components/OrganizationSwitcher.tsx
export function OrganizationSwitcher() {
const { organizations, currentOrg, switchOrg } = useOrganization();
return (
<Select value={currentOrg.id} onValueChange={switchOrg}>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</Select>
);
}
Step 5: Implement Role-Based Access Control#
MakerKit includes a full RBAC system:
// lib/permissions.ts
export const ROLES = {
OWNER: {
permissions: ['*'], // Full access
},
ADMIN: {
permissions: [
'projects:read',
'projects:write',
'projects:delete',
'members:read',
'members:invite',
'billing:read',
],
},
MEMBER: {
permissions: [
'projects:read',
'projects:write',
],
},
VIEWER: {
permissions: [
'projects:read',
],
},
};
// Usage in API routes
export async function DELETE(req: Request, { params }) {
const session = await getSession();
if (!hasPermission(session.user.organizationRole, 'projects:delete')) {
return new Response('Forbidden', { status: 403 });
}
// Delete project...
}
Migrate Your API Routes#
Update every API route to check permissions:
// Before (ShipFast) - simple ownership check
export async function GET(req: Request) {
const session = await getServerSession();
const projects = await prisma.project.findMany({
where: { userId: session.user.id },
});
return Response.json(projects);
}
// After (MakerKit) - organization + permission check
export async function GET(req: Request) {
const session = await getSession();
if (!hasPermission(session.user.organizationRole, 'projects:read')) {
return new Response('Forbidden', { status: 403 });
}
const projects = await prisma.project.findMany({
where: { organizationId: session.user.organizationId },
});
return Response.json(projects);
}
Step 6: Migrate Billing to Organization Level#
Update Stripe Metadata#
// When creating checkout sessions
const session = await stripe.checkout.sessions.create({
customer_email: user.email,
metadata: {
// ShipFast: userId
// MakerKit: organizationId
organizationId: currentOrg.id,
},
line_items: [
{
price: priceId,
quantity: seatCount, // MakerKit adds seat-based billing
},
],
subscription_data: {
metadata: {
organizationId: currentOrg.id,
},
},
});
Update Webhook Handler#
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(...);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
const organizationId = session.metadata.organizationId;
// Update organization's subscription
await prisma.subscription.upsert({
where: { organizationId },
update: {
stripeSubscriptionId: session.subscription,
status: 'active',
},
create: {
organizationId,
stripeCustomerId: session.customer,
stripeSubscriptionId: session.subscription,
status: 'active',
},
});
break;
}
case 'customer.subscription.updated': {
const subscription = event.data.object;
const organizationId = subscription.metadata.organizationId;
await prisma.subscription.update({
where: { organizationId },
data: {
status: subscription.status,
quantity: subscription.items.data[0].quantity, // Seat count
currentPeriodEnd: new Date(subscription.current_period_end * 1000),
},
});
break;
}
}
}
Step 7: Add Team Management Features#
MakerKit includes team invitation flows:
// app/api/organizations/[orgId]/invitations/route.ts
export async function POST(req: Request) {
const session = await getSession();
const { email, role } = await req.json();
// Check permission to invite
if (!hasPermission(session.user.organizationRole, 'members:invite')) {
return new Response('Forbidden', { status: 403 });
}
// Check seat limits
const org = await prisma.organization.findUnique({
where: { id: session.user.organizationId },
include: {
subscription: true,
memberships: true,
},
});
const seatLimit = org.subscription?.quantity || 1;
if (org.memberships.length >= seatLimit) {
return Response.json(
{ error: 'Seat limit reached. Upgrade to add more members.' },
{ status: 400 }
);
}
// Create invitation
const invitation = await prisma.invitation.create({
data: {
email,
role,
organizationId: session.user.organizationId,
invitedById: session.user.id,
token: generateToken(),
expiresAt: addDays(new Date(), 7),
},
});
// Send invitation email
await sendInvitationEmail(email, invitation);
return Response.json(invitation);
}
Step 8: Update Frontend Components#
Dashboard Layout#
// MakerKit dashboard includes org context
export default function DashboardLayout({ children }) {
return (
<div className="flex h-screen">
<Sidebar>
<OrganizationSwitcher />
<Navigation />
<TeamMembersList />
</Sidebar>
<main className="flex-1 overflow-auto">
<OrganizationGuard requiredPermission="dashboard:access">
{children}
</OrganizationGuard>
</main>
</div>
);
}
Permission-Gated UI#
// components/PermissionGate.tsx
export function PermissionGate({
permission,
children,
fallback = null
}) {
const { organizationRole } = useSession();
if (!hasPermission(organizationRole, permission)) {
return fallback;
}
return children;
}
// Usage
<PermissionGate permission="billing:read">
<BillingSettings />
</PermissionGate>
<PermissionGate
permission="members:invite"
fallback={<p>Contact an admin to invite members.</p>}
>
<InviteMemberButton />
</PermissionGate>
Common Challenges#
1. Data Isolation Bugs#
Problem: Accidentally showing data from other organizations.
Solution: Create a helper that always scopes queries:
// lib/db-helpers.ts
export function scopedQuery(organizationId: string) {
return {
where: { organizationId },
};
}
// Usage - makes it harder to forget
const projects = await prisma.project.findMany({
...scopedQuery(session.user.organizationId),
orderBy: { createdAt: 'desc' },
});
2. Stripe Customer Migration#
Problem: Existing Stripe customers are tied to user emails.
Solution: Keep customer IDs but update metadata:
// Update existing Stripe customers
await stripe.customers.update(stripeCustomerId, {
metadata: {
organizationId: newOrgId,
migratedFromUserId: oldUserId,
},
});
3. Session Context#
Problem: Components expect user-only session.
Solution: Update session type and usage:
// types/next-auth.d.ts
declare module 'next-auth' {
interface Session {
user: {
id: string;
email: string;
name?: string;
organizationId: string;
organizationRole: 'OWNER' | 'ADMIN' | 'MEMBER' | 'VIEWER';
};
}
}
Testing Checklist#
- User can create a new organization
- User can switch between organizations
- Data is isolated between organizations
- RBAC prevents unauthorized actions
- Team invitations work correctly
- Subscription ties to organization
- Seat limits are enforced
- Existing data migrated correctly
- Stripe webhooks update correct org
Timeline Estimate#
| Phase | Estimated Time |
|---|---|
| Planning & Schema Design | 3 hours |
| Data Migration Scripts | 5 hours |
| API Route Updates | 6 hours |
| RBAC Implementation | 4 hours |
| Frontend Updates | 4 hours |
| Testing & Bug Fixes | 2 hours |
| Total | ~24 hours |
Conclusion#
Migrating from ShipFast to MakerKit is a significant undertaking but essential for B2B SaaS with team requirements. The key paradigm shift is thinking organization-first rather than user-first. Every database query, every API route, and every UI component needs to be aware of the organizational context.
Take this migration in phases, test thoroughly, and consider running parallel systems during the transition.
Not sure which boilerplate to choose?
Take our 2-minute quiz and get personalized recommendations.
Take the Quiz