Skip to main content

Frontend modernisation

Migrating a JSP and jQuery Frontend to React Without a Big-Bang Rewrite

A practical plan for migrating a JSP and jQuery frontend to React incrementally, with clear ownership, API contracts, testing, rollout, and microfrontend trade-offs.

Written by Aminul Islam Alvi18 min read

A JSP and jQuery application rarely becomes difficult because JSP suddenly stops rendering HTML or jQuery stops handling clicks. It becomes difficult because years of behaviour accumulate in selectors, global variables, inline scripts, server-rendered fragments, CSS overrides, and endpoints designed around pages rather than explicit contracts.

That is also why “replace it with React” is not yet a migration plan.

A big-bang rewrite asks the business to fund the old product and its replacement at the same time. It delays user feedback until late, hides years of edge cases behind a clean demo, and creates a release where routing, authentication, analytics, accessibility, and every important workflow change together. The new code may be more pleasant while the delivery risk becomes much worse.

I prefer an incremental migration: keep the Java backend and JSP delivery path working, let React own carefully selected parts of the interface, and move the boundary only when the new path has earned confidence. The old and new frontends coexist for a while, but they do not share ownership of the same DOM or state.

This article describes that architecture, the first slice I would choose, the contracts that keep two runtimes from fighting, and the point at which microfrontends are useful rather than fashionable.

The target is safer change, not React everywhere

React is a means, not the success metric.

The migration should improve measurable constraints in the current product: lead time for a workflow, regression rate, accessibility, test confidence, interaction performance, onboarding time, or the cost of making one business change. A percentage such as “70% converted to React” says little if the hardest workflow still crosses both systems and every release remains risky.

I would write the target in operational terms:

  1. A team can change an order workflow without editing global jQuery handlers.
  2. The workflow has an explicit HTTP contract and automated coverage.
  3. A release can expose the React path to a small cohort and return them to the JSP path without a database rollback.
  4. Error rate, task completion, accessibility, and performance are no worse than the legacy baseline.
  5. Once the React path is stable, its replaced JSP, JavaScript, CSS, and feature flag can be deleted.

That last point matters. An incremental migration is not permission to keep two implementations forever. Every slice needs an entry condition, a comparison period, and a deletion condition.

The pattern resembles Martin Fowler's Strangler Fig Application: new capabilities grow around an existing system and gradually replace it. For a frontend, however, the practical risk is finer-grained. Two rendering systems can occupy one page, so ownership must be defined at DOM, data, navigation, and styling boundaries—not only at the URL.

Map the legacy frontend before choosing a boundary

Start with behaviour, not file extensions.

A JSP page may include tag libraries, fragments, inline JavaScript, a shared layout, and a bundle that registers delegated jQuery handlers against the entire document. A button may trigger an Ajax request, replace a server-rendered fragment, update a global cart count, and open a plugin-controlled modal. Looking only at the template makes that button appear much simpler than it is.

For each candidate workflow, I map:

  1. The URL and server controller that create the page.
  2. JSP includes and conditional rendering.
  3. DOM selectors read or mutated by jQuery.
  4. Ajax endpoints, form posts, redirects, and validation responses.
  5. Session, permission, CSRF, locale, and feature-flag inputs.
  6. Global events, timers, plugins, and analytics calls.
  7. CSS selectors that can affect the area from outside.
  8. Keyboard, focus, and screen-reader behaviour.
  9. Browser and device requirements.
  10. Production traffic, errors, and support issues.

The result is a dependency map, not a proposal to rewrite everything on it. It shows which apparently small widgets depend on half the application and which complete workflow already has a clean seam.

The best first slice is valuable enough to prove the approach but bounded enough to reverse. I look for a page or panel with one clear job, a small number of backend calls, moderate traffic, known users, and limited dependence on global plugins. An internal administration workflow is often better than the site-wide navigation or checkout. The first slice should exercise real authentication, validation, analytics, deployment, and support paths without putting the company's most expensive transaction at risk.

Before changing it, I add characterisation tests around the behaviour the product must preserve. Legacy behaviour can be awkward and still be relied upon. The migration is the wrong place to mix a framework change, visual redesign, API redesign, and policy change unless those changes are deliberately separated and tested.

Choose a boundary that one runtime can own

