Documentation
Documentation
Introduction

Getting Started

Getting StartedInstallationQuick StartProject Structure

Architecture

Architecture OverviewTech StacktRPC MiddlewareDesign Principles

Patterns

Code Patterns & ConventionsFeature ModulesError HandlingType Safety

Database

DatabaseSchema DefinitionDatabase OperationsMigrationsCaching

API

tRPCProceduresRouterstRPC Proxy Setup
APIsOpenAPIREST Endpoints

Auth & Access

AuthenticationConfigurationOAuth ProvidersRolesSession Management
AuthorizationUser RolesPermissions

Routing & i18n

RoutingDeclarative RoutingNavigation
InternationalizationTranslationsLocale Routing

Components & UI

ComponentsButtonsFormsNavigationDialogs
StylesTailwind CSSThemingTypography

Storage

StorageConfigurationUsageBuckets

Configuration

ConfigurationEnvironment VariablesFeature Flags

Templates

Template GuidesCreate New FeatureCreate New PageCreate Database TableCreate tRPC RouterAdd Translations

Development

DevelopmentCommandsAI AgentsBest Practices

Caching

Cache tags and invalidation strategy

The project uses Next.js caching primitives (unstable_cache) with table-based cache tags for automatic invalidation.

Why Caching?

Database queries can be expensive. Caching:

  • Reduces database load - Fewer queries to PostgreSQL
  • Improves performance - Serve from cache instead of DB
  • Lowers latency - Faster response times
  • Scales better - Handle more requests with less resources

Next.js caches on the server, not the browser. Perfect for server components!

Cache Strategy

Read Operations: Cached

Operations that read data are automatically cached:

  • listDocuments() - Cached with table tag
  • getDocument() - Cached with table tag
  • countDocuments() - Cached with table tag
  • listTable() - Cached with table tag

Write Operations: Revalidate

Operations that write data automatically revalidate cache:

  • createDocument() - Revalidates table tag
  • updateDocument() - Revalidates table tag
  • removeDocument() - Revalidates table tag

This ensures cached data stays fresh.

Table Tags

Define cache tags in src/db/tags.ts:

export enum TableTags {
  users = "users",
  settings = "settings",
  organizations = "organizations",
  members = "members",
  invitations = "invitations",
  subscriptions = "subscriptions",
  apiKeys = "apiKeys",
  // Add new tables here
}

Every table should have a tag for proper cache invalidation!

Adding a New Tag

When creating a new table:

  1. Add tag to TableTags enum
  2. Use the tag in your operations
// src/db/tags.ts
export enum TableTags {
  // ...existing
  posts = "posts", // NEW
}

How Caching Works

Automatic via Operations

When using createDrizzleOperations, caching is automatic:

import { createDrizzleOperations } from "@/db/drizzle-operations";
import { posts } from "@/db/tables/posts";

const operations = createDrizzleOperations<typeof posts, Post>({
  table: posts,
});

// Cached read
export async function list() {
  return operations.listDocuments(); // ✅ Cached with "posts" tag
}

// Write with revalidation
export async function create(data: NewPost) {
  return operations.createDocument(data); // ✅ Revalidates "posts" tag
}

Manual Cache Control

For custom queries, use unstable_cache:

import { unstable_cache } from "next/cache";
import { dbDrizzle } from "@/db";
import { TableTags } from "@/db/tags";

export const getPostWithAuthor = unstable_cache(
  async (postId: string) => {
    return await dbDrizzle.query.posts.findFirst({
      where: eq(posts.id, postId),
      with: {
        author: true,
      },
    });
  },
  ["post-with-author"], // Cache key
  {
    tags: [TableTags.posts], // Cache tag
    revalidate: 3600, // Optional: revalidate after 1 hour
  }
);

The createDrizzleOperations abstraction automatically wraps operations with unstable_cache, so you only need manual caching for custom queries.


## Cache Invalidation

### Automatic Invalidation

