Skip to main content
Nanahoshi is built with multi-tenancy from the ground up. Organizations allow multiple users to share libraries while maintaining proper access control and isolation.

Architecture overview

The multi-tenancy system uses better-auth with the organizations plugin:
packages/auth/src/index.ts
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, organization } from "better-auth/plugins";

const authConfig = {
  database: drizzleAdapter(db, {
    provider: "pg",
    schema: schema,
  }),
  emailAndPassword: {
    enabled: true,
  },
  plugins: [organization(), admin()],
} satisfies BetterAuthOptions;

export const auth = betterAuth(authConfig);
The organization() plugin from better-auth provides built-in support for multi-tenant organizations with roles, invitations, and member management.

Organization structure

The better-auth organizations plugin creates these tables:
packages/db/src/schema/auth.ts
table: organization {
  id: string;              // UUID
  name: string;
  slug: string | null;     // Unique identifier
  logo: string | null;
  createdAt: Date;
  metadata: JSON | null;   // Custom org data
}

table: member {
  id: string;              // UUID
  organizationId: string;
  userId: string;
  role: string;            // "owner", "admin", "member"
  createdAt: Date;
}

table: invitation {
  id: string;              // UUID
  organizationId: string;
  email: string;
  role: string;
  status: string;          // "pending", "accepted", "expired"
  expiresAt: Date;
  inviterId: string;
}

Organization roles

Better-auth organizations support three default roles:

Owner

Full control over the organization, including deletion and billing

Admin

Can manage members, libraries, and settings

Member

Can access organization libraries (read-only or read-write based on permissions)

Library-organization association

Libraries are scoped to organizations:
packages/db/src/schema/general.ts
table: library {
  id: number;
  name: string;
  organizationId: string;   // Foreign key to organization.id
  isCronWatch: boolean;
  isPublic: boolean;
  createdAt: Date;
}

Creating organization-scoped libraries

packages/api/src/routers/libraries/library.service.ts
export const createLibrary = async (
  input: CreateLibraryInput & { paths?: string[] },
  organizationId: string,
) => {
  return await libraryRepository.create(input, organizationId);
};
The repository enforces the organization association:
packages/api/src/routers/libraries/library.repository.ts
async create(
  input: CreateLibraryInput & { paths?: string[] },
  organizationId: string,
): Promise<LibraryComplete> {
  return db.transaction(async (tx) => {
    const [created] = await tx
      .insert(library)
      .values({
        ...input,
        organizationId,  // Scoped to organization
      })
      .returning();

    // ... add paths

    return created;
  });
}

Querying organization libraries

packages/api/src/routers/libraries/library.repository.ts
async findByOrganization(organizationId: string): Promise<LibraryComplete[]> {
  const libs = await db
    .select()
    .from(library)
    .where(eq(library.organizationId, organizationId));

  // Fetch paths for each library
  const result: LibraryComplete[] = [];
  for (const lib of libs) {
    const paths = await db
      .select()
      .from(libraryPath)
      .where(eq(libraryPath.libraryId, lib.id));
    result.push({ ...lib, paths });
  }
  return result;
}

Authorization middleware

The API uses oRPC procedures with organization context:
packages/api/src/index.ts
import { auth } from "@nanahoshi-v2/auth";
import { createContext } from "./context";

// Protected procedure requires authenticated session
export const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
  if (!context.session) {
    throw new ORPCError("UNAUTHORIZED", { message: "Authentication required" });
  }

  return next({
    context: {
      ...context,
      user: context.session.user,
      session: context.session.session,
    },
  });
});

Context extraction

The context includes organization membership:
packages/api/src/context.ts
export async function createContext(request: Request) {
  const session = await auth.api.getSession({ headers: request.headers });

  // Extract active organization from session or header
  const organizationId = session?.session?.activeOrganizationId;

  return {
    session,
    organizationId,
  };
}

User-library associations

Users can have granular permissions per library:
table: user_library {
  userId: string;
  libraryId: number;
  role: string;            // "viewer", "editor", "admin"
  createdAt: Date;
}
The user_library table is reserved for future use. Currently, access is controlled at the organization level through better-auth memberships.

Organization switching