The most important rule during coexistence is simple:

At any moment, one runtime owns a DOM subtree and one system owns each piece of mutable state.

There are three useful boundary sizes.

A small island is a self-contained control such as an address finder, date-range picker, or upload panel. It is easy to introduce, but dozens of islands can create repeated bootstrapping, duplicated requests, inconsistent context, and complicated page-level coordination.

A feature panel owns a complete task inside a JSP page: an order editor, permission manager, or report builder. This is often the best starting boundary because it is large enough to contain its state and small enough to roll back.

A route gives React the entire content area below a stable server-owned shell. It creates the cleanest frontend boundary. The Java application can still authenticate the request, render the surrounding layout, and serve or proxy APIs, while React owns everything inside the route root. React's own guidance for existing projects supports both incremental roots inside an existing page and complete React subroutes.

I normally move from one feature panel toward route ownership. I do not scatter React across every button merely because small components feel safe. The migration unit should follow business cohesion: state that changes together should usually live under the same owner.

At a route boundary, use ordinary full-page navigation between legacy and modern areas first. A global client-side router can come later if it produces a user benefit. Keeping browser navigation and server routing intact removes an entire category of transition bugs involving history, deep links, permissions, focus, and analytics.

Mount React inside a JSP-owned page

JSP can continue to produce the document, header, navigation, and an empty mount point. React owns only the children of that mount point.

The JSP side can be deliberately boring:

<div
  id="order-editor-root"
  data-order-id="<c:out value='${order.id}' />"
  data-api-base="<c:url value='/api/orders' />"
></div>

<script type="module" src="<c:url value='/assets/order-editor.js' />"></script>

The entry module reads a small bootstrap contract and creates one React root:

import { createRoot } from 'react-dom/client'

import { OrderEditor } from './OrderEditor.jsx'

const container = document.getElementById('order-editor-root')

if (container) {
  const { orderId, apiBase } = container.dataset

  if (!orderId || !apiBase) {
    throw new Error('Order editor bootstrap data is incomplete')
  }

  createRoot(container).render(
    <OrderEditor orderId={orderId} apiBase={apiBase} />
  )
}

This example targets the React 19 client API and JSP 3.1 with JSTL. React's createRoot documentation defines it as the API for rendering a React tree into a browser DOM node. Do not call hydrateRoot on HTML generated by a JSP implementation of the feature. React documents hydrateRoot for HTML previously generated by React on the server, with equivalent output. Similar-looking JSP markup is not React server rendering.

The production build should generate content-hashed assets and a manifest that the Java application can resolve. That gives each deployment immutable files and prevents a cached HTML page from accidentally loading a semantically different bundle under the same name. I also load the entry only on pages that contain its root. Shipping React, React DOM, and feature code across every legacy route creates transition cost without transition value.

If the page can insert and remove the mount point without a full navigation—for example, a legacy tab system loads server fragments—give the integration a small mount(container, options) and unmount() adapter. Store the returned root and call root.unmount() before the legacy code removes its container. This makes lifecycle ownership explicit and avoids abandoned subscriptions or document listeners.

Make data, authentication, and events explicit

The initial page needs enough information to start, but the JSP should not become a second state store for the React feature.

I pass identifiers, stable URLs, locale, and a small amount of immutable request context through quoted data-* attributes. The component then fetches its resource from a versioned JSON endpoint. Larger bootstrap data can come from a dedicated JSON response. Avoid pasting arbitrary JSON.stringify() output into an inline script: OWASP's XSS guidance notes that valid JSON is not automatically safe in HTML or inline JavaScript contexts.

Keep authentication authoritative on the server. For a same-origin application, the existing session cookie can continue to identify the user. The React client should receive the same 401, 403, and validation semantics as any other API consumer. It should not reconstruct permissions from buttons rendered by JSP. Hiding a button is presentation; the server still authorises the operation.

CSRF handling also belongs in the bootstrap contract. If the Java application expects a token in a header, expose the token and header name through a context-appropriate encoded value or endpoint, then centralise the behaviour in one API client. Do not let every component invent its own fetch options.

The API should return domain-shaped errors rather than HTML fragments:

{
  "code": "ORDER_VERSION_CONFLICT",
  "message": "This order changed after you opened it.",
  "currentVersion": 18
}

