# Middleware and Database-Backed Architectures

- **Authors:** Tim Benniks, Lo Etheridge
- **Published:** 2026-04-02T09:34:03.155Z
- **Updated:** 2026-04-02T09:40:16.002Z
- **Tags:** headless, sdk, api, visual_editing

---

Prerequisites: This chapter builds on How Live Preview Works and assumes familiarity with at least one rendering strategy chapter (CSR, SSR, or SSG). The core concepts - hash propagation, Preview vs Delivery API switching, and caching rules - apply here across additional architectural layers.

What you'll be able to do after this chapter:

Route preview context through a BFF or API proxy without exposing tokens to the browser

Ensure edge middleware, redirects, and URL normalization preserve the live preview hash

Bypass database and memory caches for preview requests while keeping production caching intact

## Why this matters

Each layer between the browser and Contentstack (API proxies, BFFs, edge middleware, database caches) can silently drop the preview hash. When that happens, preview falls back to published content with no error.

Many production systems don't fetch content directly from Contentstack in the frontend. They use intermediary layers: BFF services, edge middleware, or database-backed caching. Live Preview works with all of these, but only if preview context flows end to end.

## The Core Rule

Preview context must travel with the request. The live preview hash needs to reach whatever layer actually fetches content from Contentstack. If the frontend assumes preview state while the backend fetches from delivery services, the page renders a mix of draft and published content.

flowchart LR

  frontend["Frontend<br/>Hash in URL"]

  middleware["BFF / Middleware<br/>Preserve hash"]

  contentstack["Contentstack<br/>Preview or delivery"]

  rule1["Rule: Decide preview vs delivery where data is fetched."]

  rule2["If hash is present, bypass caches and databases."]


  frontend --> middleware --> contentstack

  middleware -.-> rule1 -.-> rule2

## API Route as Content Proxy

Instead of calling Contentstack directly from the browser or server components, route all content requests through your own API endpoint. This keeps tokens server-side, centralizes endpoint switching, and lets the same fetch function work for both preview and production.

The [Next.js Middleware Kickstart](https://github.com/contentstack/kickstart-next-middleware) implements this pattern. Here's how it works.

### The API Route

A single API route handles all content requests. It reads query parameters to determine whether to call the Preview API or Delivery API:

```typescript
// app/api/middleware/route.ts
import contentstack from "@contentstack/delivery-sdk";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const content_type_uid = searchParams.get("content_type_uid");
  const pageUrl = searchParams.get("url");
  const live_preview = searchParams.get("live_preview");
  // Optional; stack may send it for time-aligned preview  - forward when present
  const preview_timestamp = searchParams.get("preview_timestamp");

  // Pick the right API based on preview context
  const hostname = live_preview
    ? process.env.CONTENTSTACK_PREVIEW_HOST    // rest-preview.contentstack.com
    : process.env.CONTENTSTACK_DELIVERY_HOST;  // cdn.contentstack.io

  const headers = new Headers({
    "Content-Type": "application/json",
    "api_key": process.env.CONTENTSTACK_API_KEY,
  });

  if (live_preview) {
    headers.set("live_preview", live_preview);
    headers.set("preview_token", process.env.CONTENTSTACK_PREVIEW_TOKEN);
    if (preview_timestamp) headers.set("preview_timestamp", preview_timestamp);
  } else {
    headers.set("access_token", process.env.CONTENTSTACK_DELIVERY_TOKEN);
  }

  const query = encodeURIComponent(JSON.stringify({ url: pageUrl }));
  // v3 entries API: same path shape for preview and delivery; host + headers decide which service answers
  const apiUrl = `https://${hostname}/v3/content_types/${content_type_uid}/entries?environment=${process.env.CONTENTSTACK_ENVIRONMENT}&query=${query}`;

  const response = await fetch(apiUrl, { headers });
  const data = await response.json();
  const entry = data.entries?.[0];

  if (!entry) return Response.json({ error: "Not found" }, { status: 404 });

  // Add edit tags for Visual Builder when in previewif (live_preview) {
    contentstack.Utils.addEditableTags(entry, content_type_uid, true);
  }

  return Response.json(entry);
}
```

### The Fetch Function

Content fetching reads the hash from the SDK and passes it to the API route as a query parameter. The same function works whether called from a server component or a client component:

```typescript
// lib/contentstack.ts
import ContentstackLivePreview from "@contentstack/live-preview-utils";

