By the end of this tutorial you'll have a Next.js app that signs users in against AppEngine, gates routes with cookie-JWT middleware, renders a protected dashboard backed by a CRM collection, and accepts payments through the Storefront module. The structure mirrors base-app — the canonical reference site for AppEngine.
We'll work in the App Router, with React Server Components doing the data fetching and small client components for interactivity.
Prerequisites
- Node.js 20+, pnpm, or npm.
- An AppMint org (
orgid) and an admin User account. - An AppEngine API key for the org (admin → API keys).
- Stripe test keys configured on the org (admin → Connect → Stripe).
- 1
Scaffold the app
npx create-next-app@latest my-saas \ --ts --app --src-dir --tailwind --eslint --no-import-alias cd my-saas pnpm add axios @stripe/stripe-jsThe base-app stack (Tailwind 4, shadcn/Radix, BlockNote) is overkill for a starter — you can add pieces as you go. The pieces we do need:
axiosfor the AppEngine HTTP client and@stripe/stripe-jsfor the checkout button. - 2
Configure environment
Create
.env.local:NEXT_PUBLIC_APPENGINE_URL=https://appengine.appmint.io NEXT_PUBLIC_APPENGINE_ORGID=your-org-id APPENGINE_APP_ID=your-app-id APPENGINE_APP_KEY=your-app-key APPENGINE_APP_SECRET=your-app-secret NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...Server-only secrets (no
NEXT_PUBLIC_prefix) stay on the server. Add a typed config layer.// src/lib/appmint-config.ts export const appmintConfig = { appengine: { host: process.env.NEXT_PUBLIC_APPENGINE_URL!, appId: process.env.APPENGINE_APP_ID!, key: process.env.APPENGINE_APP_KEY!, secret: process.env.APPENGINE_APP_SECRET!, }, orgId: process.env.NEXT_PUBLIC_APPENGINE_ORGID!, }; - 3
Build the HTTP client
Drop in a thin client. The full version in
base-app/src/lib/appmint-client.tshandles token refresh and thegetProxiedUrlflip; this is the minimum.// src/lib/appmint-client.ts import axios, { Method } from 'axios'; import { appmintConfig } from './appmint-config'; class AppEngineClient { async request<T = any>( method: Method, path: string, data?: any, token?: string, ): Promise<T> { const url = `${appmintConfig.appengine.host}/${path.replace(/^\//, '')}`; const res = await axios.request<T>({ url, method, data, headers: { 'Content-Type': 'application/json', orgid: appmintConfig.orgId, ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }); return res.data; } } export const appengine = new AppEngineClient(); - 4
Wire up auth — sign-in and sign-up
Two pages and one shared form. Customer signs in via
POST /profile/customer/signin; the response carries{ token, user }.// src/app/(auth)/login/page.tsx 'use client'; import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { appengine } from '@/lib/appmint-client'; export default function Login() { const router = useRouter(); const params = useSearchParams(); const callbackUrl = params.get('callbackUrl') || '/dashboard'; const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const [err, setErr] = useState<string | null>(null); async function onSubmit(e: React.FormEvent) { e.preventDefault(); setErr(null); try { const res = await appengine.request('post', 'profile/customer/signin', { email, password }); // Set the cookie via a server route so HttpOnly works await fetch('/api/auth/set-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: res.token, user: res.user }), }); router.push(decodeURIComponent(callbackUrl)); } catch (e: any) { setErr(e.response?.data?.message || 'Sign-in failed'); } } return ( <form onSubmit={onSubmit} className="max-w-sm mx-auto p-6 space-y-3"> <input value={email} onChange={e => setEmail(e.target.value)} type="email" placeholder="Email" required className="w-full border p-2 rounded" /> <input value={password} onChange={e => setPassword(e.target.value)} type="password" placeholder="Password" required className="w-full border p-2 rounded" /> {err && <p className="text-red-600 text-sm">{err}</p>} <button className="w-full bg-blue-600 text-white p-2 rounded">Sign in</button> </form> ); }The session-setting route is a one-liner that converts the JSON token into an HttpOnly cookie:
// src/app/api/auth/set-session/route.ts import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; export async function POST(req: Request) { const { token, user } = await req.json(); const c = await cookies(); c.set('token', token, { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7 }); c.set('user', JSON.stringify(user), { httpOnly: false, secure: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 7 }); return NextResponse.json({ ok: true }); } - 5
Add middleware
Drop
middleware.tsat the project root — copied frombase-app/middleware.tsand unchanged in spirit.// middleware.ts // adapted from base-app/middleware.ts import { NextRequest, NextResponse } from 'next/server'; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const publicPaths = [ '/login', '/register', '/auth', '/auth/complete-login', '/auth/magiclink', '/login/error', '/forgot-password', '/reset-password', ]; const isPublicPath = publicPaths.some(path => pathname.startsWith(path) || pathname === '/' || pathname.startsWith('/_next') || pathname.includes('/public/') || pathname.includes('.') ); const token = request.cookies.get('token')?.value; if (!isPublicPath && !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('/dashboard', request.url)); } const response = NextResponse.next(); response.headers.set('x-pathname', pathname); return response; } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'], };Now
/dashboard,/account, anything-not-on-the-allow-list redirects to/loginwhen there's notokencookie. - 6
Read the session in a server component
// src/lib/get-session.ts import { cookies } from 'next/headers'; export async function getSession() { const c = await cookies(); const token = c.get('token')?.value; const userRaw = c.get('user')?.value; if (!token || !userRaw) return null; try { return { token, user: JSON.parse(userRaw) }; } catch { return null; } }Use it from any RSC:
// src/app/dashboard/page.tsx import { redirect } from 'next/navigation'; import { getSession } from '@/lib/get-session'; import { appengine } from '@/lib/appmint-client'; export default async function Dashboard() { 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 ( <div className="p-8"> <h1 className="text-2xl font-semibold">Welcome, {session.user.email}</h1> <h2 className="text-lg mt-6 mb-2">Recent orders</h2> <ul className="space-y-2"> {orders.data.map((o: any) => ( <li key={o.sk} className="border p-3 rounded"> <div className="font-medium">{o.data.orderNumber}</div> <div className="text-sm text-gray-600">${o.data.total} — {o.data.state}</div> </li> ))} </ul> </div> ); } - 7
Fetch from a custom collection
A SaaS dashboard usually shows tenant-scoped resources — projects, sites, sources, whatever your product names them. Build them as a custom collection in AppEngine (admin → Collections → New) and read from
/data/<collection>.// src/app/dashboard/projects/page.tsx import { getSession } from '@/lib/get-session'; import { appengine } from '@/lib/appmint-client'; export default async function ProjectsPage() { const session = (await getSession())!; const projects = await appengine.request<{ data: any[] }>( 'post', 'data/project/find', { criteria: { ownerId: session.user.sk } }, session.token, ); return ( <ul> {projects.data.map(p => ( <li key={p.sk}>{p.data.name}</li> ))} </ul> ); }POST /repository/find/<collection>accepts a Dynamic Query — the same query language used by audiences, automation, and rule engine. Filter, sort, and paginate on the server. - 8
Add a checkout button
Wire up Stripe. The flow: cart → intent → Stripe.js → submit order. For a one-product upsell page, you can skip the cart and use buy-now.
// src/app/upgrade/page.tsx 'use client'; import { useState } from 'react'; import { loadStripe } from '@stripe/stripe-js'; const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); export default function UpgradePage() { const [loading, setLoading] = useState(false); async function buy() { setLoading(true); const stripe = await stripePromise; if (!stripe) return; // 1. Create the intent server-side const intentRes = await fetch('/api/upgrade/intent', { method: 'POST' }); const { clientSecret } = await intentRes.json(); // 2. Confirm the card payment client-side // ...mount the Stripe element, call confirmCardPayment(clientSecret, ...) // This snippet skips Elements setup for brevity — see Stripe docs. } return ( <button onClick={buy} disabled={loading} className="px-6 py-3 bg-blue-600 text-white rounded"> {loading ? 'Loading...' : 'Upgrade to Pro — $29/mo'} </button> ); }The intent route:
// src/app/api/upgrade/intent/route.ts import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; import { appengine } from '@/lib/appmint-client'; export async function POST() { const c = await cookies(); const token = c.get('token')?.value; if (!token) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); const intent = await appengine.request<{ clientSecret: string }>( 'post', 'storefront/stripe/intent', { amount: 2900, currency: 'usd' }, token, ); return NextResponse.json({ clientSecret: intent.clientSecret }); }For subscriptions, use
storefront/stripe/subscription-sessionand redirect to the returnedurlinstead of running Elements yourself — Stripe hosts the page and AppEngine receives the webhook. - 9
Submit the completed order
After Stripe confirms the payment client-side, post to
/storefront/checkout-cartfrom a server route — the same shapebase-appuses.// src/app/api/upgrade/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; const userRaw = c.get('user')?.value; if (!token || !userRaw) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); const user = JSON.parse(userRaw); const { paymentIntentId } = await req.json(); const order = await appengine.request<{ data: any }>( 'post', 'storefront/checkout-cart', { items: [ { sku: 'plan-pro', name: 'Pro plan (monthly)', quantity: 1, price: 29 }, ], customerInfo: { email: user.email, name: user.name }, shippingAddress: { country: 'US' }, paymentRef: paymentIntentId, paymentGateway: 'stripe', subtotal: 29, total: 29, }, token, ); return NextResponse.json({ orderNumber: order.data.orderNumber }); } - 10
Sign-out
// src/app/api/auth/sign-out/route.ts import { cookies } from 'next/headers'; import { NextResponse } from 'next/server'; export async function POST() { const c = await cookies(); c.delete('token'); c.delete('user'); return NextResponse.json({ ok: true }); }Hook it to a client button —
await fetch('/api/auth/sign-out', { method: 'POST' }), thenrouter.push('/login'). - 11
Run it
pnpm devHit
/login, sign in with a customer account from your org, watch the cookie set, get redirected to/dashboard, see your real orders. Hit/upgrade, push the button, watch the order land instorefront/ordersfrom the admin UI.
What you skipped
This is the minimum end-to-end SaaS. To match base-app you'd add:
- The
[[...slug]]catch-all that renders CMS pages from AppEngine — covered in Dynamic pages from CMS. - A real product/cart flow with
storefront/cart/getandstorefront/cart/update— covered in Cart. - The proxy-utils flip that lets browser code call
/api/storefront/*directly without exposing the AppEngine host — covered in Consume AppEngine from RSC. - 2FA, OAuth providers, magic-link sign-in — see the auth section under
/docs/appengine/auth/. - Activity tracking, affiliate cookie, Build Studio integration — pulled from
base-app/src/lib/.
Pull the relevant files from base-app/src/lib/ as you grow — they're written to be cherry-picked.
Project layout
src/
app/
(auth)/
login/page.tsx
register/page.tsx
api/
auth/set-session/route.ts
auth/sign-out/route.ts
upgrade/intent/route.ts
upgrade/complete/route.ts
dashboard/
page.tsx
projects/page.tsx
upgrade/page.tsx
layout.tsx
lib/
appmint-client.ts
appmint-config.ts
get-session.ts
middleware.ts
That's it — a SaaS shell on AppEngine in nine files plus the auth scaffolding.