A Next.js + AppEngine app has three places where data fetches happen, and each plays a different role:
- React Server Components — direct fetches to AppEngine on render. The most common pattern.
- Browser code — calls forwarded through your own
/api/*routes so the AppEngine host and credentials stay server-side. - 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
orgidand 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.
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:
| Scenario | Where to fetch |
|---|---|
| Initial page render data | RSC, direct |
| Same data needed by component + metadata | RSC + cache() wrapper |
| Live, user-driven updates (search, cart) | Browser → /api/* proxy |
| Two AppEngine calls that must succeed together | Domain-specific route handler |
| Background fetch on a schedule | Route handler called by cron / Edge Function |
| Public marketing page on a static deploy | RSC 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.