export async function getPage(baseUrl: string, url: string) {
  // Populated inside the preview iframe after the SDK syncs with the parent frame
  const livePreviewHash = ContentstackLivePreview.hash;
  const apiUrl = new URL("/api/middleware", baseUrl);

  apiUrl.searchParams.set("content_type_uid", "page");
  apiUrl.searchParams.set("url", url);

  if (livePreviewHash) {
    apiUrl.searchParams.set("live_preview", livePreviewHash);
  }

  const result = await fetch(apiUrl);
  return result.json();
}
```

### Dynamic Rendering

The page component decides how to render based on whether preview is active. Preview uses CSR (client-side refetch loop), production uses SSR (server-rendered):

```typescript
// app/page.tsx
export default async function Home() {
  // Preview: client subtree owns SDK + refetch; production: server calls proxy without hash
  if (isPreview) return <Preview path="/" baseUrl={baseUrl} />;

  const page = await getPage(baseUrl, "/");
  return <Page page={page} />;
}
```

The <Preview> component initializes the Live Preview SDK with ssr: false, subscribes to onEntryChange, and refetches through the same /api/middleware route on every edit. The <Page> component is a static renderer - no SDK, no subscriptions.

This works because the API route doesn't care who's calling it. The preview component calls it with a hash and gets draft content. The server component calls it without a hash and gets published content. Same route, same logic, different results.

### Why This Pattern

- Tokens never reach the browser - preview tokens and delivery tokens stay in server-side environment variables


- Single place for endpoint switching - one if (live_preview) check instead of scattered logic


- Works with any rendering strategy - CSR, SSR, or both through the same API route


- Easy to extend - add logging, rate limiting, or response transformation in one place



## Backend-for-Frontend (BFF) Architecture

The proxy pattern above generalizes to any BFF architecture. The frontend calls your BFF, the BFF fetches from Contentstack, and the BFF returns processed content. The BFF must know about preview context:

Frontend forwards preview parameters:

```typescript
async function fetchPage(slug) {
  const params = new URLSearchParams(window.location.search);
  const livePreviewHash = params.get('live_preview');

  const url = new URL(`/api/page/${slug}`, window.location.origin);
  if (livePreviewHash) {
    url.searchParams.set('live_preview', livePreviewHash); // BFF must echo this to Contentstack
  }

  const response = await fetch(url);
  return response.json();
}
```

BFF detects preview and switches API:

```typescript
app.get('/api/page/:slug', async (req, res) => {
  const livePreviewHash = req.query.live_preview;
  const client = createContentstackClient(livePreviewHash); // Must be request-scoped (see Chapter 3 for why)
  const data = await client.getPageBySlug(req.params.slug);

  if (livePreviewHash) {
    res.set('Cache-Control', 'no-store');
  } else {
    res.set('Cache-Control', 'public, max-age=300');
  }

  res.json(data);
});
```

## Edge Middleware

Edge middleware (Vercel Edge, Cloudflare Workers, Lambda@Edge) runs between the CDN and your origin. Three common pitfalls:

### Pitfall 1: Stripping Query Parameters

```typescript
// DANGEROUS: This breaks Live Preview
export function middleware(request) {
  const url = new URL(request.url);
  url.search = '';  // Removes live_preview!return NextResponse.rewrite(url);
}

// FIX: Preserve preview parameters
export function middleware(request) {
  const url = new URL(request.url);
  const previewParams = ['live_preview', 'content_type_uid', 'entry_uid', 'locale'];
  const preserved = new URLSearchParams();

  previewParams.forEach(param => {
    const value = url.searchParams.get(param);
    if (value) preserved.set(param, value);
  });

  url.search = preserved.toString();
  return NextResponse.rewrite(url);
}
```

### Pitfall 2: URL Normalization

```typescript
// DANGEROUS: Redirect may lose query params
export function middleware(request) {
  const url = new URL(request.url);
  if (!url.pathname.endsWith('/')) {
    url.pathname += '/';
    return NextResponse.redirect(url); // Relative redirects can drop search string depending on runtime
  }
}

// FIX: Use URL constructor (preserves search params by default)
export function middleware(request) {
  const url = new URL(request.url);
  if (!url.pathname.endsWith('/')) {
    const newUrl = new URL(url);
    newUrl.pathname += '/';
    return NextResponse.redirect(newUrl);
  }
}
```

### Pitfall 3: Authentication Redirects

```typescript
// DANGEROUS: Redirect loses preview context
export function middleware(request) {
  if (!isAuthenticated(request)) {
    return NextResponse.redirect('/login');  // Preview params lost!
  }
}

// FIX: Forward preview params
export function middleware(request) {
  if (!isAuthenticated(request)) {
    const loginUrl = new URL('/login', request.url);
    const previewHash = request.nextUrl.searchParams.get('live_preview');
    if (previewHash) {
      loginUrl.searchParams.set('return_preview', previewHash);
    }
    return NextResponse.redirect(loginUrl);
  }
}
```

## Database-Backed Content Caching

Some architectures sync Contentstack content to a local database for performance. Preview content must never be written to the database:

```typescript
async function getContent(slug, livePreviewHash) {
  if (livePreviewHash) {
    // ALWAYS fetch directly from Contentstack for preview
    // Never read from or write to database
    return fetchFromContentstackPreview(slug, livePreviewHash);
  }

  // Production: Read from database
const cached = await database.findBySlug(slug);
  if (cached) return cached;

  const fresh = await fetchFromContentstackDelivery(slug);
  await database.upsert(slug, fresh);
  return fresh;
}
```

Preview content is session-scoped. Writing it to shared storage means other users might see drafts, content might outlive the editing session, and different editors' drafts might overwrite each other.

### Bypassing the Cache Layer

```typescript
class ContentService {
  async getPage(slug, options = {}) {
    const { livePreviewHash } = options;

    if (livePreviewHash) {
      // Bypass all caches for preview
      return this.fetchFromPreviewAPI(slug, livePreviewHash);
    }

    return this.fetchWithCaching(slug);
  }

