Tinker AI
Read reviews
intermediate 7 min read

Windsurf for full-stack work: keeping the frontend and backend in one Cascade session

Published 2026-04-10 by Owner

Most full-stack work has the same shape: a feature requires changes on both the frontend and the backend, and the changes need to fit together — types matching, contracts agreed, error states aligned. The traditional approach with AI tools is to do the backend in one session, then context-switch to the frontend with a new session, then re-explain enough of the backend to keep the frontend consistent.

Windsurf’s Cascade can hold both in one session, with both file trees in scope. For full-stack features, this is a meaningful workflow improvement. After about two months of using it this way on a Next.js + Express monorepo, here’s what works.

The setup

The repo structure that Cascade handles well:

my-app/
├── apps/
│   ├── web/        # Next.js frontend
│   └── api/        # Express backend
├── packages/
│   ├── shared-types/   # TypeScript types shared by both
│   ├── ui/             # Shared UI components
│   └── db/             # Database client and schemas
└── package.json

Open Windsurf at the repo root. Cascade can navigate the whole tree. The shared-types package becomes the natural anchor for full-stack changes — the types defined there are imported by both apps.

A working brief for full-stack features

The brief structure that consistently produces good output for full-stack work:

GOAL
<one-sentence user-visible outcome>

TYPE CONTRACT
<the shared types this feature involves; the source of truth for both sides>

BACKEND CHANGES
<files in apps/api that need to change>

FRONTEND CHANGES
<files in apps/web that need to change>

INTEGRATION POINTS
<where the two sides meet — usually API endpoints and the type contract>

CONSTRAINTS
<existing patterns to match, things not to touch>

DONE MEANS
<the integrated success criterion>

A real example, adding a feature for users to set notification preferences:

GOAL
A user can set their notification preferences (email on/off, daily digest 
on/off, real-time alerts on/off) in their settings page, with changes 
persisting to the database.

TYPE CONTRACT
NotificationPreferences in packages/shared-types/src/notifications.ts:
{ emailEnabled: boolean; dailyDigest: boolean; realtimeAlerts: boolean }

BACKEND CHANGES
- apps/api/src/routes/preferences.ts (new): GET and PATCH /preferences
- apps/api/src/db/preferences.ts (new): DB helper for read/update
- apps/api/src/db/schema.ts: add notification_preferences table

FRONTEND CHANGES  
- apps/web/src/app/settings/notifications/page.tsx (new): the settings UI
- apps/web/src/hooks/usePreferences.ts (new): hook for read/update
- apps/web/src/components/PreferenceToggle.tsx: reuse existing component

INTEGRATION POINTS
- GET/PATCH /preferences with NotificationPreferences as the body shape
- Auth via the existing session middleware
- The frontend hook must handle pending state during PATCH

CONSTRAINTS
- Use the existing Drizzle ORM pattern in apps/api/src/db
- Use the existing useSession hook for auth on the frontend
- shadcn/ui Switch component for toggles, not custom

DONE MEANS
- Settings page renders with current preferences loaded
- Toggling a switch persists the change
- Reloading shows the persisted state
- A test in apps/api covering the GET and PATCH flow

That’s about 250 words. Cascade produced a 9-file diff (3 new backend, 3 new frontend, 1 modified type file, 1 modified schema, 1 new test). The diff was reviewable: every file change served a stated purpose in the brief.

I accepted the diff with two small adjustments — renamed an internal helper for naming consistency, and tightened a TypeScript type that Cascade had left looser than I prefer. About 4 minutes total review time.

Why this matters

The alternative workflow — backend session then frontend session — has specific failure modes:

Type drift. The backend session generates a response shape, the frontend session generates a consumer, and somewhere between the two the shapes diverge. Either the frontend handles fields the backend doesn’t send, or vice versa. The bug surfaces in integration testing, not in either session’s local check.

Naming inconsistency. Backend uses email_enabled, frontend uses emailEnabled, your serializer translates inconsistently. Cascade with both sides in scope catches this naturally.

