CatalyzeUp
sondely/migration

Screens into Next.js

After the data cutover, Sondely's screens move from the React and Vite app (frontend/) into the Next.js app (server/), one route at a time. Survey taking and the landing page go first. Until a route has moved, the Vite app serves it from the same Docker image, so every URL keeps working throughout. This page lists every route, the order they move in, what each move carries with it, the fallback that makes a partial move safe, the sign-in token's move to a cookie, custom domains, and when the Vite app is deleted.

Related: Migration overview, Auth, API Routes, Testing and Catching Bugs, Translation, Infrastructure.

As built on 8 November 2026

The screens did not wait for the data cutover. They were built in Next.js in CatalyzeUp/sondely-platform on 7 and 8 November 2026, alongside the API and Sona: sign-in and its recovery pages, the dashboard, surveys and the builder for all six question types, templates, preview, share, translations, responses, survey taking, impact, organizations and members, API keys, assignments, notification settings and the developer pages. The new app runs in its own container apps on its own hosts (Infrastructure), so there is no Vite fallback inside it: the plan below for serving the Vite build from the Next image, and for moving routes one at a time, is not built and is not needed while the two apps live on different hosts.

What that changes in this page:

  • Sessions in the new app are an httpOnly cookie, sondely_session, from the first screen, marked Secure when served over https. The localStorage token stays in the Vite app only, and goes when that app is retired.
  • The Vite app keeps serving sondely.com from CatalyzeUp/sondely until the cutover moves the hosts, and is deleted with the Python app after the rollback window.
  • Custom domains: the new app has the organization-by-host lookup in its API; its middleware does not resolve hosts. The plan below for host resolution in middleware is open.
  • Browser tests run in Chromium on a desktop and a phone project (playwright.config.ts). The usability capture and grading gate is not built.

The sections below are the plan of 5 November 2026; where they name server/, read the repository root of sondely-platform.

Why after the data, and why one route at a time

The data cutover is the one step that cannot easily be undone, so it goes alone, with no screen changes in the same release. Once it has happened, the API and the database are shared by both apps: a screen in Next and a screen in Vite call the same /api/v1 routes on the same server. Each route can then move on its own, ship, and be checked on its own, and a bad move is undone by deleting one Next route, which hands the URL back to Vite.

Every route in the Vite app

Read from frontend/src/App.tsx at commit 06debbf. Lines are the page file's own (wc -l); the screens' child components add to it where noted. API calls include those made by the page's direct child components. Paths are under /api/v1. "Signed in" means the client-side ProtectedRoute; "admin" means AdminRoute, which passes when user.is_admin is true, and the backend sets that for an OWNER or ADMIN of any org, or a user with no memberships (backend/app/api/auth/routes.py, line 871). Both are client-side gates; the API enforces its own.

