Documentation

JavaScript / TypeScript

The canonical Next.js + AppEngine integration pattern, lifted from base-app.

There is no published @appmint/sdk npm package yet. Instead, the canonical pattern is a small set of files you copy into src/lib/ and own. Two reference apps ship this layout already — base-app (full SaaS template) and yugo (booking site). This page documents the structure so you can replicate it cleanly in any Next.js project.

The whole client is ~600 lines spread across eight files. Each file does one thing.

File layout

src/lib/
  appmint-config.ts        // env -> typed config
  appmint-endpoints.ts     // every endpoint URL constant
  appmint-client.ts        // HTTP core (axios + auth + retry)
  appmint-auth.ts          // sign-in / sign-up / refresh
  active-session.ts        // cookie-based JWT, client + server
  proxy-utils.ts           // browser -> /api/* -> AppEngine
  repository-api.ts        // generic CRUD (one of N domain APIs)
  storefront-api.ts        // domain API per AppEngine module
middleware.ts              // gates routes by token cookie

Add domain modules (crm-api.ts, events-api.ts, finance-api.ts) as you need them. Each follows the same shape as repository-api.ts.

Config

A single file reads env and exports typed config. No process.env access elsewhere in the codebase.

// src/lib/appmint-config.ts
const appmintConfig = {
  useAppEngine: true,
  appengine: {
    host: process.env.APPENGINE_ENDPOINT,    // e.g. https://appengine.appmint.io
    appId: process.env.APP_ID,
    key: process.env.APP_KEY,
    secret: process.env.APP_SECRET,
  },
  siteURL: process.env.SITE_URL,
  orgId: process.env.ORG_ID,
  siteName: process.env.SITE_NAME,
  domainAsOrg: process.env.DOMAIN_AS_ORG,
  defaultLocale: process.env.DEFAULT_LOCALE,
};

export { appmintConfig };

Required env:

FieldTypeDescription
APPENGINE_ENDPOINT*string

The AppEngine base URL. Typically https://appengine.appmint.io.

ORG_ID*string

Your tenant id. Sent on every request as the orgid header.

APP_ID*string

The app id from the AppEngine system-user credentials. Used to obtain a server-side JWT.

APP_KEY*string

The app key portion of the credential.

APP_SECRET*string

The app secret. Server-only — never expose to the browser.

SITE_NAMEstring

The site identifier when the project hosts a CMS-driven site.

DOMAIN_AS_ORGboolean

Set true when the public hostname maps 1:1 to an org (multi-brand sites).

