Documentation

Build a SaaS site

End-to-end Next.js + AppEngine — auth, dashboard, collections, checkout.

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. 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-js
    

    The 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: axios for the AppEngine HTTP client and @stripe/stripe-js for the checkout button.

  2. 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. 3

    Build the HTTP client

    Drop in a thin client. The full version in base-app/src/lib/appmint-client.ts handles token refresh and the getProxiedUrl flip; 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. 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. 5

    Add middleware

    Drop middleware.ts at the project root — copied from base-app/middleware.ts and 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 /login when there's no token cookie.

  6. 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. 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. 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-session and redirect to the returned url instead of running Elements yourself — Stripe hosts the page and AppEngine receives the webhook.

  9. 9

    Submit the completed order

    After Stripe confirms the payment client-side, post to /storefront/checkout-cart from a server route — the same shape base-app uses.

    // 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. 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' }), then router.push('/login').

  11. 11

    Run it

    pnpm dev
    

    Hit /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 in storefront/orders from 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/get and storefront/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.