Auth assumption mismatches. Backend session asks “is this an authenticated route?” and Cascade picks one answer. Frontend session asks “does this hook need auth headers?” and picks another. With both in scope, the auth pattern stays consistent.

Error state drift. Backend returns 422 with a specific error code; frontend handles 400 generically. Both sides looked correct in isolation; the integration is broken.

A unified Cascade session catches these because the model has both sides in context. It’s the same reason human pair programming on full-stack features works — shared context prevents drift.

When unified sessions don’t help

Three cases where the workflow breaks down:

Heavy backend or heavy frontend changes that don’t really integrate. A 200-line backend optimization with no frontend impact doesn’t benefit from frontend context. Just do it as a backend session.

Tasks that span more than two apps. Cascade handles two-app contexts well. Three or more (frontend + backend + admin tool + worker) starts to dilute attention. Split into pairs.

Major architectural changes. Cross-cutting changes like “switch from REST to GraphQL across the whole stack” are too big for one Cascade session. Break them down.

Legacy code on either side. If the backend or frontend is messy enough that Cascade can’t accurately model it, unified sessions amplify the confusion. Untangle one side first, then bring the other in.

The token cost

Unified sessions are more expensive in tokens than focused sessions. The full-stack session for the notification preferences feature used about 380k input tokens for one feature; a backend-only or frontend-only equivalent would have used 180k each, totaling 360k. So the unified version is slightly more efficient than the split version.

But the savings are conditional on the unified version succeeding without re-prompts. If you have to redo a unified session, the cost is double. Split sessions are more forgiving — re-doing one half doesn’t cost the other.

For features I’m confident about, unified is cheaper. For features I’m uncertain about, split is safer.

Editor configuration

A few Windsurf settings that help full-stack work:

Workspace setup. Open the monorepo root, not a subdirectory. Cascade can navigate the whole tree only if it starts at a root that contains both apps.

Multi-app awareness via @-references. When briefing Cascade, you can @apps/api and @apps/web to include both as context. Without this, Cascade might focus on whichever app contains the file you currently have open.

Test command in the .windsurf config. If the test command is repo-wide (pnpm test runs all packages), Cascade can run it after generating changes. This catches integration issues automatically.

Avoid auto-accepting. Full-stack diffs are larger and more important to review than focused diffs. Make sure auto-accept is disabled for these sessions.

What I’ve learned about briefing

After a few weeks of full-stack Cascade use:

The TYPE CONTRACT section is the highest-leverage part of the brief. When the shared types are explicit, both sides snap into alignment naturally. When they’re implicit, drift happens.

INTEGRATION POINTS deserves its own section. Calling these out separately from “backend changes” and “frontend changes” makes Cascade pay extra attention to where the two halves meet. Without this section, the changes are sometimes correct individually but don’t quite connect.

DONE MEANS should describe the integrated outcome. Not “the backend tests pass and the frontend renders.” Specifically “the user can do X end-to-end.” This focuses Cascade on the integration as the deliverable.

CONSTRAINTS often needs both backend-flavor and frontend-flavor entries. “Use the existing auth middleware” covers one side; “use the existing useSession hook” covers the other. Both are important for keeping the diff consistent with the rest of the codebase.

What this is not

Unified Cascade sessions don’t make full-stack work easy. They make it less awkward. The brief is more work to write than two simpler briefs would be. The diff is larger to review. The risk if Cascade misunderstands is bigger.

What you save is the cognitive load of holding both sides in your head while context-switching between AI sessions. For features where the integration is non-trivial, that load reduction is meaningful. For features where each side is independently simple, it’s not worth the brief overhead.

The decision rule: if the type contract between frontend and backend is the riskiest part of the feature, use a unified session. If the type contract is obvious and the work on each side is the riskier part, use split sessions.

This rule has held up well in practice. Most modern full-stack features I do are in the “type contract is the risky part” category — that’s where the bugs come from, and that’s where unified sessions help most.