Path Page component Who sees it API calls Lines
/ TenantAwareHome in App.tsx, rendering pages/HomePage.tsx Public. On an org's custom domain, redirects to /organizations/:id or to sign-in GET /organizations/by-host (from contexts/TenantContext.tsx, on every route) 30, plus 1,138 in components/marketing/
/auth/signin pages/SignInPage.tsx Public, signed-in users sent to /dashboard POST /auth/login, GET /auth/me, POST /auth/request-otp, POST /auth/verify-otp, redirect to /auth/google or /auth/apple 123
/auth/signup pages/SignUpPage.tsx Public POST /auth/register, GET /auth/me, redirect to /auth/google 162
/auth/google/callback pages/GoogleCallbackPage.tsx Public, the end of the Google redirect Reads access_token from the query string, POST /surveys/responses/{id}/claim 65
/auth/apple/callback pages/AppleCallbackPage.tsx Public, the end of the Apple redirect Same as Google 65
/forgot-password pages/ForgotPasswordPage.tsx Public POST /auth/forgot-password 94
/reset-password pages/ResetPasswordPage.tsx Public, from the email link POST /auth/reset-password 123
/invitations/accept pages/AcceptInvitationPage.tsx Public or signed in, from the invitation email GET and POST /invitations/accept?token= 191
/survey/:publicId pages/SurveyTakePage.tsx Respondent, anonymous or signed in GET /surveys/public/{publicId}?lang=, GET /surveys/{id}/can-attempt, POST /surveys/{id}/sessions/start, PUT /sessions/{id}/save, POST /sessions/{id}/complete, POST /sessions/{id}/abandon, POST /surveys/{publicId}/respond, POST /auth/auto-enroll, POST /auth/upgrade, POST /surveys/responses/{id}/claim, GET /geo/detect, POST /auth/request-otp, POST /auth/verify-otp, GET /surveys/{id}/my-responses, GET /surveys/{id}/my-responses/{rid} 1,331, plus about 1,800 in TakeLanding, ProfileStep, ContextStep, QuestionRenderer, ScoreBreakdown, PasswordlessSignIn and components/results/
/dashboard pages/DashboardPage.tsx Signed in (respondent, owner, admin) GET /dashboard/stats, GET /surveys, GET /me/assignments 451
/organizations components/organizations/OrganizationList.tsx Signed in GET /organizations 106
/organizations/:id pages/OrganizationDetailPage.tsx Signed in; the management sections are for owners and admins GET /organizations/{id}, GET .../members, GET .../invitations, GET /surveys?organization_id=, POST .../members/invite, DELETE .../members/{mid}, PUT .../members/{mid}/role, DELETE .../invitations/{iid}, GET .../members/export.csv, GET /surveys/{id}/responses per survey, GET .../overdue with extend, waive and remind, GET, POST and DELETE .../api-keys, PUT custom domain 518, plus MemberTable, InvitationTable, OverdueSection, ApiKeyTable, CustomDomainSection
/settings/notifications pages/NotificationSettingsPage.tsx Signed in GET and PUT /notifications/preferences, POST .../unmute-survey/{id} 335
/surveys/:surveyId/responses pages/SurveyResponsesPage.tsx Signed in (not admin-gated on the client) GET /surveys/{id}, GET /surveys/{id}/responses, GET .../responses/export.csv 211
/surveys/:surveyId/take pages/SurveySessionPage.tsx Signed in, an assigned respondent GET /surveys/{id}, POST /surveys/{id}/sessions/start, GET /sessions/{id}, PUT /sessions/{id}/save, POST /sessions/{id}/complete, POST /sessions/{id}/abandon, GET /surveys/{id}/sessions, GET .../sessions/stats, GET .../can-attempt 130, plus 617 in components/sessions/
/organizations/new components/organizations/CreateOrganization.tsx Admin POST /organizations 88
/organizations/:orgId/surveys/:surveyId/compliance pages/CompliancePage.tsx Admin GET /surveys/{id}, GET .../compliance, GET .../respondent/{email}/history, assignment extend, waive and remind 336
/surveys/templates pages/SurveyTemplatesPage.tsx Admin GET /organizations, GET /templates, GET /templates/{id}, POST /templates/{id}/create-survey 241
/impact pages/ImpactReportPage.tsx Admin GET /surveys, GET /surveys/{id}/impact, GET .../export.csv, GET .../report.pdf 226
/surveys/:surveyId/impact pages/ImpactReportPage.tsx Admin Same (same file)
/surveys/new pages/SurveyBuilderPage.tsx Admin GET /organizations, POST /surveys, GET and PUT /surveys/{id}, GET and POST .../questions, POST .../translate, PATCH and DELETE .../translations/{lang} 1,477, plus CsvOptionImport, TranslationManager, TranslationReview (758)
/surveys/new/classic pages/CreateSurveyPage.tsx Admin GET /organizations, POST /surveys, GET /surveys/{id} 255
/surveys/:surveyId/share pages/SurveySharePage.tsx Admin GET /surveys/{id}, GET .../responses, assignments list, assign and unassign, GET .../members 248
/surveys/:surveyId/edit pages/SurveyBuilderPage.tsx Admin As /surveys/new (same file)
/surveys/:surveyId/edit/classic pages/EditSurveyPage.tsx Admin GET and PUT /surveys/{id}, .../questions 319
/surveys/:surveyId/test pages/AdminTestPage.tsx Admin GET /surveys/{id}/preview 600
/surveys/:surveyId/preview pages/SurveyPreviewPage.tsx Admin GET /surveys/{id} 63
/style-guide pages/StyleGuidePage.tsx Public (marked dev only in a comment, not gated) None 392
/api-docs pages/ApiDocsPage.tsx Public GET /openapi.json, plus the Scalar viewer from jsDelivr 68
/developers pages/DevelopersPage.tsx Public, marketing None 25
* Navigate to "/" Anyone None

30 paths plus the catch-all, served by 26 files in pages/ and two in components/organizations/. The whole frontend/src is 18,634 lines of TypeScript and TSX.

Move order