That contract lets the React feature offer a deliberate refresh or comparison path. It also separates backend behaviour from the details of either frontend.

Sometimes the migrated panel must tell the legacy page that something happened. Prefer a narrow callback supplied by the integration adapter. When the boundary cannot import the legacy code, a browser CustomEvent can carry a coarse domain notification:

container.dispatchEvent(
  new CustomEvent('orders:updated', {
    bubbles: true,
    detail: { orderId, version },
  })
)

The legacy shell can listen and refresh its summary. Keep these events few, documented, and versioned when their shape changes. A global event bus with dozens of undocumented messages recreates the coupling that the migration was meant to remove.

Most importantly, do not let JSP, jQuery, and React each keep a writable copy of the same order. The server resource is the durable source of truth; React owns the edit session inside its boundary; the legacy shell receives only the summary it needs. When two interfaces must edit the same state simultaneously, the boundary is probably too small.

Stop jQuery and React from fighting over the DOM

React assumes it controls the nodes below its root. Legacy scripts often assume the document is available for mutation. Coexistence works only when both assumptions are narrowed.

I enforce these rules:

  1. jQuery must not select into [data-react-root] or replace its descendants.
  2. React must not query for and mutate legacy nodes outside its root.
  3. A jQuery plugin used by React is wrapped in one component that creates, updates, and destroys it through a ref.
  4. Document-level listeners have named owners and cleanup paths.
  5. Server fragment replacement never removes a live React root without unmounting it.

Delegated handlers are a common trap. Code such as $(document).on('click', '.save', handler) can unexpectedly match a new React button that reuses a generic legacy class. During the migration, qualify legacy selectors under a legacy container and give integration selectors names that describe their role rather than their appearance.

CSS crosses boundaries even when JavaScript behaves. A legacy rule like form input { ... } reaches into the React subtree. A new global reset can damage the rest of the JSP page. I start with locally scoped styles, a root class, and no global reset in the feature bundle. CSS Modules or a strict naming convention help with new styles, but they do not stop old global selectors from entering the root. Use the browser's computed-style tools to identify those leaks and remove them deliberately.

Portals need the same care. A modal rendered into document.body has left the apparent root and may inherit a different stacking and style environment. Prefer an overlay container owned by the feature, or make the global overlay host an explicit shared platform contract.

Accessibility must survive the boundary too. If React replaces a panel after an action, preserve a logical focus path, announce important asynchronous status, and keep labels and error relationships intact. W3C's WCAG 2.2 focus-order guidance requires sequential focus to preserve meaning and operability. A visually correct migration can still be a regression when focus disappears into the document or a legacy modal traps it behind the new panel.

Migrate one vertical slice at a time

A useful slice runs from the user interaction to the backend contract and back. Replacing only the markup while keeping implicit jQuery state underneath produces a React-shaped legacy system.

For an order editor, I would sequence the work like this:

  1. Capture current save, validation, permission, conflict, and cancellation behaviour with tests and production baselines.
  2. Add or stabilise JSON endpoints behind the existing Java service layer. Keep the old JSP action working.
  3. Create the hashed asset build and a reusable JSP mount tag or include.
  4. Implement the React editor against the explicit API, including loading, empty, error, conflict, and unauthorised states.
  5. Put the new path behind a server-controlled flag evaluated before the page is rendered.
  6. Run the same end-to-end acceptance scenarios against the JSP and React variants.
  7. Release to the engineering team, then internal users, then small customer cohorts.
  8. Compare behaviour and operational signals, fix gaps, and expand the cohort.
  9. Remove the old editor, its handlers, CSS, template fragment, and unused endpoint behaviour.
  10. Remove the temporary flag after the rollback window closes.

This sequence creates a repeated migration mechanism. The second feature should reuse the asset manifest, API client, error model, observability, feature-flag approach, and test harness. If every new root needs a different boot process, the platform boundary is not stable yet.

Do not build a complete design system before the first slice. Start with the tokens and components the slice actually needs, but review them for reuse. Promote a component only after a second real use shows which variation belongs in its API. This avoids replacing accidental JSP conventions with speculative React abstractions.

Test the transition, not only the new components