  async fetchFromPreviewAPI(slug, hash) {
    const response = await fetch(
      `https://rest-preview.contentstack.com/...`,
      {
        headers: {
          'preview_token': process.env.PREVIEW_TOKEN,
          'live_preview': hash
        }
      }
    );
    return response.json();
    // No caching, no database write
  }

  async fetchWithCaching(slug) {
    const memCached = this.memoryCache.get(slug);
    if (memCached) return memCached;

    const dbCached = await this.database.findBySlug(slug);
    if (dbCached) {
      this.memoryCache.set(slug, dbCached);
      return dbCached;
    }

    const fresh = await this.fetchFromDeliveryAPI(slug);
    await this.database.upsert(slug, fresh);
    this.memoryCache.set(slug, fresh);
    return fresh;
  }
}
```

## Server-Side Data Aggregation

When pages combine content from multiple sources, only Contentstack content uses the preview hash:

```typescript
async function getPageData(slug, livePreviewHash) {
  const [content, products, user] = await Promise.all([
    livePreviewHash
      ? fetchContentstackPreview(slug, livePreviewHash)
      : fetchContentstackDelivery(slug),

    fetchProducts(), // Commerce/CMS-agnostic sources: keep on delivery paths
    fetchCurrentUser()
  ]);

  return { content, products, user, isPreview: !!livePreviewHash };
}
```

## Debugging Context Propagation

When Live Preview isn't working in a complex architecture, trace the hash through each layer:

- Is the hash in the initial URL? Check browser devtools


- Does the frontend forward the hash to the BFF? Log BFF requests


- Does the BFF use preview API? Log API calls


- Does middleware preserve the hash? Log before/after middleware


- Is the database being bypassed? Check data source for preview requests



## Key Takeaways

- Preview context must travel end to end. Every layer must forward the hash.


- A BFF/API proxy centralizes token management and endpoint switching in one place.


- Edge middleware is a common source of silent hash loss (URL normalization, parameter stripping, auth redirects).


- Never write preview content to a shared database or persistent cache.



## Check Your Understanding

- Your BFF returns cached content for performance. An editor activates Live Preview, but their changes don't appear. What's the most likely cause, and how would you fix the BFF to handle both preview and production correctly?


- Why should preview content never be written to a shared database, even temporarily? What would happen if two editors previewed the same page simultaneously and your database stored both drafts?


- Your edge middleware normalizes URLs by adding a trailing slash with a redirect. Why might this break Live Preview, and how would you fix it?



## Exercise: Trace the Hash

Map the path the live preview hash takes through your own architecture, from the CMS iframe URL to the final Contentstack API call. For each layer, answer:

- Does this layer receive the hash? How? (query parameter, header, cookie)


- Does this layer forward the hash? To where?


- Could this layer silently drop the hash? (redirects, URL rewrites, parameter stripping)


- Does this layer cache responses? Is caching bypassed when the hash is present?



If you find a gap - a layer that doesn't forward the hash or doesn't bypass caching - that's where your preview will break.




---

## Frequently asked questions

### What is the core requirement for Live Preview in a BFF or proxy architecture?

The live preview hash must be forwarded end to end to the layer that fetches content from Contentstack. The data-fetching layer must switch between Preview and Delivery APIs based on that hash.

### How do you keep Contentstack tokens out of the browser while supporting Live Preview?

Route content requests through a server-side API endpoint (BFF/proxy) and store tokens in environment variables. The frontend passes only the preview hash; the proxy attaches the preview or delivery tokens server-side.

### What common edge middleware behaviors break Live Preview?

Stripping query parameters, redirects that drop the search string during URL normalization, and authentication redirects that fail to forward preview parameters. Each can remove the live_preview hash without throwing an error.

### Should preview responses be written to a database or shared cache?

No. Preview content is session-scoped and must bypass database and persistent caches to avoid leaking drafts to other users or overwriting content across editors.

### How do you debug missing preview updates in a multi-layer architecture?

Trace the hash through every layer: initial URL, frontend request to BFF, BFF call to Contentstack, middleware rewrites/redirects, and cache/database behavior. Confirm the preview API is used and caching is bypassed when the hash is present.



---

## Chapter Navigation

**← Previous:** [Static Site Generation](https://developers.contentstack.com/guides/the-ultimate-guide-to-contentstack-visual-building/static-site-generation)

**Next →:** [Edit Tags and Visual Builder](https://developers.contentstack.com/guides/the-ultimate-guide-to-contentstack-visual-building/edit-tags-and-visual-builder)