Operations handle revalidation automatically:

```typescript
// This revalidates all cache entries tagged with "posts"
await operations.createDocument({
  title: "New Post",
  userId: "123",
});

Manual Invalidation

Use revalidateTag for custom invalidation:

import { revalidateTag } from "next/cache";
import { TableTags } from "@/db/tags";

// Revalidate all "posts" cache entries
revalidateTag(TableTags.posts);

// Revalidate multiple tags
revalidateTag(TableTags.posts);
revalidateTag(TableTags.users);

When to Manually Invalidate

You typically need manual invalidation when:

  1. External data changes - Data updated outside your app
  2. Complex operations - Multiple tables affected
  3. Batch operations - Bulk updates
  4. Custom queries - Not using operations abstraction

Example:

export async function publishPost(postId: string) {
  await dbDrizzle
    .update(posts)
    .set({ published: true, publishedAt: new Date() })
    .where(eq(posts.id, postId));

  // Manually revalidate
  revalidateTag(TableTags.posts);
}

Cache Configuration

Cache Duration

Control how long cache entries live:

export const getCachedUsers = unstable_cache(
  async () => {
    return operations.listDocuments();
  },
  ["users-list"],
  {
    tags: [TableTags.users],
    revalidate: 3600, // Revalidate after 1 hour (in seconds)
  }
);

Options:

  • revalidate: false - Cache forever (until manually invalidated)
  • revalidate: 0 - No caching
  • revalidate: 60 - Cache for 60 seconds

No Caching

For operations that should never be cached:

import { unstable_noStore } from "next/cache";

export async function getRealTimeData() {
  unstable_noStore(); // Opt out of caching
  
  return await dbDrizzle.query.events.findMany({
    where: eq(events.type, "realtime"),
  });
}

Performance Considerations

Cache Hit Ratio

Monitor how often cache is used vs. database queries:

export async function getWithMetrics(id: string) {
  const start = Date.now();
  const result = await operations.getDocument(id);
  const duration = Date.now() - start;
  
  console.log(`Query took ${duration}ms`);
  return result;
}

Cache Key Strategy

Use specific cache keys for better hit rates:

// ❌ Bad: Too generic
const data = unstable_cache(
  () => getData(),
  ["data"]
);

// ✅ Good: Specific to query
const data = unstable_cache(
  () => getData(),
  ["data", userId, filter, page.toString()]
);

Stale-While-Revalidate

Allow stale data while revalidating in background:

export const getUsers = unstable_cache(
  async () => operations.listDocuments(),
  ["users"],
  {
    tags: [TableTags.users],
    revalidate: 60, // Revalidate every 60s
    // Serve stale data while revalidating
  }
);

Common Patterns

Cache-Aside Pattern

Use unstable_cache with operations:

import { unstable_cache } from "next/cache";
import { TableTags } from "@/db/tags";

export const getOrganization = unstable_cache(
  async (id: string) => {
    return await operations.getDocument(id);
  },
  ["organization"],
  {
    tags: [TableTags.organizations],
  }
);

Write-Through Pattern

Write to DB and invalidate cache:

export async function updateOrganization(id: string, data: Partial<Organization>) {
  // Write to DB
  const result = await operations.updateDocument(id, data);
  
  // Cache automatically invalidated by operations
  return result;
}

Multi-Table Invalidation

Invalidate multiple tables when data spans tables:

export async function createPostWithTags(
  postData: NewPost,
  tagIds: string[]
) {
  const result = await dbDrizzle.transaction(async (tx) => {
    const [post] = await tx.insert(posts).values(postData).returning();
    
    await tx.insert(postTags).values(
      tagIds.map(tagId => ({ postId: post.id, tagId }))
    );
    
    return post;
  });

  // Invalidate both tables
  revalidateTag(TableTags.posts);
  revalidateTag(TableTags.tags);
  
  return result;
}

Debugging Cache

Check Cache Status

import { unstable_cache } from "next/cache";

export const getUsers = unstable_cache(
  async () => {
    console.log("🔴 Cache MISS - Querying database");
    return operations.listDocuments();
  },
  ["users"],
  {
    tags: [TableTags.users],
  }
);

// First call: "🔴 Cache MISS"
await getUsers();

// Second call: No log (cache hit)
await getUsers();

Force Cache Refresh

import { revalidateTag } from "next/cache";

// Clear cache for testing
revalidateTag(TableTags.users);

// Next call will query DB
await getUsers();

Best Practices

Follow these guidelines for effective caching.

  1. Use operations abstraction - Automatic caching and invalidation
  2. Define table tags - Every table needs a tag in src/db/tags.ts
  3. Tag custom queries - Always add cache tags to custom queries
  4. Revalidate on writes - Clear cache when data changes
  5. Use specific cache keys - Include relevant parameters in keys
  6. Monitor performance - Track cache hit rates
  7. Consider revalidate time - Balance freshness vs. performance
  8. Invalidate related data - Clear cache for dependent tables

Real-World Example

From src/features/api-keys/functions.ts:

import { createDrizzleOperations } from "@/db/drizzle-operations";
import { apiKeys } from "@/db/tables";
import { ApiKey } from "@/features/api-keys/schema";

const operations = createDrizzleOperations<typeof apiKeys, ApiKey>({
  table: apiKeys,
});

// ✅ Automatically cached with "apiKeys" tag
export async function list() {
  return operations.listDocuments();
}

// ✅ Automatically revalidates "apiKeys" tag
export async function create(data: NewApiKey) {
  return operations.createDocument(data);
}

// ✅ Automatically revalidates "apiKeys" tag
export async function update(id: string, data: Partial<ApiKey>) {
  return operations.updateDocument(id, data);
}

// ✅ Automatically revalidates "apiKeys" tag
export async function remove(id: string) {
  return operations.removeDocument(id);
}

No manual cache management needed! 🎉

Edge Cases

Time-Sensitive Data

For data that must be real-time:

import { unstable_noStore } from "next/cache";

export async function getCurrentBidPrice() {
  unstable_noStore(); // Never cache
  
  return await dbDrizzle.query.auctions.findFirst({
    orderBy: desc(auctions.currentBid),
  });
}

User-Specific Data

Include user ID in cache key:

export const getUserSettings = unstable_cache(
  async (userId: string) => {
    return operations.listDocuments(eq(settings.userId, userId));
  },
  ["user-settings"], // Base key
  {
    tags: [TableTags.settings],
    revalidate: 300, // 5 minutes
  }
);

// Each user gets their own cache entry
await getUserSettings("user-1");
await getUserSettings("user-2");

Complex Dependencies

When one table depends on another:

export async function updateUserProfile(userId: string, data: ProfileUpdate) {
  await operations.updateDocument(userId, data);
  
  // User profile affects posts display
  revalidateTag(TableTags.users);
  revalidateTag(TableTags.posts); // Also invalidate posts
}

Next Steps

  • Operations - CRUD operations with auto-caching
  • Schema Definition - Define tables
  • Feature Modules - Organize features
  • Performance - Optimize application performance

On this page

Why Caching?
Cache Strategy
Read Operations: Cached
Write Operations: Revalidate
Table Tags
Adding a New Tag
How Caching Works
Automatic via Operations
Manual Cache Control
Manual Invalidation
When to Manually Invalidate
Cache Configuration
Cache Duration
No Caching
Performance Considerations
Cache Hit Ratio
Cache Key Strategy
Stale-While-Revalidate
Common Patterns
Cache-Aside Pattern
Write-Through Pattern
Multi-Table Invalidation
Debugging Cache
Check Cache Status
Force Cache Refresh
Best Practices
Real-World Example
Edge Cases
Time-Sensitive Data
User-Specific Data
Complex Dependencies
Next Steps