Users can be members of multiple organizations:
apps/web/src/components/org-switcher.tsx
export function OrgSwitcher() {
  const { data: orgs } = useQuery({
    queryKey: ["organizations"],
    queryFn: () => orpc.organizations.list.query(),
  });

  const { data: activeOrg } = useQuery({
    queryKey: ["active-organization"],
    queryFn: () => orpc.organizations.getActive.query(),
  });

  const switchOrg = async (orgId: string) => {
    await orpc.organizations.setActive.mutate({ orgId });
    // Refresh all queries scoped to organization
    queryClient.invalidateQueries();
  };

  return (
    <Select value={activeOrg?.id} onValueChange={switchOrg}>
      {orgs?.map((org) => (
        <SelectItem key={org.id} value={org.id}>
          {org.name}
        </SelectItem>
      ))}
    </Select>
  );
}

Public vs private libraries

Libraries can be public or private within an organization:
library.isPublic = true;   // All organization members can access
library.isPublic = false;  // Only specific users with permissions

Filtering by visibility

const publicLibraries = await db
  .select()
  .from(library)
  .where(
    and(
      eq(library.organizationId, orgId),
      eq(library.isPublic, true)
    )
  );

Book access control

Books inherit access control from their parent library:
packages/api/src/routers/books/book.repository.ts
async listRecent(limit = 20, organizationId?: string) {
  let query = db
    .select({ /* ... */ })
    .from(book)
    .innerJoin(library, eq(library.id, book.libraryId))  // Join to enforce org scope
    .orderBy(desc(book.createdAt))
    .limit(limit);

  if (organizationId) {
    query = query.where(
      eq(library.organizationId, organizationId),  // Filter by organization
    ) as typeof query;
  }

  return query;
}

Creating a workspace (organization)

The frontend provides a workspace creation form:
apps/web/src/components/forms/create-workspace-form.tsx
export function CreateWorkspaceForm() {
  const createMutation = useMutation({
    mutationFn: (data: { name: string; slug: string }) =>
      orpc.organizations.create.mutate(data),
    onSuccess: (org) => {
      // Automatically switch to new organization
      queryClient.setQueryData(["active-organization"], org);
      router.push("/dashboard");
    },
  });

  return (
    <Form
      onSubmit={(values) => createMutation.mutate(values)}
      schema={CreateWorkspaceSchema}
    >
      <Input name="name" label="Workspace name" />
      <Input name="slug" label="Workspace slug" />
      <Button type="submit">Create workspace</Button>
    </Form>
  );
}

Invitation flow

Invite users to join an organization:
1

Send invitation

An admin creates an invitation with the user’s email and desired role.
2

Email notification

Better-auth sends an invitation email with a secure token.
3

Accept invitation

The user clicks the link and is added to the organization.
4

Access granted

The user can now access all libraries in the organization (based on library visibility and their role).

Setup wizard

New users are guided through organization creation:
apps/web/src/components/setup-page.tsx
export function SetupPage() {
  const { data: session } = useQuery({
    queryKey: ["session"],
    queryFn: () => orpc.auth.getSession.query(),
  });

  const { data: orgs } = useQuery({
    queryKey: ["organizations"],
    queryFn: () => orpc.organizations.list.query(),
    enabled: !!session,
  });

  // Redirect to create workspace if user has no organizations
  if (session && orgs?.length === 0) {
    return <CreateWorkspaceForm />;
  }

  // Otherwise show workspace selector
  return <WorkspaceSelector orgs={orgs} />;
}

Data isolation

Organization data is completely isolated:
  • Libraries - Scoped by library.organizationId
  • Books - Inherit scope through book.libraryId → library.organizationId
  • Collections - Scoped by collection.userId (per-user, not shared)
  • Reading progress - Scoped by reading_progress.userId
  • Activities - Scoped by activity.userId
Collections and reading progress are user-scoped, not organization-scoped. This means users maintain their personal reading lists and progress even if they switch organizations.

Example: Multi-tenant query

// Get all books in user's active organization
const { data: books } = useQuery({
  queryKey: ["books", "recent", organizationId],
  queryFn: () => orpc.books.listRecent.query({ limit: 20 }),
});

// The backend automatically filters by organizationId from context

Better-auth configuration

Environment variables for multi-tenancy:
# Better-auth
BETTER_AUTH_SECRET=your-secret-key
BETTER_AUTH_URL=http://localhost:3000

# SMTP for invitation emails
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=[email protected]
SMTP_PASSWORD=your-smtp-password
SMTP_FROM=Nanahoshi <[email protected]>
Better-auth provides type-safe authentication with built-in organization support, eliminating the need for custom role-based access control (RBAC) implementation.
Yes! Nanahoshi including better-auth is completely self-hosted. No external authentication services required.
Better-auth uses standard bcrypt password hashing. You can import existing user data by inserting into the user table with properly hashed passwords.

Build docs developers (and LLMs) love