Component tests are necessary and insufficient. Most migration failures happen where systems meet.

I use a layered strategy:

Characterisation tests record legacy business behaviour before implementation begins. They make accidental policy changes visible.

API contract tests verify payloads, validation codes, authorisation, CSRF behaviour, concurrency conflicts, and compatibility with both clients. During coexistence, changing a response can break the old page even when the React tests pass.

Component integration tests exercise the React feature with realistic network responses, keyboard interaction, loading, errors, and cleanup. Test observable behaviour rather than internal hook calls.

Boundary tests render the JSP shell with the mount attributes and built asset manifest. They catch missing roots, bad URLs, absent context, and route-specific bundle problems.

End-to-end tests cover protected customer journeys across both variants. Include deep links, back and forward navigation, session expiry, permission changes, double submission, slow responses, and legacy-to-React navigation.

Accessibility checks combine automation with keyboard and screen-reader review. Automated tools find many markup violations; they do not decide whether focus moved somewhere meaningful after the interface changed.

Visual regression tests are useful around CSS leakage and shared layouts. They should support, not replace, behavioural assertions.

Run a small contract suite against both implementations until the legacy path is deleted. I do not require DOM equivalence. The implementations can differ structurally while preserving the same business and accessibility outcomes.

Release with a fast way back

An incremental architecture is only safe if the release mechanism is incremental too.

The server should decide whether a request receives the JSP or React variant using a stable flag based on user, tenant, or route. Stable assignment prevents a user from switching implementations halfway through a task. Keep the flag independent from database schema migrations whenever possible so rollback means rendering the old path again, not reversing stored data.

For each variant, observe:

  1. JavaScript errors and failed asset loads.
  2. API error codes and latency.
  3. Task completion, cancellation, and repeated submissions.
  4. Core interaction and loading performance for the migrated route.
  5. Accessibility defects and support contacts.
  6. Business outcomes that the workflow affects.

Record the variant with logs and analytics so comparisons are real. A site-wide error average can hide a serious problem in the 5% cohort using the new feature.

The transition temporarily ships more code, so set a JavaScript budget for each route. Do not load the legacy feature bundle when React owns the route. Split modern bundles by workflow, cache immutable assets, and track parse and execution cost on representative devices. The goal is not merely to make the React bundle small; it is to make the total route cheaper as the legacy code disappears.

Define rollback triggers before release. An increase in failed saves, permission errors, uncaught exceptions, or task abandonment should have an owner and an action. “We can turn off the flag” is incomplete if nobody knows who watches it or how quickly a cached page respects the change.

When the migration should become microfrontends

A React root inside JSP is not automatically a microfrontend. A route-specific bundle is not automatically one either.

I reserve the term for frontend slices with meaningful autonomy: a team owns a business capability, builds it independently, can release it without coordinating a single application deployment, and consumes explicit platform contracts. That autonomy has a cost.

Microfrontends can be justified when:

  1. Several durable teams own distinct product domains.
  2. A shared frontend release train is a demonstrated delivery bottleneck.
  3. Teams need independent release and rollback schedules.
  4. Route or feature boundaries are stable enough to expose small contracts.
  5. The organisation can operate multiple builds, dependency policies, observability views, and incident paths.

They are usually the wrong response when one team owns the product, the main problem is tangled jQuery, or the proposed “domains” are buttons and visual components. Splitting one coupled codebase into independently deployed coupled codebases adds network and operational failure without creating autonomy.

Start with one React build containing feature modules. It is the least expensive way to learn the domain boundaries. If build and deployment ownership later need to separate, there are several composition choices:

  1. Server or route composition: the Java server or edge proxy sends different routes to different frontend applications. This is the clearest operational boundary and preserves ordinary navigation.
  2. Build-time packages: teams publish versioned components or domain libraries consumed by one application build. Releases are coordinated, but runtime failure stays simpler.
  3. Runtime composition: a shell loads independently deployed frontend modules. Webpack's Module Federation documentation describes separate builds forming one application and loading remote modules asynchronously.
  4. Iframes: strong runtime and style isolation at the cost of harder navigation, sizing, accessibility, authentication, and cross-frame communication. They suit a few genuinely isolated or untrusted experiences, not a default product shell.