Step Routes Why in this place
1 /survey/:publicId and / Respondents are most of the traffic and often on a weak phone connection; a server-rendered survey shows its first question without waiting for a 1,300-line bundle. The landing page gains indexing. Neither needs an owner's session
2 /auth/*, /forgot-password, /reset-password, /invitations/accept The sign-in screens are where the token moves into a cookie (see below). Moving them together means one switch, not two
3 /dashboard, /organizations, /organizations/:id, /organizations/new, /settings/notifications The signed-in home and org management. Small pages, many API calls, good ground for the shared components
4 /surveys/new, /surveys/:surveyId/edit, /surveys/templates, /surveys/:surveyId/share, /surveys/:surveyId/test, /surveys/:surveyId/preview The builder is the largest screen (1,477 lines plus 758 in its translation and CSV components) and the one an agent will drive in the agent phases. It reuses the question renderer from step 1
5 /surveys/:surveyId/responses, /impact, /surveys/:surveyId/impact, /organizations/:orgId/surveys/:surveyId/compliance, /surveys/:surveyId/take Results and compliance, which reuse the result visualisations from step 1
6 /api-docs, /developers, /style-guide The style guide is replaced by TourCockpit's registry-driven src/app/style-guide/
Not moved /surveys/new/classic, /surveys/:surveyId/edit/classic The classic builder duplicates the builder. Whether it is retired instead of moved is a decision for Chip; this page assumes retired (inferred)

Inside a step, routes move one pull request at a time.

What moves with each screen

A route counts as moved only when all of this lands in the same pull request:

Item Detail
The page Under server/src/app/, server-rendered where it reads data, client components only where the screen is interactive
Its route removed from the Vite router Deleted from frontend/src/App.tsx, so there is one owner per URL
Component tests tests/component/*.test.tsx under vitest.component.config.ts. The existing Vite tests for that screen (TakeLanding.test.tsx, ProfileStep.test.tsx, ContextStep.test.tsx, QuestionRenderer.test.tsx, PasswordlessSignIn.test.tsx, MyResultsHistory.test.tsx, ResultsPoints.test.tsx, SurveyResponsesPage.test.tsx, ApiDocsPage.test.tsx, HomeSections.test.tsx, MarketingNav.test.tsx) are ported, not dropped
End to end A Playwright spec on the production build, desktop project, plus a *.mobile.spec.ts for any respondent screen. The take flow spec replaces scripts/e2e_take_flow.py, the export spec scripts/e2e_export.py, the localization spec scripts/e2e_localization.py
Catalog strings Every string through t(), imported into the catalogs with its existing French and Spanish (Translation). The hard-coded string guard covers the new files from their first commit
Usability check The screen's journey captured at phone, tablet and desktop width and graded against tests/visual/rubric.md, per the Usability Verification Playbook. The usability gate fails on a blocker. ui-checker screenshots both widths before the pull request opens
Fallback check An end to end test that a sample of the routes still in Vite load the Vite app (see the fallback)

The fallback: Next first, Vite for the rest

Piece How
Build The Docker image builds the Vite app with base: '/app/' in frontend/vite.config.ts (today it has no base) and copies frontend/dist into server/public/app/. Assets are then served by Next as static files under /app/assets/
Routing server/next.config.ts adds a fallback rewrite: { source: '/:path*', destination: '/app/index.html' }. Next checks its own pages, its public files and its dynamic routes first, and only a URL none of them answers falls through to the Vite shell. The browser's URL does not change, so React Router in the Vite app sees /dashboard and renders it as today
A moved route Exists in server/src/app/, so Next answers it and the rewrite never fires. Deleting the Next page hands the URL back to Vite at once, which is the per-screen undo
Links between the two apps A React Router <Link> in the Vite app to a moved route would render nothing, because the Vite router no longer has it. The Vite catch-all changes from <Navigate to="/" /> to a full page load (window.location.assign) so any path the Vite app does not own goes back to the server, which then serves the Next page. Next's own <Link> to a Vite route is a plain <a> for the same reason
API Unchanged: both apps call /api/v1 on the same origin
A trap to avoid A dynamic segment at the root of server/src/app/ matches every one-segment URL before the fallback is reached. TourCockpit has one (src/app/[locale]/, its localized public pages). Sondely does not copy it while the Vite app exists; locale prefixes, if any, are handled in middleware. The fallback check above fails if this is broken (Next documentation on rewrite order; behaviour with generateStaticParams is inferred and the test is what proves it)

Today the Python app serves the Vite build from backend/static with a catch-all (backend/app/main.py). That goes away with the cutover, when the Next server becomes the only web process in the image.

Today Evidence
The access token is stored in localStorage under access_token, plus a session_token frontend/src/utils/api.ts lines 142 to 152, frontend/src/contexts/AuthContext.tsx
14 pages read localStorage.getItem('access_token') directly, 24 call sites in pages: SurveyBuilderPage (7), SurveySharePage (3), CreateSurveyPage (2), EditSurveyPage (2), and one each in DashboardPage, SurveyTakePage, SurveyTemplatesPage, SurveyResponsesPage, CompliancePage, ImpactReportPage, SurveyPreviewPage, NotificationSettingsPage, OrganizationDetailPage, AdminTestPage grep of frontend/src
Three shared readers: src/api/client.ts (the axios client), src/utils/api.ts, src/utils/downloadExport.ts same
SurveyTakePage also writes the token after /auth/upgrade, and holds an auto-enrolled respondent's token in memory only (setSessionOnlyToken in src/api/client.ts) SurveyTakePage.tsx line 123
The Google and Apple callbacks receive the token in the query string GoogleCallbackPage.tsx and AppleCallbackPage.tsx line 30

The cutover keeps this bearer contract so no screen changes with the data (Auth). The move to TourCockpit's cookie happens with step 2 of the move order:

Step What changes
Sign-in screens move The Next sign-in routes set TourCockpit's httpOnly, Secure (outside local development), SameSite=Lax session cookie (src/lib/auth/sign-in.ts, src/lib/auth/session-cookie.ts, named tp_session in TourCockpit and renamed for Sondely). The OAuth callbacks set the cookie on the server and redirect, so no token appears in a URL
Both forms accepted The API accepts the cookie or the bearer header during the move, so a Vite page that still sends the header keeps working. The Vite api clients stop reading localStorage once the cookie is present: the three shared readers change in one commit, and the 14 pages that read the token directly are changed to call the shared client in the same commit (they only build an Authorization header)
Respondents The auto-enroll token becomes the respondent grant (Auth), set as its own cookie scoped to the survey's routes, or kept in memory as today. Which one is decided there
Bearer for browsers retired When no screen reads localStorage any more. API keys and agent clients keep the bearer header

Custom domains per org

Today an org can set a custom domain (components/CustomDomainSection.tsx, PUT on the organization). The browser asks GET /organizations/by-host on every page load, and contexts/TenantContext.tsx skips the probe for canonical hosts (localhost, sondely.com, impactpulse.catalyzeup.ai, impactpulse.catalyzeupdev.com, azurecontainerapps.io, azurestaticapps.net). On a custom domain, / redirects to the org's page or to sign-in.

In Next, this moves to server/src/middleware.ts:

Concern Rule
Resolving the host Middleware reads the Host header, skips the canonical list, and resolves any other host to an org by its stored custom domain, with a short in-process cache. The org id is passed to the page in a request header, so a server-rendered survey or landing page renders the org's branding in the first response, without the client probe
Unknown host Treated as canonical, as today
Isolation The host is presentation, never a tenant boundary. The same rule TourCockpit writes in src/lib/auth/brand-host.ts: the session and the tenant filter stay the only isolation
Cookies TourCockpit's white-label hosts are subdomains of one parent, so it writes a parent-domain cookie. Sondely's custom domains are unrelated hostnames (happiness.wehappers.org is the example in App.tsx), so the session cookie is host-only on each domain and a person signs in on each domain they use. Google and Apple sign-in must return to the domain they started on (backend/tests/test_oauth_return_origin.py describes today's behaviour)
TLS and DNS Unchanged by the move (Infrastructure)

TourCockpit's UI rules, adopted

Rule Source In Sondely
One copy of each component .claude/agents/tour-platform-components.md: a visual pattern exists once, in src/components/sketch/ (66 files: Button, Table, Card, Chip, EmptyState, Popover, RowActions, Select, opens-record.tsx and the rest), and a second copy is a defect. Every fold ships with a source-scan test A server/src/components/sketch/ folder. Sondely's frontend/src/components/ui/ (15 components and an index) is folded into it as screens move, never copied beside it. The components agent is copied and renamed
Desktop and phone parity .claude/agents/tour-platform-ui.md: a screen rendered as a desktop table and as phone cards is two trees, and a control added to one compiles and ships without the other. Primitives make the rule structural: opens-record.tsx (a row opens its record on every surface, scanned by tests/unit/rows-open-their-record.test.ts), overlays in a portal (tests/unit/overlays-must-portal.test.ts), widths proven in a browser Applied to the owner screens (members, invitations, responses, compliance), which are tables on desktop. Respondent screens are phone first and must be checked on the mobile project
Style guide from the registry src/styleguide/registry.tsx and src/app/style-guide/ Replaces pages/StyleGuidePage.tsx
Strings through the catalogs The hard-coded string guard (tests/unit/i18n-hardcoded-guard.test.ts) From the first moved file (Translation)
Contrast tests/visual/contrast.mjs Sondely already has one (frontend/tests/visual/contrast.mjs) and a unit test (frontend/src/styles/colorContrast.test.ts); the TourCockpit script replaces it in server/

When the Vite app is deleted

When the last route in the table above has moved or been retired, and:

  1. frontend/src/App.tsx has no route left except the catch-all.
  2. The fallback check has nothing left to check, and the rewrite in server/next.config.ts is removed.
  3. No code reads localStorage for a token.
  4. The frontend CI job is removed.

Then frontend/ and server/public/app/ are deleted, the frontend-build stage leaves the Dockerfile, and the Vite, react-intl and axios dependencies go with it. That is the done condition of phase 4b in the stack plan.

Open

  • The classic builder routes: retired or moved.
  • The respondent token after the move: a survey-scoped cookie or memory only (Auth).
  • Whether /style-guide stays public.