APPENGINE_ENDPOINT and ORG_ID may also be exposed as NEXT_PUBLIC_* for any browser-side reads, but the recommended path is to keep secrets server-side and let the browser go through /api/* (see Server proxy).

Endpoint constants

Every URL the client will ever call lives in one file. No URL strings appear in the API modules — they import from here.

// src/lib/appmint-endpoints.ts (excerpt)
export const appmintEndpoints = {
  appkey:       { name: 'appkey',     method: 'post', path: 'profile/app/key' },
  login:        { name: 'login',      method: 'post', path: 'profile/customer/signin' },
  refresh_token:{ name: 'refresh',    method: 'post', path: 'profile/customer/refresh' },
  get:          { name: 'get',        method: 'get',  path: 'repository/get' },
  find:         { name: 'find',       method: 'post', path: 'repository/find' },
  create:       { name: 'create',     method: 'put',  path: 'repository/create' },
  update:       { name: 'update',     method: 'post', path: 'repository/update' },
  delete:       { name: 'delete',     method: 'del',  path: 'repository/delete' },
  // ... ~200 more
};

When AppEngine adds endpoints, you add a row. Search-and-replace becomes trivial.

HTTP client

The client owns three things: server-side auth (using app credentials to get a service JWT), browser-side auth (using the user's cookie JWT), and 401-driven token refresh on both sides.

// src/lib/appmint-client.ts (excerpt — pulled from base-app)
export class AppEngineClient {
  private renewTries = 0;
  private token: string | null = null;

  constructor(private appConfig: any) {}

  async getAppToken() {
    const path = getProxiedUrl(appmintEndpoints.appkey.path, this.appConfig.appengine.host);
    const data = {
      appId: this.appConfig.appengine.appId,
      secret: this.appConfig.appengine.secret,
      key: this.appConfig.appengine.key,
    };
    const rt = await axios.post(path, data, this.getBaseHeader());
    return rt?.data?.token ?? null;
  }

  getBaseHeader() {
    return {
      headers: {
        'Content-Type': 'application/json',
        orgid: this.appConfig.orgId,
        domainAsOrg: this.appConfig.domainAsOrg,
        'shared-org-id': this.appConfig.orgId,
      },
    };
  }

  async processRequest(method, clientPath, clientData?, clientAuth?, clientQuery?, clientInfo?, isMultiPath?) {
    if (typeof window !== 'undefined') {
      return this.processRequestClient(method, clientPath, clientData, clientInfo);
    }
    return this.processRequestServer(method, clientPath, clientData, clientAuth, clientQuery, clientInfo, isMultiPath);
  }
}

processRequest is the only function the API modules call. It dispatches to a browser path or a server path based on typeof window.

Browser path

In the browser, requests go to /api/... (the local Next.js route), not directly to AppEngine. getProxiedUrl rewrites the path. The browser sends the user's cookie JWT in Authorization, retries once on 401 after refresh, and gives up on the second 401.

async processRequestClient(method, clientPath, clientData?, clientInfo?) {
  const path = getProxiedUrl(clientPath, '');
  const header = {
    headers: {
      'Content-Type': 'application/json',
      authorization: activeSession.getToken(),
    },
  };
  this.applyClientInfoHeaders(header.headers, this.getBrowserClientInfo(clientInfo));

  try {
    return this.processResponse(await this.executeClientRequest(method, path, clientData, header));
  } catch (err) {
    if (err?.response?.status === 401 && this.renewTries < 1) {
      this.renewTries++;
      if (await this.refreshClientToken()) {
        header.headers.authorization = activeSession.getToken();
        return this.processResponse(await this.executeClientRequest(method, path, clientData, header));
      }
    }
    throw err;
  }
}

Server path

On the server, requests go directly to APPENGINE_ENDPOINT. The client uses app credentials (not a user JWT) to mint a service token, and forwards the calling user's JWT in x-client-authorization so AppEngine can attribute the action.

async processRequestServer(method, clientPath, clientData?, clientAuth?, clientQuery?, clientInfo?, isMultiPath?) {
  const path = getProxiedUrl(clientPath, this.appConfig.appengine.host);
  const header = await this.getHeaderWithToken();         // Bearer <serviceToken>

  if (!clientInfo) clientInfo = await this.getServerClientInfo();
  const authToken = clientAuth || clientInfo?.authorization;
  if (authToken) header.headers['x-client-authorization'] = authToken;
  this.applyClientInfoHeaders(header.headers, clientInfo);

  // axios.post / put / get / delete with retry on 401
}

The full file is ~370 lines. Copy it; don't rewrite.

Server proxy

The browser never talks to AppEngine directly. Instead it hits /api/... on the Next.js app, which forwards to AppEngine. This keeps APP_SECRET server-side, lets you inject auth, and gives you a single place to log/throttle.

// src/lib/proxy-utils.ts
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, '/');
};

A catch-all route (app/api/[...slug]/route.ts) forwards the request:

// app/api/[...slug]/route.ts (sketch)
import { getAppEngineClient } from '@/lib/appmint-client';

export async function POST(req: Request, { params }: { params: { slug: string[] } }) {
  const path = params.slug.join('/');
  const body = await req.json().catch(() => null);
  const auth = req.headers.get('authorization') ?? undefined;
  const client = getAppEngineClient();
  const data = await client.processRequest('post', path, body, auth);
  return Response.json(data);
}

Active session

A small namespace handles cookie + in-memory JWT. Works in both server and client environments.

// src/lib/active-session.ts (excerpt)
export namespace activeSession {
  export const setActiveSession = (token, user, refreshToken) => {
    setCookie('token', token);
    setCookie('user', user);
    setCookie('refreshToken', refreshToken);
  };
  export const getToken = (): string => {
    if (!token) token = getCookie('token');
    return token ? 'Bearer ' + token : '';
  };
  export const getRefreshToken = (): string => {
    if (!refreshToken) refreshToken = getCookie('refreshToken');
    return 'Bearer ' + refreshToken;
  };
  export const clearSession = () => { /* clear cookies + memory */ };
}

