Adding a New Product to SM
This is the complete guide for adding a new product to the Sprint Mode platform. Follow every step.
Prerequisites
- Product name decided (e.g.
osma) - App domain decided (e.g.
app.osma.aiorosma.sprintmode.ai) - GitHub org access (
nomadahq) - Cloudflare account access
Step 1 — Create the Repo
Create a private repo in the nomadahq org: nomadahq/sprint-mode-osma (or nomadahq/osma).
# After Aaron creates the repo on GitHub
git clone git@github.com:nomadahq/sprint-mode-osma.git
cd sprint-mode-osmaStep 2 — Bootstrap the Project
npm init -y
npm install react react-dom react-router-dom vite @vitejs/plugin-react
npm install github:nomadahq/sprint-mode-uiCreate vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist'
}
})Create index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Osma</title>
<link rel="icon" href="/favicon.ico" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>Step 3 — Set Up the App Shell
src/main.jsx:
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Layout, Login } from '@nomadahq/sm-ui'
import '@nomadahq/sm-ui/css'
import '@nomadahq/sm-ui/css/shell'
import '@nomadahq/sm-ui/css/components'
import './app.css'
import Dashboard from './pages/Dashboard.jsx'
import Settings from './pages/Settings.jsx'
const NAV = {
'osma': {
label: 'Osma',
items: [
{ to: '/dashboard', label: 'Dashboard', icon: 'grid', exact: true },
{ to: '/settings', label: 'Settings', icon: 'gear' },
]
}
}
ReactDOM.createRoot(document.getElementById('root')).render(
<BrowserRouter>
<Routes>
<Route path="/auth/login" element={
<Login
productName="Osma"
logoSrc="/assets/osma-horizontal.png"
logoDarkSrc="/assets/osma-horizontal-dark.png"
/>
} />
<Route path="/" element={<Layout navConfig={NAV} />}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
)src/app.css:
/* Product accent — overrides --accent from @sprintmode/ui/css */
:root {
--accent: #your-brand-color;
--accent-hover: #your-brand-color-darker;
--accent-10: rgba(your-brand-rgb, 0.10);
--accent-20: rgba(your-brand-rgb, 0.20);
--accent-tint: #your-brand-light-bg;
}Step 4 — Create the CF Worker
_worker.js — handles auth before serving the SPA:
import { getSession, verifyJWT, signJWT } from '@nomadahq/sm-ui/auth'
const SM_API = 'https://api.sprintmode.ai'
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url)
// Auth callback from SM API — exchange for session cookie
if (url.pathname === '/auth/callback') {
const token = url.searchParams.get('token')
if (!token) return Response.redirect('/auth/login')
const session = await verifyJWT(token, env.SESSION_SECRET)
if (!session) return Response.redirect('/auth/login')
const jwt = await signJWT(session, env.SESSION_SECRET)
const redirect = session.redirect || '/dashboard'
return new Response(null, {
status: 302,
headers: {
'Location': redirect,
'Set-Cookie': `sm_client=${jwt}; Domain=.sprintmode.ai; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=604800`
}
})
}
// Public assets and login — no auth required
if (url.pathname.startsWith('/auth/') || url.pathname.startsWith('/assets/')) {
return env.ASSETS.fetch(request)
}
// All other routes — require auth
const token = getSession(request)
if (!token) {
const loginUrl = '/auth/login?redirect=' + encodeURIComponent(url.pathname + url.search)
return Response.redirect(loginUrl)
}
const session = await verifyJWT(token, env.SESSION_SECRET)
if (!session) {
return Response.redirect('/auth/login')
}
// Check product access
if (!session.products || !session.products.includes('osma')) {
return new Response('Access denied', { status: 403 })
}
return env.ASSETS.fetch(request)
}
}Step 5 — Configure CF Pages
- Go to Cloudflare Pages
- Create a project → Connect to Git → select
nomadahq/sprint-mode-osma - Build settings:
- Framework preset: None (custom)
- Build command:
npm run build - Output directory:
dist
- Environment variables to set:
SESSION_SECRET— copy from another portal (same secret = shared cookie)SM_API_CLIENT_ID— copy from sprint-mode-clients CF Pages env varsSM_API_CLIENT_SECRET— copy from sprint-mode-clients CF Pages env vars
Step 6 — Add the Domain
In CF Pages → your project → Custom domains → Add osma.sprintmode.ai (or your custom domain).
Then in Cloudflare DNS:
CNAME osma.sprintmode.ai → sprint-mode-osma.pages.devStep 7 — Register the Product with SM API
Add your domain to SM API's CORS allowed origins. In _crm/worker.js, find ALLOWED_ORIGINS and add:
var ALLOWED_ORIGINS = [
'https://clients.sprintmode.ai',
'https://studios.sprintmode.ai',
'https://mode.sprintmode.ai',
'https://osma.sprintmode.ai', // ← add this
]Commit and push — SM API auto-deploys.
Step 8 — Tag Contacts with Your Product
When a client signs up for Osma, tag them in SM CRM:
# Grant portal access + tag with product
curl -X PATCH https://api.sprintmode.ai/api/contacts/ct_abc123 \
-H "X-SM-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"portal_access": 1,
"product": "osma"
}'The user's JWT will now include "products": ["osma"]. They can log in to osma.sprintmode.ai.
Step 9 — Tag Stripe + QB Data
For revenue tracking, tag your Stripe products and invoices:
// When creating Stripe invoice
stripe.invoices.create({
customer: stripeCustomerId,
metadata: { product: 'osma', company_id: smCompanyId }
})SM's Finance page uses metadata.product to filter revenue by product.
Step 10 — Deploy & Verify
git add -A
git commit -m "initial osma portal"
git push origin mainCF Pages auto-deploys on push to main.
Verify:
- [ ]
osma.sprintmode.ai/auth/loginshows the login page with product logo - [ ] Google SSO redirect works — user lands on
/dashboardafter auth - [ ]
useSession()returns correct session in console - [ ]
productsin session includesosma - [ ] SM API calls from the app return data (not 401)
Common Mistakes
| Mistake | Fix |
|---|---|
| Logo not showing | Copy logo files into repo public/assets/. Never reference from another domain. |
| SSO redirects loop | Check SESSION_SECRET matches across portals. Check ALLOWED_ORIGINS in SM API. |
| API calls fail with 403 | Contact has portal_access: 1 but product field is wrong or missing. |
| Dark mode logo broken | Use <picture> element — not <img>. Copy -dark.png variant into repo. |
| Data not loading | Set SM_API_CLIENT_ID and SM_API_CLIENT_SECRET on CF Pages. Without these, server-to-server calls fail CF Access. |
