Documentation

Consume AppEngine from RSC

Server-component fetching, the server proxy, browser /api/* routes, and error handling.

A Next.js + AppEngine app has three places where data fetches happen, and each plays a different role:

  1. React Server Components — direct fetches to AppEngine on render. The most common pattern.
  2. Browser code — calls forwarded through your own /api/* routes so the AppEngine host and credentials stay server-side.
  3. API route handlers — the proxy itself, plus any business logic that touches multiple AppEngine endpoints atomically.

This tutorial walks through each, using base-app as the source of truth.

Pattern 1 — fetch from a server component

The cheapest, fastest read path. The server component runs once on render, calls AppEngine directly, and the response streams to the browser as HTML. No client-side state, no loading flash.

// src/app/products/page.tsx
import { appengine } from '@/lib/appmint-client';

export default async function ProductsPage() {
  const products = await appengine.request<{ data: any[] }>(
    'get',
    'storefront/products?ps=24',
  );

  return (
    <ul className="grid grid-cols-3 gap-4">
      {products.data.map(p => (
        <li key={p.sk} className="border p-4 rounded">
          <img src={p.data.images?.[0]} alt={p.data.name} />
          <h3>{p.data.name}</h3>
          <p>${p.data.price}</p>
        </li>
      ))}
    </ul>
  );
}

Note what's not there: no useEffect, no useState, no 'use client'. The appengine client uses axios, runs on Node, and the JSON parses into the component before HTML hits the wire.

For an authenticated page, pull the token from cookies:

// src/app/account/orders/page.tsx
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/get-session';
import { appengine } from '@/lib/appmint-client';

export default async function OrdersPage() {
  const session = await getSession();
  if (!session) redirect('/login');

  const orders = await appengine.request<{ data: any[] }>(
    'get',
    `storefront/orders/get/${session.user.sk}`,
    null,
    session.token,
  );

  return <OrdersList orders={orders.data} />;
}

Pattern 2 — getPageData

When you fetch the same data from multiple components in a page, wrap it with React's cache() helper so the request runs once per render. base-app does this for the site/page/blog read that backs every CMS-rendered page:

// base-app/src/lib/get-page-data.ts (excerpt)
import { isStaticPath } from '@/lib-client/helpers';
import { getAppEngineClient } from './appmint-client';
import { appmintConfig } from './appmint-config';
import { getRequestInfo } from './activity-tracker';

export async function getPageData(slug: string[], headers, searchParams?) {
  if (isStaticPath(slug)) return {};

  const headersObj = headers instanceof Promise ? await headers : headers;
  const hostName = headersObj.get('host') || '';
  const protocol = headersObj?.get('x-forwarded-proto') || 'http';
  const requestPath = slug.join('/');
  const url = `${protocol}://${hostName}${requestPath}`;

  let site = null;
  try {
    site = await getAppEngineClient().getSite(hostName);
  } catch (err) {
    console.error('Error fetching site:', err);
  }
  if (!site) {
    return { props: { notFound: true } };
  }

  const siteOrgId = site.pk.split('|')[0];
  let queryParams = '';
  if (searchParams) {
    const params = await searchParams;
    queryParams = new URLSearchParams(params).toString();
  }

  let page = null, requestInfo = null;
  try {
    const clientInfo = await getRequestInfo(headersObj, url);
    clientInfo.orgId = siteOrgId;
    requestInfo = clientInfo;
    page = await getAppEngineClient().getPage(
      hostName, site.data.name, requestPath, queryParams, clientInfo,
    );
  } catch (err) {
    console.error('Error fetching page:', err);
  }

  return {
    pageMeta: {
      title: page?.data?.title || site?.data?.title || appmintConfig.defaultTitle,
      description: page?.data?.description || site?.data?.description || appmintConfig.defaultDescription,
      keywords: page?.data?.keywords || site?.data?.keywords || null,
    },
    siteOrgId,
    site,
    page,
    requestInfo,
  };
}

Then, in the page itself:

// base-app/src/app/[[...slug]]/page.tsx (excerpt)
import { cache } from 'react';
import { headers } from 'next/headers';
import { getPageData } from '@/lib/get-page-data';

const loadPageData = cache(getPageData);

export default async function Page({ params, searchParams }) {
  const resolvedParams = await params;
  const slug = resolvedParams.slug ?? ['index'];
  const headersList = await headers();
  const resolvedSearchParams = await searchParams;

  const { page, site, requestInfo } = await loadPageData(slug, headersList, resolvedSearchParams);
  // ...
}

React.cache(fn) dedupes calls to fn(...sameArgs) within a single request — both the page component and its metadata generator can call loadPageData(slug, headers) and only one HTTP fetch runs.

Pattern 3 — server proxy for browser fetches

Sometimes you need the data on the client — a search-as-you-type box, a cart that updates without re-render, a chart that refreshes every 30 seconds. The browser shouldn't call https://appengine.appmint.io directly because:

  • The AppEngine host might be private (a VPC gateway, a tunnel) and the browser can't reach it.
  • You'd have to ship the orgid and any per-call headers to client JS.
  • CORS becomes a config problem.

The fix is the proxy pattern. Browser code calls /api/... (your own Next.js app) and the route handler forwards to AppEngine.

// base-app/src/lib/proxy-utils.ts (excerpt)
export const getProxiedUrl = (path: string, host: string): string => {
  const isBrowser = typeof window !== 'undefined';
  if (path.startsWith('/api/')) {
    path = path.replace('/api', '');
  } else if (path.startsWith('api')) {
    path = path.replace('api', '');
  }
  if (isBrowser) {
    return `/api/${path}`.replace(/\/\//g, '/');
  }
  return `${host}/${path}`.replace(/\/\//g, '/');
};

The shared client uses this helper to flip URLs based on environment — server-side, it hits AppEngine directly; browser-side, it hits /api/*.

The route handler that forwards is generic:

// src/app/api/[...path]/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { appmintConfig } from '@/lib/appmint-config';

async function proxy(req: Request, ctx: { params: Promise<{ path: string[] }> }) {
  const { path } = await ctx.params;
  const url = new URL(req.url);
  const targetUrl =
    `${appmintConfig.appengine.host}/${path.join('/')}${url.search}`;

  const c = await cookies();
  const token = c.get('token')?.value;

  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
    orgid: appmintConfig.orgId,
  };
  if (token) headers.Authorization = `Bearer ${token}`;

  const body =
    req.method === 'GET' || req.method === 'HEAD' ? undefined : await req.text();

  const upstream = await fetch(targetUrl, {
    method: req.method,
    headers,
    body,
  });

  const text = await upstream.text();
  return new NextResponse(text, {
    status: upstream.status,
    headers: { 'Content-Type': upstream.headers.get('Content-Type') || 'application/json' },
  });
}

export const GET = proxy;
export const POST = proxy;
export const PUT = proxy;
export const PATCH = proxy;
export const DELETE = proxy;

The browser then calls /api/storefront/products?query=shoes and gets exactly what AppEngine would have returned — no host config, no CORS, no leaked secrets.

Don't forward everything

A blanket proxy is convenient for prototypes but lets the browser hit any AppEngine endpoint, including admin ones, with whatever JWT the user holds. For production, narrow the proxy to specific path prefixes (e.g. /api/storefront/* and /api/data/contact/*) and reject the rest.

Pattern 4 — composed business logic in a route handler

When the browser action triggers two or three AppEngine calls that have to happen together, write a domain-specific route handler instead of fanning out from the client.

// src/app/api/checkout/complete/route.ts
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { appengine } from '@/lib/appmint-client';

export async function POST(req: Request) {
  const c = await cookies();
  const token = c.get('token')?.value;
  if (!token) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

  const body = await req.json();

  // 1. Final price calculation server-side — never trust client totals
  const summary = await appengine.request(
    'post',
    'storefront/discounts/calculate',
    {
      productItems: body.items,
      rentalItems: [],
      couponCode: body.couponCode,
      shippingAddress: body.shippingAddress,
    },
    token,
  );

  // 2. Create a Stripe payment intent for the verified total
  const intent = await appengine.request(
    'post',
    'storefront/stripe/intent',
    { amount: Math.round(summary.data.total * 100), currency: 'usd' },
    token,
  );

  return NextResponse.json({
    summary: summary.data,
    clientSecret: intent.clientSecret,
  });
}

The browser sends the cart once, gets back both the verified total and the payment client-secret in one round-trip. Two AppEngine calls happen on the server, neither leaks to the client.

Error handling

AppEngine errors come back as JSON with a consistent shape. The most useful fields:

{
  "statusCode": 400,
  "message": "validation failed",
  "errors": [
    { "field": "email", "message": "must be a valid email" }
  ]
}

Wrap your client to surface the message and code together:

// src/lib/handle-error.ts
import { AxiosError } from 'axios';

export function explainError(err: unknown): { status: number; message: string; details?: any } {
  if (err instanceof AxiosError) {
    const status = err.response?.status ?? 0;
    const data = err.response?.data;
    return {
      status,
      message: data?.message || err.message,
      details: data?.errors,
    };
  }
  if (err instanceof Error) return { status: 500, message: err.message };
  return { status: 500, message: 'Unknown error' };
}

In a server component, throw and rely on error.tsx boundaries:

// src/app/dashboard/page.tsx
export default async function Dashboard() {
  const session = await getSession();
  if (!session) redirect('/login');

  // any throw here lands in /app/dashboard/error.tsx
  const data = await appengine.request('get', `client-data/profile`, null, session.token);
  return <Profile data={data.data} />;
}

// src/app/dashboard/error.tsx
'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return (
    <div>
      <h2>Something went wrong</h2>
      <pre>{error.message}</pre>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

In a route handler, return JSON with the right status:

try {
  // ...
} catch (e) {
  const ex = explainError(e);
  return NextResponse.json({ message: ex.message, details: ex.details }, { status: ex.status });
}

In client code, catch and show:

try {
  await fetch('/api/cart/add', { ... });
} catch (e: any) {
  toast.error(e.message);
}

When to fetch where

Quick rules:

ScenarioWhere to fetch
Initial page render dataRSC, direct
Same data needed by component + metadataRSC + cache() wrapper
Live, user-driven updates (search, cart)Browser → /api/* proxy
Two AppEngine calls that must succeed togetherDomain-specific route handler
Background fetch on a scheduleRoute handler called by cron / Edge Function
Public marketing page on a static deployRSC with revalidate set

The mistake to avoid: fetching the same data both server-side (for first paint) and client-side (for "freshness"). It doubles your request count and you end up with consistency problems. Pick one — usually RSC for first paint and re-fetch via router.refresh() after a mutation.