Cookies are set with a 7-day expiry by default. The Authorization header is always Bearer <jwt>.

Auth flows

appmint-auth.ts wraps the auth endpoints (login, register, magic-link, social, forgot/reset password, 2FA) and runs them through the same processRequest:

// src/lib/appmint-auth.ts (excerpt)
export class AppmintAuth {
  private client = getAppEngineClient();

  async login(email: string, password: string) {
    const { clientInfo } = await this.getClientInfo();
    return this.client.processRequest(
      appmintEndpoints.login.method,
      appmintEndpoints.login.path,
      { email, password },
      null, null, clientInfo,
    );
  }

  async refreshToken(refreshToken: string) {
    const { clientInfo } = await this.getClientInfo();
    return this.client.processRequest(
      appmintEndpoints.refresh_token.method,
      appmintEndpoints.refresh_token.path,
      { refreshToken },
      null, null, clientInfo,
    );
  }
}

A successful login response carries { token, refreshToken, user }. Store with activeSession.setActiveSession(...); the cookie wiring does the rest.

Middleware route gating

A 50-line middleware.ts gates the whole app. Public paths skip the check; everything else needs the token cookie or gets redirected to /login.

// middleware.ts (full file)
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const publicPaths = ['/login', '/register', '/auth', '/forgot-password', '/reset-password'];
  const isPublic = publicPaths.some(p => pathname.startsWith(p)) ||
    pathname === '/' || pathname.startsWith('/_next') || pathname.includes('.');

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

  if (!isPublic && !token) {
    const url = new URL('/login', request.url);
    url.searchParams.set('callbackUrl', encodeURIComponent(pathname));
    return NextResponse.redirect(url);
  }
  if (token && (pathname.startsWith('/login') || pathname.startsWith('/register'))) {
    return NextResponse.redirect(new URL('/', request.url));
  }

  const response = NextResponse.next();
  response.headers.set('x-pathname', pathname);
  return response;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

Per-domain API modules

Each AppEngine domain gets its own thin file. repository-api.ts is the prototype:

// src/lib/repository-api.ts (excerpt)
class RepositoryAPIService {
  private client = getAppEngineClient();

  async getData(datatype: string, id: string) {
    const path = `repository/get/${datatype}/${id}`;
    const response = await this.client.processRequest('get', path);
    return response?.data ?? null;
  }

  async find(datatype: string, query: any, options?: any) {
    const path = `repository/find/${datatype}`;
    return this.client.processRequest('post', path, { query, options }) || { data: [], total: 0 };
  }

  async findPageData(params: { datatype: string; keyword?: string; options?: any }) {
    const q = new URLSearchParams();
    if (params.keyword) q.append('keyword', params.keyword);
    if (params.options?.page) q.append('p', String(params.options.page));
    if (params.options?.pageSize) q.append('ps', String(params.options.pageSize));
    return this.client.processRequest('get', `repository/find-page-data/${params.datatype}?${q}`);
  }
}

export const repositoryAPI = new RepositoryAPIService();

storefront-api.ts, events-api.ts, finance-api.ts, affiliate-api.ts, client-app-api.ts follow the same pattern. The domain knowledge lives in the function names (getProduct, getCart, purchaseTicket, getWallet); everything else delegates.

Putting it together

// app/products/page.tsx
import { repositoryAPI } from '@/lib/repository-api';

export default async function ProductsPage() {
  const result = await repositoryAPI.findPageData({
    datatype: 'product',
    options: { page: 1, pageSize: 24, sort: 'modifydate', sortType: 'desc' },
  });

  return (
    <ul>
      {result.data.map(rec => <li key={rec.sk}>{rec.data.title}</li>)}
    </ul>
  );
}

In a server component, the call goes server-to-server (using app credentials). In a client component, the same call would proxy through /api/repository/find-page-data/product. The API module doesn't care which.

Lifting it to your repo

  1. Copy src/lib/{appmint-client,appmint-config,appmint-endpoints,appmint-auth,active-session,proxy-utils,repository-api}.ts from base-app.
  2. Copy middleware.ts.
  3. Add a catch-all app/api/[...slug]/route.ts that forwards to the client.
  4. Set the env variables above.
  5. Add domain API modules as needed.

That's the whole integration. Anything fancier is a layer on top — Zustand stores, React-Query hooks, BlockNote editors — none of which is required to talk to AppEngine.