| name | web-backend |
| description | Guidelines for building backend services, API routes, and data access layers in the /web project using TypeScript, Supabase, and service-oriented architecture. |
Web Backend Development Skill
This skill provides guidance for developing backend services and API routes in the /web project for CRAN/E.
Tech Stack
- TypeScript for type safety
- React Router v7 server-side capabilities (Vite SSR)
- Supabase for database access with generated types
- Zod for runtime validation
- TTLCache (@isaacs/ttlcache) for caching
- AI SDK (@ai-sdk/google) for embeddings
- MCP (Model Context Protocol) server implementation
Architecture Principles
Service Layer Pattern
All data access is organized into service classes with static methods:
- PackageService (
app/data/package.service.ts) - CRAN package operations - AuthorService (
app/data/author.service.ts) - Author/maintainer operations - SearchService (
app/data/search.service.ts) - Universal search - PackageInsightService (
app/data/package-insight.service.server.ts) - Download stats, trends - ArticleService (
app/data/article.service.server.ts) - Press/blog content - PageInsightService (
app/data/page-insight.service.ts) - Analytics
Generated Database Types
Always use the generated Supabase types from app/data/supabase.types.generated.ts:
import { Database, Tables } from "./supabase.types.generated";
// Type a specific table
type Package = Tables<"cran_packages">;
type Author = Tables<"authors">;
// Type the database client
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient<Database>(url, key);
Regenerate types when schema changes:
npm run db.types # Requires Supabase CLI + credentials
Supabase Client Pattern
Single shared client instance (app/data/supabase.server.ts):
import { createClient } from "@supabase/supabase-js";
import { Database } from "./supabase.types.generated";
export const supabase = createClient<Database>(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
);
Always:
- Use typed queries with
from()and typed table names - Handle errors explicitly with
.errorchecks - Use
.maybeSingle()for nullable results,.single()for required results - Use
.select("*")or specific columns for type safety
Example query:
const result = await supabase
.from("cran_packages")
.select("*")
.eq("name", packageName)
.maybeSingle();
if (result.error || !result.data) {
return null;
}
return result.data;
Validation with Zod
Define schemas in *.shape.ts files:
import { z } from "zod";
export const packageNameSchema = z.string().min(1).max(300);
export const packageIdSchema = z.number().int().positive().min(1);
export type PackageSlug = z.infer<typeof packageNameSchema>;
Validate inputs at service boundaries:
static async getPackageByName(name: string) {
packageNameSchema.parse(name); // Throws if invalid
// ... proceed with query
}
Caching Strategy
Use TTLCache for expensive operations:
import TTLCache from "@isaacs/ttlcache";
import { hoursToMilliseconds, minutesToMilliseconds } from "date-fns";
class PackageService {
private static cache = new TTLCache<CacheKey, CacheValue>({
ttl: hoursToMilliseconds(6),
max: 1000,
});
static async getData(key: string) {
const cached = this.cache.get(key);
if (cached) return cached;
const data = await fetchData();
this.cache.set(key, data);
return data;
}
}
Cache keys: Use string literals or union types for type safety.
TTL guidelines:
- Sitemap/metadata: 6 hours
- Search results: 5 minutes
- Download stats: 6 hours
- Dynamic content: 1-5 minutes
Service Class Structure
export class ExampleService {
// Private static cache
private static cache = new TTLCache<CacheKey, CacheValue>({
ttl: hoursToMilliseconds(6),
});
// Static methods only (stateless)
static async getById(id: number) {
// 1. Validate input
idSchema.parse(id);
// 2. Check cache
const cached = this.cache.get(`id:${id}`);
if (cached) return cached;
// 3. Query database
const result = await supabase
.from("table_name")
.select("*")
.eq("id", id)
.maybeSingle();
// 4. Handle errors
if (result.error) {
slog.error("Query failed", { error: result.error, id });
return null;
}
// 5. Cache and return
if (result.data) {
this.cache.set(`id:${id}`, result.data);
}
return result.data;
}
}
API Route Patterns
Action-based API (POST)
File: app/routes/api.search._index.ts
import { ActionFunction } from "react-router";
import { SearchService } from "../data/search.service";
export const action: ActionFunction = async ({ request }) => {
const formData = await request.formData();
const intent = formData.get("intent");
if (intent === "all") {
const query = String(formData.get("q")).slice(0, 100);
const result = await SearchService.searchUniversal(query);
return Response.json(result);
}
throw new Error("Invalid intent");
};
Use for:
- Search operations
- Mutations
- Form submissions
Loader-based API (GET)
export const loader: LoaderFunction = async ({ params, request }) => {
const { packageName } = params;
const data = await PackageService.getPackageByName(packageName);
if (!data) {
throw data(null, { status: 404 });
}
return data(data, {
headers: {
"Cache-Control": "public, max-age=3600",
},
});
};
Use for:
- Page data loading
- Read-only operations
- SSR data fetching
MCP Server Endpoint
File: app/routes/api.mcp.ts
Singleton transport pattern for stateful MCP connections:
let transport: WebStandardStreamableHTTPServerTransport | null = null;
function getTransport() {
if (!transport) {
transport = new WebStandardStreamableHTTPServerTransport({});
const server = getMcpServer();
server.connect(transport);
}
return transport;
}
export async function loader({ request }: LoaderFunctionArgs) {
return getTransport().handleRequest(request);
}
Error Handling
Use structured logging (app/modules/observability.server.ts):
import { slog } from "../modules/observability.server";
try {
const result = await someOperation();
} catch (error) {
slog.error("Operation failed", {
error,
context: { userId, action },
});
throw error;
}
Log levels:
slog.info()- Informational eventsslog.warn()- Warning conditionsslog.error()- Error conditions
Type Patterns
Tables Type Helper
import { Tables } from "../data/supabase.types.generated";
type Pkg = Tables<"cran_packages">;
type Author = Tables<"authors"> & { roles: string[] };
Custom Types
Define in app/data/types.ts:
export type PackageRelationshipType =
| "depends"
| "imports"
| "suggests"
| "linking_to"
| "enhances"
| "reverse_depends"
| "reverse_imports"
| "reverse_suggests"
| "reverse_enhances"
| "reverse_linking_to";
export type PackageDependency = {
relationship_type: PackageRelationshipType;
version: string | null;
related_package: { id: number; name: string };
};
Loader Data Types
type LoaderData = {
item: Tables<"cran_packages">;
relations: Partial<Record<PackageRelationshipType, PackageDependency[]>>;
authors: Author[];
maintainer: Author;
// ... other fields
};
export const loader: LoaderFunction = async ({ params }) => {
const data: LoaderData = await buildLoaderData(params);
return data(data);
};
External API Integration
Example: CRAN Logs API (PackageInsightService)
class PackageInsightService {
private static readonly CRAN_LOGS_URL = "https://cranlogs.r-pkg.org";
private static async fetchFromCRAN<T>(path: string): Promise<T | null> {
try {
const response = await fetch(`${this.CRAN_LOGS_URL}${path}`);
if (!response.ok) return null;
return await response.json();
} catch (error) {
slog.error("CRAN API fetch failed", { error, path });
return null;
}
}
static async getTopDownloadedPackages(period: string, count: number) {
const cached = this.cache.get(`/top/${period}/${count}`);
if (cached) return cached;
const data = await this.fetchFromCRAN<TopDownloadsResponse>(
`/top/${period}/${count}`
);
if (data) this.cache.set(`/top/${period}/${count}`, data);
return data || [];
}
}
Async Operations
Use Promise.allSettled() for parallel operations with error handling:
const [packages, authors] = await Promise.allSettled([
PackageService.searchPackages(query),
AuthorService.searchAuthors(query),
]);
if (packages.status === "rejected") {
slog.error("Package search failed", { error: packages.reason });
}
const packageHits =
packages.status === "fulfilled"
? packages.value
: { combined: [], isSemanticPreferred: false };
Environment Variables
Define in app/data/env.ts with Zod validation:
import { z } from "zod";
const envSchema = z.object({
SUPABASE_URL: z.string().url(),
SUPABASE_ANON_KEY: z.string().min(1),
// ... other vars
});
export const env = envSchema.parse(process.env);
Access: import { env } from "./data/env";
Database Query Patterns
Simple Select
const packages = await supabase
.from("cran_packages")
.select("id, name, title")
.limit(10);
With Filters
const result = await supabase
.from("cran_packages")
.select("*")
.eq("name", packageName)
.maybeSingle();
With Joins (via foreign keys)
const result = await supabase
.from("package_relations")
.select(`
relationship_type,
version,
related_package:related_package_id (
id,
name
)
`)
.eq("package_id", packageId);
Full-text Search
const results = await supabase
.from("cran_packages")
.select("name, title, description")
.textSearch("name", query, { type: "websearch" })
.limit(50);
Vector/Embedding Search
const embeddings = await supabase.rpc("match_package_embeddings", {
query_embedding: embedding,
match_threshold: 0.5,
match_count: 10,
});
Performance Best Practices
- Always limit queries: Use
.limit()to prevent over-fetching - Select specific columns: Avoid
SELECT *when possible - Cache expensive operations: Use TTLCache for repeated queries
- Parallel fetching: Use
Promise.all()orPromise.allSettled() - Deduplicate results: Use
uniqBy()fromes-toolkit - Lazy load heavy data: Split large queries into smaller chunks
Utility Functions
Use es-toolkit for data manipulation:
import { groupBy, uniqBy, omit } from "es-toolkit";
const grouped = groupBy(items, (item) => item.type);
const unique = uniqBy(items, (item) => item.id);
const cleaned = omit(obj, ["sensitiveField"]);
Use date-fns for date operations:
import {
format,
formatRelative,
subDays,
hoursToMilliseconds,
minutesToSeconds,
} from "date-fns";
AI Integration
Embeddings for semantic search:
import { google } from "@ai-sdk/google";
import { embed } from "ai";
const { embedding } = await embed({
model: google.textEmbeddingModel("text-embedding-004"),
value: query,
});
File Organization
- Service classes:
/web/app/data/*.service.tsor*.service.server.ts - Type definitions:
/web/app/data/*.shape.ts(Zod),*.types.generated.ts(Supabase) - Custom types:
/web/app/data/types.ts - API routes:
/web/app/routes/api.*.ts - Server utilities:
/web/app/modules/*.server.ts
Server-Only Code
Files with .server.ts suffix are excluded from client bundles. Use for:
- Supabase client initialization
- Environment variable access
- External API calls
- Logging/observability
- MCP server logic
Testing Strategy
- Typecheck:
npm run typecheckcatches type errors - Build:
npm run buildvalidates server code compiles - Lint:
npm run lintenforces code quality - No unit tests; rely on TypeScript + runtime validation
Common Pitfalls to Avoid
- Don't instantiate service classes - Use static methods only
- Don't bypass Zod validation - Always validate at service boundaries
- Don't ignore Supabase errors - Check
.errorproperty - Don't skip caching - Cache expensive DB/API calls
- Don't use
anytypes - Leverage generated Supabase types - Don't forget
.server.tssuffix - Keep server code out of client bundle - Don't hardcode URLs - Use environment variables
Environment
- Node >= 20 (v23.3.0 recommended)
- npm with
legacy-peer-deps = true - Required env vars:
SUPABASE_URL,SUPABASE_ANON_KEY - Optional:
GOOGLE_GENERATIVE_AI_API_KEYfor embeddings