Migration runbook
How to bring an existing site up to the platform standard, and the gotchas on the way. The migration guide covers replacing imports; augur-doctor shows what is still left.
Order of work
Each step depends on the one before.
- Align
package.json(by hand, thenpnpm install). - Extend
augur-config/tsconfig/next. - Switch to
augur-config/eslint/next. - Align
next.config(serverExternalPackages, headers,optimizePackageImports). - Replace imports (migration guide).
- Set up vitest.
- Run the whole quality gate: typecheck, lint, test, build.
Package dependencies
Sites MUST NOT bump augur packages with pnpm update or pnpm add -L. Both re-resolve transitive dependencies, which can change how shared ones such as React resolve; vitest then loads two Reacts and fails with "Invalid hook call", which is hard to trace.
- Edit the versions in
package.jsonby hand. - Run
pnpm install. - Check
pnpm ls reactshows one copy. - Run
pnpm augur-doctor .: each augur package should be on its own latest release.
Every augur package, augur-api included, is CalVer (YYYY.M.seq) and released on its own, so each one is upgraded on its own. Peer dependencies (React, TanStack Query, Next.js, NextAuth, Zustand) SHOULD satisfy the ranges in each package's peerDependencies.
Icons
The standard is react-icons/lu (Lucide through react-icons). Sites MUST NOT use lucide-react or @heroicons/react:
- import { ShoppingCartIcon } from "@heroicons/react/24/outline";
+ import { LuShoppingCart } from "react-icons/lu";Tailwind 4
A site on Tailwind 3 upgrades to 4:
- Install
tailwindcss@^4andtailwindcss-animate@^1.0.7. - Delete
tailwind.config.ts: Tailwind 4 is configured in CSS and finds content itself. - Replace the stylesheet's head:
/* Before (Tailwind 3) */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* After (Tailwind 4) */
@import "tailwindcss";
@import "@simpleapps-com/augur-tailwind/base.css";
@plugin "tailwindcss-animate";- Move theme extensions from the JS config into
@themeblocks.
tsconfig
{
"extends": "@simpleapps-com/augur-config/tsconfig/next",
"compilerOptions": {
"noUncheckedIndexedAccess": false,
"declaration": false,
"declarationMap": false,
"sourceMap": false,
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}The preset gives strict mode, bundler resolution, isolatedModules, JSX, DOM libs, incremental and the Next.js plugin (all presets). Turning noUncheckedIndexedAccess off is a stopgap for a site with many index reads; leave it on when you can. Map @/* to ./* or ./src/*, whichever the site already uses, consistently.
ESLint
// eslint.config.mjs
import augurNext from "@simpleapps-com/augur-config/eslint/next";
export default [
...augurNext,
{
files: ["**/*.{ts,tsx,js,jsx}"],
rules: {
// site-specific overrides only
},
},
{ ignores: ["out/**"] },
];The preset brings @typescript-eslint, react-hooks and @next/next (recommended and Core Web Vitals). A leftover .eslintrc.json or .eslintrc.js MUST be deleted: flat config ignores it, so its rules silently stop applying.
next.config
serverExternalPackages
List native Node modules that can't be bundled: ioredis, isomorphic-dompurify, jsdom, dompurify. It MUST NOT list @simpleapps-com/augur-server, which is plain JavaScript that Next.js has to bundle; externalized, it fails at runtime with "module not found".
serverExternalPackages: ["ioredis", "isomorphic-dompurify", "jsdom", "dompurify"],optimizePackageImports
experimental: {
optimizePackageImports: [
"@simpleapps-com/augur-core",
"@simpleapps-com/augur-hooks",
"@simpleapps-com/augur-server",
"@simpleapps-com/augur-utils",
"react-icons",
"zustand",
],
},Security headers
headers: async () => [
{
source: "/:path*",
headers: [
{ key: "X-DNS-Prefetch-Control", value: "on" },
{ key: "Strict-Transport-Security", value: "max-age=31536000; includeSubDomains" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
],
},
{
source: "/_next/static/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=31536000, immutable" }],
},
],Also set reactStrictMode: true, and list agr.media in images.remotePatterns for product images.
Vitest
// vitest.config.ts
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig } from "vitest/config";
const require = createRequire(import.meta.url);
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname),
// one copy of augur-api; /testing MUST come before the main entry
"@simpleapps-com/augur-api/testing": require.resolve("@simpleapps-com/augur-api/testing"),
"@simpleapps-com/augur-api": require.resolve("@simpleapps-com/augur-api"),
},
dedupe: ["react", "react-dom"],
},
test: {
environment: "node",
include: ["__tests__/**/*.test.ts"],
setupFiles: ["__tests__/setup.ts"],
server: { deps: { inline: [/@simpleapps-com/] } },
env: {
AUGUR_SITE_ID: "example-site",
AUGUR_JWT: "test-jwt-token",
AUTH_SECRET: "test-auth-secret",
DEFAULT_CUSTOMER_ID: "99999",
DEFAULT_CONTACT_ID: "0",
},
},
});| Setting | Why |
|---|---|
server.deps.inline: [/@simpleapps-com/] |
The augur packages are ESM; vitest needs to inline them to resolve their exports |
| The augur-api aliases | Force one copy, or type guards fail across two; aliases match by prefix, so /testing goes first |
dedupe: ["react", "react-dom"] |
One React, or hooks fail with "Invalid hook call" |
environment: "node" |
Server action tests don't need a DOM; use jsdom only for component tests |
env |
Server code reads these at import time |
Global mocks
// __tests__/setup.ts
import { vi } from "vitest";
vi.mock("next/headers", () => ({
cookies: () => ({ get: vi.fn(), set: vi.fn(), delete: vi.fn() }),
headers: () => new Headers(),
}));
vi.mock("ioredis", () => {
const RedisMock = vi.fn(() => ({
get: vi.fn().mockResolvedValue(null),
set: vi.fn().mockResolvedValue("OK"),
del: vi.fn().mockResolvedValue(1),
on: vi.fn(),
status: "ready",
}));
return { default: RedisMock, Redis: RedisMock };
});SDK contract tests (__tests__/sdk-contracts/, which augur-doctor looks for) call the real SDK through createMockClient; if a global mock covers augur-api, get the real one with vi.importActual:
const { createMockClient } = await vi.importActual<typeof import("@simpleapps-com/augur-api/testing")>(
"@simpleapps-com/augur-api/testing",
);For component tests, mock @simpleapps-com/augur-hooks (what components call), not augur-api: mocking the SDK still runs the hooks, which then need React Query and the providers.
Pre-commit hooks
The standard is lefthook with lint-staged. It isn't required; augur-doctor suggests it when missing.
pnpm add -D lefthook lint-staged
pnpm exec lefthook install# lefthook.yml
pre-commit:
parallel: true
jobs:
- name: typecheck
run: pnpm run typecheck
- name: lint-staged
run: pnpm exec lint-staged
- name: lockfile-sync
glob: "**/package.json"
run: pnpm install --frozen-lockfile{
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css,scss}": ["prettier --write"]
}
}(or the same in .lintstagedrc.json). Lefthook is one binary, runs jobs in parallel and filters by glob; a site already on husky can keep it.
Proxy migration
Replace local React Query hooks (in lib/augur.ts: useItemPrice, useInvMastDoc, …) with createServerQueryProxy. The site needs createServerSite() in lib/augur-site.ts and an augur-api release with typed responses (2026.3.7 or later, which augur-doctor checks).
- Create
lib/augur-query.ts(recipe). - Replace one hook at a time, checking the page each time:
useItemPrice(itemId, customerId, 1)becomesuseQuery({ ...q.pricing.priceEngine.list({ itemId, customerId, quantity: 1 }), enabled: Boolean(itemId) }). - Change the server prefetch for that query to
createAugurOptionsin the same commit: the proxy's keys follow the SDK path, so an old prefetch key no longer matches and the page fetches twice.
- import { getItemPriceOptions } from "@simpleapps-com/augur-hooks/server";
- await queryClient.prefetchQuery(getItemPriceOptions(api, itemId, customerId));
+ import { createAugurOptions } from "@simpleapps-com/augur-hooks/server";
+ await queryClient.prefetchQuery(createAugurOptions(site.client).pricing.priceEngine.list({ itemId, customerId }));- When every hook is replaced, delete the local hook file, the old option factories and their tests.
| Old key | Proxy key |
|---|---|
["itemPrice", itemId, customerId, qty] |
["pricing", "priceEngine", "list", { customerId, itemId, quantity }] |
["invMastDoc", uid, itemId] |
["items", "invMast", "doc", "get", uid, { itemId }] |
["itemCategory", uid] |
["items", "itemCategory", "get", uid] |
["categoryItems", uid, filters] |
["items", "categories", "items", "list", uid, filters] |
["searchSuggestions", query] |
["openSearch", "suggestions", "suggest", "list", { q: query }] |
A proxy key is the SDK path followed by the arguments, each object's keys sorted; autocomplete on q shows the path. enabled, initialData, select and debouncing stay at the call site, after the spread.
Old cache entries are simply abandoned: the first visit after the switch fetches again. To avoid even that, ship the server prefetch change first and the client change second.
Debugging
| Symptom | Cause | Fix |
|---|---|---|
| "Invalid hook call" in vitest | Two Reacts | resolve.dedupe: ["react", "react-dom"]; don't use pnpm update |
| "Module not found: augur-server" at runtime | augur-server in serverExternalPackages |
Remove it |
| augur-web classes missing | Tailwind didn't scan the package | @source "../node_modules/@simpleapps-com/augur-web/dist"; |
| ESLint rules not firing | A leftover .eslintrc* |
Delete it; use eslint.config.mjs |
| "Cannot find module augur-api/testing" in vitest | Alias order | Put the /testing alias first |
| Hook data undefined in component tests | The SDK is mocked instead of the hooks | Mock augur-hooks |
| Type errors after the tsconfig change | The site relied on looser options | Fix them, or override the option in compilerOptions |
pnpm install changed unrelated dependencies |
pnpm update was used |
Revert the lockfile, edit package.json, run pnpm install |
| SSR data doesn't hydrate | Server and client keys differ | Build both through a proxy |
Proxy autocomplete shows unknown |
An augur-api release without typed responses | Upgrade augur-api |
"No API instance provided" from a queryFn |
No api and no registered query action |
Import @simpleapps-com/augur-server/hooks (or pass api) |