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, markedSecurewhen served over https. ThelocalStoragetoken stays in the Vite app only, and goes when that app is retired. - The Vite app keeps serving sondely.com from
CatalyzeUp/sondelyuntil 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.
The sign-in token: from localStorage to an httpOnly cookie
| 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:
frontend/src/App.tsxhas no route left except the catch-all.- The fallback check has nothing left to check, and the rewrite in
server/next.config.tsis removed. - No code reads
localStoragefor a token. - The
frontendCI 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-guidestays public.