Skip to main content
Nanahoshi uses better-auth for authentication, providing email/password authentication with organizations support.

Better-auth endpoints

All authentication endpoints are mounted at /api/auth/* on the server:
// Login
POST /api/auth/sign-in/email

// Register
POST /api/auth/sign-up/email

// Sign out
POST /api/auth/sign-out

// Get session
GET /api/auth/get-session

// Organization management
POST /api/auth/organization/create
POST /api/auth/organization/invite-member
GET /api/auth/organization/list
POST /api/auth/organization/set-active
Refer to the better-auth documentation for detailed endpoint specifications.

Session handling

Sessions are stored in cookies and automatically included in all requests when using credentials: "include":
const link = new RPCLink({
  url: `${VITE_SERVER_URL}/rpc`,
  fetch(url, options) {
    return fetch(url, {
      ...options,
      credentials: "include", // Required for session cookies
    });
  },
});

Session context

Every oRPC procedure receives the session in its context:
export async function createContext({ context }: CreateContextOptions) {
  const session = await auth.api.getSession({
    headers: context.req.raw.headers,
  });
  return {
    session, // null if not authenticated
    req: context.req.raw,
  };
}
The session object contains:
session
object | null
user
object
id
string
User ID
email
string
User email
role
'user' | 'admin'
User role
name
string | null
User display name
emailVerified
boolean
Whether email is verified
banned
boolean
Whether user is banned
session
object
id
string
Session ID
userId
string
User ID
activeOrganizationId
string | null
Currently active organization ID
expiresAt
Date
Session expiration time

Protected procedures

Most API endpoints use protectedProcedure, which requires an authenticated session:
const requireAuth = o.middleware(async ({ context, next }) => {
  if (!context.session?.user) {
    throw new ORPCError("UNAUTHORIZED");
  }
  return next({
    context: {
      session: context.session,
    },
  });
});

export const protectedProcedure = publicProcedure.use(requireAuth);
If the session is missing, the procedure throws an UNAUTHORIZED error:
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Unauthorized"
  }
}

Admin procedures

Some endpoints require admin role using adminProcedure:
const requireAdmin = o.middleware(async ({ context, next }) => {
  if (!context.session?.user) {
    throw new ORPCError("UNAUTHORIZED");
  }
  if (context.session.user.role !== "admin") {
    throw new ORPCError("FORBIDDEN");
  }
  return next({
    context: {
      session: context.session,
    },
  });
});

export const adminProcedure = publicProcedure.use(requireAdmin);
Possible errors:
  • UNAUTHORIZED - No session found
  • FORBIDDEN - User is not an admin

Example: Login flow

// 1. Sign in via better-auth
const response = await fetch("/api/auth/sign-in/email", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "[email protected]",
    password: "password123",
  }),
  credentials: "include", // Important!
});

const result = await response.json();

// 2. Session cookie is automatically set
// 3. All subsequent oRPC calls include the session
const books = await client.books.listRecent({ limit: 20 });
// ✓ Authenticated request

Example: Protected route

import { createRoute } from "@tanstack/react-router";
import { redirect } from "@tanstack/react-router";

const booksRoute = createRoute({
  path: "/books",
  beforeLoad: async ({ context }) => {
    // Check if user is authenticated
    const session = await context.orpc.privateData.query();
    
    if (!session) {
      throw redirect({
        to: "/login",
        search: {
          redirect: "/books",
        },
      });
    }
  },
});

Organizations

Nanahoshi supports multi-tenancy via better-auth’s organizations plugin. Each library and its books are scoped to an organization. The active organization is stored in session.session.activeOrganizationId and used to filter queries:
listRecent: protectedProcedure
  .input(z.object({ limit: z.number().int().min(1).max(50).default(20) }).optional())
  .handler(async ({ input, context }) => {
    return await bookService.getRecentBooks(
      input?.limit ?? 20,
      context.session.session.activeOrganizationId ?? undefined,
    );
  }),

Build docs developers (and LLMs) love