This document explains how the TuvixRSS frontend (React app) and backend (tRPC API) are linked, how they communicate, and how types are shared between them.
- Architecture Overview
- Package Structure
- tRPC Client Setup
- Type Sharing Mechanism
- Authentication Flow
- API Communication
- Development Workflow
- Build & Deployment
- Environment Configuration
TuvixRSS uses a monorepo structure with two main packages:
TuvixRSS/
├── packages/
│ ├── api/ # tRPC backend (Node.js/Cloudflare Workers)
│ └── app/ # React frontend (Vite)
└── package.json # Workspace root
React Component
↓
Custom Hook (e.g., useArticles)
↓
trpc.articles.list.useQuery()
↓
httpBatchLink
↓
POST /trpc/articles.list (with session cookie)
↓
Express/Cloudflare Adapter
↓
Context Middleware (DB + Better Auth session)
↓
Middleware Stack (auth, rate limit, permissions)
↓
Procedure Handler (articles.list)
↓
Database Query (Drizzle ORM)
↓
JSON Response
↓
TanStack Query Cache
↓
React Component Re-render (with typed data)
packages/api/
├── src/
│ ├── trpc/
│ │ ├── init.ts # tRPC instance & middleware
│ │ ├── router.ts # Main router (exports AppRouter type)
│ │ └── context.ts # Request context
│ ├── routers/ # API endpoints
│ ├── adapters/ # Express & Cloudflare
│ ├── db/ # Database schema & client
│ ├── auth/ # Better Auth authentication
│ └── types/ # Shared types
├── package.json
└── tsconfig.json
Key Export: AppRouter type from packages/api/src/trpc/router.ts
packages/app/
├── src/
│ ├── lib/
│ │ ├── api/
│ │ │ ├── trpc.ts # tRPC client setup
│ │ │ └── hooks/ # Custom API hooks
│ │ └── utils.ts
│ ├── components/ # React components
│ ├── pages/ # Route components
│ └── main.tsx # App entry point
├── package.json
└── tsconfig.json
Key Import: AppRouter type from @tuvix/api
Location: packages/app/src/lib/api/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import { httpBatchLink } from "@trpc/client";
import type { AppRouter } from "@tuvix/api";
// Create typed tRPC instance
export const trpc = createTRPCReact<AppRouter>();
// Create client with configuration
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: import.meta.env.VITE_API_URL || "http://localhost:3001/trpc",
// Better Auth handles authentication via HTTP-only cookies
// No need to manually add Authorization headers
headers() {
return {};
},
}),
],
});-
Type Inference
- Imports
AppRoutertype from backend - Zero runtime connection - pure TypeScript type inference
- Full autocomplete for all procedures
- Imports
-
HTTP Batch Link
- Batches multiple requests into single HTTP call
- Reduces network overhead
- Configurable URL via environment variable
-
Authentication
- Better Auth handles sessions via HTTP-only cookies
- No manual token management needed
- Sessions automatically included in requests
-
Type Exports
import type { RouterInputs, RouterOutputs } from "@trpc/react-query";
// Input types for procedures
export type ArticleListInput = RouterInputs<AppRouter>["articles"]["list"];
// Output types for procedures
export type ArticleListOutput = RouterOutputs<AppRouter>["articles"]["list"];Location: packages/app/src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { trpc, trpcClient } from './lib/api/trpc'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')!).render(
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</trpc.Provider>
)TuvixRSS achieves full type safety without code generation through TypeScript's module system.
Root package.json:
{
"workspaces": ["packages/api", "packages/app"]
}API package.json:
{
"name": "@tuvix/api",
"version": "1.0.0",
"main": "dist/index.js",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./dist/index.js"
}
}
}App package.json:
{
"name": "@tuvix/app",
"dependencies": {
"@tuvix/api": "workspace:*"
}
}- Backend exports type:
// packages/api/src/trpc/router.ts
export const appRouter = router({
auth: authRouter,
articles: articlesRouter,
// ...
});
export type AppRouter = typeof appRouter;- Frontend imports type:
// packages/app/src/lib/api/trpc.ts
import type { AppRouter } from "@tuvix/api";
export const trpc = createTRPCReact<AppRouter>();- TypeScript resolves types at compile time:
- App's
tsconfig.jsonincludespathsmapping for@tuvix/api - TypeScript compiler reads types directly from source
- No build step required for types
- Changes to backend immediately available to frontend
- App's
- No Code Generation: No build step for types
- Instant Updates: Type changes reflect immediately
- Full Type Safety: Input/output types for all procedures
- Autocomplete: VSCode/IDE autocomplete for all API calls
- Compile-Time Errors: Invalid API calls caught before runtime
// Frontend: packages/app/src/lib/hooks/useAuth.ts
export function useRegister() {
return authClient.signUp.email.useMutation({
onSuccess: () => {
// Better Auth automatically creates session via HTTP-only cookie
// Redirect to dashboard
},
});
}
// Usage in component:
const register = useRegister();
register.mutate({
email: "john@example.com",
password: "secure123",
name: "john", // Username
});Backend flow:
- Better Auth validates input
- Hashes password with scrypt
- Creates user in database
- Creates session (HTTP-only cookie)
- Returns user data
export function useLogin() {
return authClient.signIn.username.useMutation({
onSuccess: () => {
// Better Auth automatically creates session via HTTP-only cookie
// Redirect to dashboard
},
});
}
// Usage:
const login = useLogin();
login.mutate({
username: "john",
password: "secure123",
});Backend flow:
- Find user by username
- Verify password with scrypt (Better Auth)
- Check if user banned
- Check account lockout (after maxLoginAttempts failed attempts)
- Create session (HTTP-only cookie)
- Log security event
- Return user data
Better Auth manages sessions via HTTP-only cookies. No manual token handling needed.
Location: packages/api/src/trpc/context.ts
export async function createContext({ req, env }): Promise<Context> {
let user: AuthUser | null = null;
// Better Auth handles session extraction from HTTP-only cookies
const session = await auth.api.getSession({ headers: req.headers });
if (session?.user) {
user = {
userId: session.user.id as number,
username: session.user.username || session.user.name || "",
role: (session.user.role || "user") as "user" | "admin",
};
}
return {
db: initDatabase(env),
user,
env,
headers: Object.fromEntries(req.headers),
req,
};
}// Backend: packages/api/src/trpc/init.ts
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
// Check if user exists and not banned
const user = await ctx.db.query.user.findFirst({
where: eq(schema.user.id, ctx.user.userId),
});
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
if (user.banned) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Account is banned",
});
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
export const protectedProcedure = t.procedure.use(isAuthed);export function useLogout() {
const queryClient = useQueryClient();
return authClient.signOut.useMutation({
onSuccess: () => {
queryClient.clear(); // Clear all cached data
// Redirect to login
},
});
}Frontend Hook:
// packages/app/src/lib/api/hooks/useArticles.ts
export function useArticles(filters: ArticleFilters) {
return trpc.articles.list.useQuery({
offset: filters.page * filters.limit,
limit: filters.limit,
filter: filters.filter, // "all" | "unread" | "read" | "saved"
subscriptionId: filters.subscriptionId,
categoryId: filters.categoryId,
searchTerm: filters.searchTerm,
});
}Component Usage:
// packages/app/src/components/ArticleList.tsx
export function ArticleList() {
const { data, isLoading, error } = useArticles({
page: 0,
limit: 50,
filter: 'unread'
})
if (isLoading) return <Loading />
if (error) return <Error message={error.message} />
return (
<div>
{data.map(article => (
<ArticleItem key={article.id} article={article} />
))}
</div>
)
}Type Safety:
// Full autocomplete and type checking
data: Article[] // Inferred from backend
error: TRPCClientError // Typed error
isLoading: booleanFrontend Hook:
export function useMarkArticleRead() {
const queryClient = useQueryClient();
return trpc.articles.markRead.useMutation({
onSuccess: () => {
// Invalidate articles cache to refetch
queryClient.invalidateQueries(["trpc", "articles", "list"]);
},
});
}Component Usage:
export function ArticleItem({ article }) {
const markRead = useMarkArticleRead()
const handleMarkRead = () => {
markRead.mutate({
id: article.id,
read: true
})
}
return (
<div>
<h3>{article.title}</h3>
<button onClick={handleMarkRead}>
Mark as Read
</button>
</div>
)
}export function useInfiniteArticles(filters: ArticleFilters) {
return trpc.articles.list.useInfiniteQuery(
{
limit: 50,
filter: filters.filter,
},
{
getNextPageParam: (lastPage, allPages) => {
if (lastPage.length < 50) return undefined;
return allPages.length * 50; // Next offset
},
}
);
}
// Usage:
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteArticles({ filter: "unread" });export function useMarkArticleSaved() {
const queryClient = useQueryClient();
return trpc.articles.markSaved.useMutation({
onMutate: async ({ id, saved }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries(["trpc", "articles", "list"]);
// Snapshot current value
const previous = queryClient.getQueryData(["trpc", "articles", "list"]);
// Optimistically update
queryClient.setQueryData(["trpc", "articles", "list"], (old) =>
old.map((article) =>
article.id === id ? { ...article, saved } : article
)
);
return { previous };
},
onError: (err, variables, context) => {
// Rollback on error
queryClient.setQueryData(["trpc", "articles", "list"], context.previous);
},
onSettled: () => {
// Refetch after mutation
queryClient.invalidateQueries(["trpc", "articles", "list"]);
},
});
}Root package.json scripts:
{
"scripts": {
"dev": "concurrently \"pnpm dev:api\" \"pnpm dev:app\"",
"dev:api": "pnpm --filter @tuvix/api dev",
"dev:app": "pnpm --filter @tuvix/app dev"
}
}Starting development:
# Start both API and app
pnpm dev
# Or individually:
pnpm dev:api # Starts on http://localhost:3001
pnpm dev:app # Starts on http://localhost:5173- API: Uses
tsx watchfor automatic restart on changes - App: Vite HMR for instant updates
- Types: TypeScript compiler watches both projects
# Check types in both packages
pnpm type-check
# Individual packages
pnpm --filter @tuvix/api type-check
pnpm --filter @tuvix/app type-check# Generate migration from schema changes
pnpm db:generate
# Run migrations
pnpm db:migrateRoot package.json:
{
"scripts": {
"build": "pnpm build:api && pnpm build:app",
"build:api": "pnpm --filter @tuvix/api build",
"build:app": "pnpm --filter @tuvix/app build"
}
}Build order matters: API must be built first for type resolution.
{
"scripts": {
"build": "tsc && esbuild src/adapters/express.ts --bundle --platform=node --outfile=dist/express.js"
}
}Output:
dist/express.js- Bundled Express server- Type declarations in
dist/
{
"scripts": {
"build": "vite build"
}
}Output:
dist/- Static files (HTML, JS, CSS)- Optimized and minified for production
# Multi-stage build
FROM node:20 AS builder
WORKDIR /app
COPY . .
RUN pnpm install
RUN pnpm build
FROM node:20-slim
WORKDIR /app
COPY --from=builder /app/packages/api/dist ./api
COPY --from=builder /app/packages/app/dist ./app
CMD ["node", "api/express.js"]Docker Compose:
services:
api:
build: .
ports:
- "3001:3001"
environment:
- DATABASE_PATH=/data/tuvix.db
- BETTER_AUTH_SECRET=${BETTER_AUTH_SECRET}
- CORS_ORIGIN=https://app.tuvix.dev
volumes:
- ./data:/data
app:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./packages/app/dist:/usr/share/nginx/html
environment:
- VITE_API_URL=https://api.tuvix.dev/trpcAPI: Cloudflare Workers
# Deploy API
cd packages/api
pnpm wrangler deployApp: Cloudflare Pages
# Deploy frontend
cd packages/app
pnpm build
pnpm wrangler pages deploy distEnvironment Variables (Cloudflare Dashboard):
VITE_API_URL=https://api.tuvix.dev/trpc
API: Vercel Serverless Functions
// vercel.json
{
"rewrites": [{ "source": "/trpc/:path*", "destination": "/api/trpc" }]
}App: Static Site
vercel --prodAPI (.env):
DATABASE_PATH=./data/tuvix.db
PORT=3001
CORS_ORIGIN=http://localhost:5173
NODE_ENV=development
BETTER_AUTH_SECRET=dev-secret-change-in-productionApp (.env):
VITE_API_URL=http://localhost:3001/trpcAPI:
DATABASE_PATH=/data/tuvix.db
PORT=3001
CORS_ORIGIN=https://app.tuvix.dev,https://www.tuvix.dev
NODE_ENV=production
BETTER_AUTH_SECRET=<strong-random-secret>
BASE_URL=https://api.tuvix.dev
RESEND_API_KEY=<resend-api-key>
# Optional: For cross-subdomain cookies (if frontend and API on different subdomains)
# COOKIE_DOMAIN=tuvix.devApp:
VITE_API_URL=https://api.tuvix.dev/trpcAPI:
- Node.js: Uses
dotenvpackage - Cloudflare: Configured in
wrangler.tomlor dashboard
App:
- Vite loads
.envfiles automatically - Variables prefixed with
VITE_are exposed to client - Build-time replacement (not runtime)
Request:
POST /trpc/auth.login HTTP/1.1
Host: localhost:3001
Content-Type: application/json
{
"username": "john",
"password": "secure123"
}
Response:
{
"result": {
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}Request:
POST /trpc/articles.list HTTP/1.1
Host: localhost:3001
Cookie: better-auth.session_token=...
Content-Type: application/json
{
"offset": 0,
"limit": 50,
"filter": "unread"
}
Response:
{
"result": {
"data": [
{
"id": 1,
"title": "Article Title",
"description": "Article description...",
"link": "https://example.com/article",
"pubDate": "2025-01-13T00:00:00.000Z",
"read": false,
"saved": false,
"source": {
"id": 1,
"title": "Example Blog",
"url": "https://example.com/feed.xml"
}
}
]
}
}tRPC batches multiple requests into a single HTTP call:
Request:
POST /trpc/articles.list,articles.markRead HTTP/1.1
Host: localhost:3001
Cookie: better-auth.session_token=...
Content-Type: application/json
[
{
"offset": 0,
"limit": 50
},
{
"id": 1,
"read": true
}
]
Response:
[
{
"result": {
"data": [
/* articles */
]
}
},
{
"result": {
"data": {
"success": true
}
}
}
]// Backend: packages/api/src/routers/articles.ts
throw new TRPCError({
code: "BAD_REQUEST",
message: "Article not found",
});const { data, error } = trpc.articles.list.useQuery(...)
if (error) {
// error.data.code: 'BAD_REQUEST' | 'UNAUTHORIZED' | etc.
// error.message: Human-readable message
console.error(error.message)
}BAD_REQUEST- Invalid inputUNAUTHORIZED- Not authenticatedFORBIDDEN- Not authorized (e.g., suspended, not admin)NOT_FOUND- Resource not foundTOO_MANY_REQUESTS- Rate limit exceededINTERNAL_SERVER_ERROR- Server error
The TuvixRSS frontend and backend are integrated through:
- Monorepo Structure: Shared types via workspace packages
- tRPC: Type-safe API calls with zero code generation
- TypeScript: End-to-end type safety from database to UI
- TanStack Query: Caching, invalidation, optimistic updates
- Better Auth: HTTP-only cookie session-based authentication
- Environment Variables: Configuration for different deployments
- Build Pipeline: API first, then app
This architecture provides:
- Full type safety across the stack
- Instant type updates between packages
- Excellent developer experience with autocomplete
- Flexible deployment options (Docker, Cloudflare, Vercel)
- Secure authentication and authorization
- Optimized performance with caching and batching
Last Updated: 2025-12-13