Runtime federation does not remove contracts. It makes them more important. Define the shell API, route ownership, authentication context, design tokens, analytics, supported browser policy, shared dependency ranges, loading fallback, error isolation, and rollback behaviour. A remote asset can fail while the shell succeeds; the page needs a bounded failure state rather than a blank region.

Be conservative about shared mutable state and shared libraries. Sharing React can reduce duplicate runtime cost, but version negotiation and deployment compatibility need policy. Sharing every internal package turns independent deployments back into a distributed monolith. Prefer browser and HTTP contracts, and share implementation only where coordination is intentional.

Microfrontends solve an organisational scaling problem. They do not make the underlying feature boundaries coherent for you.

Know when the migration is finished

The goal need not be zero JSP.

Server-rendered pages that are fast, accessible, secure, and cheap to change may have no business case for conversion. A mostly static account statement or legal page does not improve merely because React renders it. The useful finish line is that every remaining technology has clear ownership and no legacy path prevents important product change.

For each migrated slice, finish the deletion:

  1. Remove unused JSP fragments and tag includes.
  2. Remove jQuery handlers, plugins, selectors, and global variables.
  3. Remove CSS that existed only for the old feature.
  4. Remove compatibility endpoints or response fields no consumer uses.
  5. Remove feature flags and variant analytics after the observation window.
  6. Update runbooks, architecture maps, and ownership information.

Track deletion alongside migration. A dashboard of new React components can hide a growing coexistence layer. I would rather complete three vertical slices and delete their legacy code than mount React in twenty places while all twenty old implementations remain underneath.

The architecture can also stop at a hybrid. JSP may remain the authenticated shell and delivery mechanism while React owns the workflows that benefit from client-side state. That is a valid destination when the boundary is explicit, performance is acceptable, and the team can operate it confidently.

An incremental frontend migration checklist

Before approving a slice, I ask:

  1. What user or delivery constraint will this migration improve?
  2. Which DOM subtree and mutable state does React own?
  3. Can jQuery reach into that subtree through selectors or delegated handlers?
  4. Is the backend contract JSON and independent of either view implementation?
  5. How are session, permission, CSRF, locale, and errors represented?
  6. What happens when the bundle or API fails to load?
  7. Which keyboard, focus, and screen-reader behaviour must remain intact?
  8. Can the server route a stable cohort back to the legacy path quickly?
  9. Which metrics compare the old and new implementations?
  10. What exact code and infrastructure will be deleted after adoption?
  11. Does an independent deployment boundary solve a real team problem, or only add another runtime?
  12. Who owns the slice after the migration team moves on?

If those answers are vague, adding React will increase the number of frontend technologies without reducing uncertainty. If they are concrete, the first migration can be small, useful, and reversible—and every completed slice makes the next change safer.

Frequently asked questions

Common questions about migrating JSP and jQuery to React

Can React and jQuery run on the same page?

Yes. Give React an explicit mount element and prevent jQuery from changing anything below it. Qualify legacy delegated selectors, make lifecycle cleanup explicit, and exchange only coarse events or callbacks across the boundary. Problems begin when both libraries believe they own the same DOM or mutable state.

Should we replace JSP before adding React?

No. JSP can continue to render the document shell and React mount points while the Java application provides JSON APIs. Replace server rendering only where a different delivery architecture has a demonstrated benefit. Changing the template engine, frontend framework, backend contracts, and deployment topology together increases migration risk.

Should the first React migration be a tiny component?

Choose the smallest boundary that can own a coherent task. A tiny visual component is easy to mount but may depend on legacy state and events around it. A feature panel or modest route often produces a cleaner ownership boundary and better evidence about the real delivery path.

Do we need Module Federation for this migration?

No. Begin with one React build and route- or feature-level modules. Add runtime federation only when durable teams need independent builds and deployments, the business boundaries are stable, and the organisation is ready to operate remote-loading failures, dependency compatibility, observability, and cross-application contracts.

How long should JSP and React coexist?

Long enough to migrate and observe one bounded slice, but not indefinitely for that slice. Define the rollout and deletion conditions before implementation. Different areas of the product may remain on JSP for years if they are cheap to maintain; one completed workflow should not retain two implementations after the rollback window closes.

ReactJSPjQueryFrontend modernisationMicrofrontends