General
Organizations

Store Data

Learn how to store data for organizations in your application.

When working with organizations, you typically want to store data that belongs to each organization and can be accessed by organization members.

Database Schema

Add Organization ID to Your Tables

Add an organizationId field to tables that should be scoped to organizations:

lib/db/schema/posts.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';

import { organizations } from './organizations';
import { users } from './users';

export const postsTable = pgTable('posts', {
  id: text('id').primaryKey(),
  title: text('title').notNull(),
  content: text('content').notNull(),
  authorId: text('authorId')
    .notNull()
    .references(() => users.id, { onDelete: 'cascade' }),
  organizationId: text('organizationId')
    .notNull()
    .references(() => organizations.id, { onDelete: 'cascade' }),
  createdAt: timestamp('createdAt').defaultNow().notNull(),
  updatedAt: timestamp('updatedAt').defaultNow().notNull()
});

export const postsRelations = relations(postsTable, ({ one }) => ({
  author: one(users, {
    fields: [postsTable.authorId],
    references: [users.id]
  }),
  organization: one(organizations, {
    fields: [postsTable.organizationId],
    references: [organizations.id]
  })
}));

This allows:

  • All members of an organization to access the posts
  • The author to be tracked separately
  • Data to be properly scoped to organizations

Creating Organization-Scoped Data

Using tRPC

Use protectedOrganizationProcedure to automatically scope data to the active organization:

trpc/routers/posts.ts
import { createTRPCRouter, protectedOrganizationProcedure } from '@/trpc/init';
import { z } from 'zod';

import { assertUserIsOrgMember } from '@/lib/auth/server';
import { db } from '@/lib/db';
import { postsTable } from '@/lib/db/schema';

export const postsRouter = createTRPCRouter({
  create: protectedOrganizationProcedure
    .input(
      z.object({
        title: z.string().min(1),
        content: z.string().min(1)
      })
    )
    .mutation(async ({ input, ctx }) => {
      // ctx.organization is guaranteed to exist
      // User membership is already verified

      const [post] = await db
        .insert(postsTable)
        .values({
          title: input.title,
          content: input.content,
          authorId: ctx.user.id,
          organizationId: ctx.organization.id
        })
        .returning();

      return post;
    })
});

Verifying Membership

If you need to verify membership manually:

lib/auth/verify-membership.ts
import { assertUserIsOrgMember } from '@/lib/auth/server';

export async function verifyMembership(organizationId: string, userId: string) {
  // This will throw an error if user is not a member
  const { organization, membership } = await assertUserIsOrgMember(
    organizationId,
    userId
  );

  return { organization, membership };
}

Querying Organization Data

List Organization Posts

Query posts for the active organization:

trpc/routers/posts.ts
list: protectedOrganizationProcedure.query(async ({ ctx }) => {
  // Automatically scoped to ctx.organization.id
  const posts = await db.query.postsTable.findMany({
    where: eq(postsTable.organizationId, ctx.organization.id),
    orderBy: desc(postsTable.createdAt),
  });

  return posts;
}),

Get Single Post

Get a single post, ensuring it belongs to the organization:

trpc/routers/posts.ts
getById: protectedOrganizationProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input, ctx }) => {
    const post = await db.query.postsTable.findFirst({
      where: and(
        eq(postsTable.id, input.id),
        eq(postsTable.organizationId, ctx.organization.id)
      ),
    });

    if (!post) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "Post not found",
      });
    }

    return post;
  }),

Client-Side Usage

Creating Posts

Create posts from the UI:

components/create-post-form.tsx
'use client';

import { trpc } from '@/trpc/client';
import { useForm } from 'react-hook-form';

import { useActiveOrganization } from '@/hooks/use-active-organization';

export function CreatePostForm() {
  const { activeOrganization } = useActiveOrganization();
  const utils = trpc.useUtils();
  const createPost = trpc.posts.create.useMutation({
    onSuccess: () => {
      utils.posts.list.invalidate();
    }
  });

  const onSubmit = async (data: { title: string; content: string }) => {
    if (!activeOrganization) {
      throw new Error('No active organization');
    }

    await createPost.mutateAsync(data);
  };

  return <form onSubmit={handleSubmit(onSubmit)}>{/* form fields */}</form>;
}

Listing Posts

List posts for the active organization:

components/posts-list.tsx
'use client';

import { trpc } from '@/trpc/client';

export function PostsList() {
  const { data: posts, isLoading } = trpc.posts.list.useQuery();

  if (isLoading) return <div>Loading...</div>;
  if (!posts?.length) return <div>No posts found</div>;

  return (
    <div>
      {posts.map((post) => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  );
}

Updating Organization Data

Update with Permission Check

Only allow admins to update organization-scoped data:

trpc/routers/posts.ts
update: protectedOrganizationProcedure
  .input(
    z.object({
      id: z.string(),
      title: z.string().optional(),
      content: z.string().optional(),
    })
  )
  .mutation(async ({ input, ctx }) => {
    // Check if user is admin or owner
    const isAdmin =
      ctx.membership.role === "admin" || ctx.membership.role === "owner";

    const post = await db.query.postsTable.findFirst({
      where: and(
        eq(postsTable.id, input.id),
        eq(postsTable.organizationId, ctx.organization.id)
      ),
    });

    if (!post) {
      throw new TRPCError({ code: "NOT_FOUND" });
    }

    // Only author or admin can update
    if (post.authorId !== ctx.user.id && !isAdmin) {
      throw new TRPCError({
        code: "FORBIDDEN",
        message: "You can only edit your own posts or be an admin",
      });
    }

    const [updatedPost] = await db
      .update(postsTable)
      .set({
        title: input.title,
        content: input.content,
        updatedAt: new Date(),
      })
      .where(eq(postsTable.id, input.id))
      .returning();

    return updatedPost;
  }),

Best Practices

  1. Always scope by organizationId - Never query without organization scope
  2. Use protectedOrganizationProcedure - Automatically handles scoping
  3. Verify membership - Always verify user is a member before operations
  4. Check permissions - Verify roles before allowing modifications
  5. Cascade deletes - Use onDelete: "cascade" for organization-scoped data
  6. Index organizationId - Add database indexes on organizationId for performance