Skip to content

ADR-0001: Migrate frontend from legacy PHP to Next.js

Context

The frontend currently operates as a hybrid: PHP renders backend templates and serves statically built React assets (built via GitHub Actions) for client-side functionality, using Redux and React Query for client state — see Frontend, Tech stack, and React rewrite.

Pages that have not yet been converted from PHP remain critical for SEO, since the React SPA is client-side only and has no server-rendered HTML for crawlers.

Decision

Unify the frontend stack under the Next.js App Router, enabling Server-Side Rendering (SSR) for SEO-critical pages while preserving the existing Redux + React Query client state and CSR-only pages.

Phase 1 — Lift and shift existing client-side React pages

The existing React app is inherently CSR-only, so this phase requires minimal refactoring:

  • Client Component designation — add the 'use client' directive to existing page components so they continue to run client-side only, preserving compatibility with browser-only APIs and hooks.
  • State management integration — port the existing Redux store and React Query configuration into a single <Providers> Client Component wrapping the Next.js root layout.
  • Authentication checks — since these pages render client-side, route protection must happen before the JS payload executes. Use Next.js Middleware to intercept requests and redirect unauthorized users before the page renders.

Phase 2 — Convert legacy PHP pages for SEO

Pages not yet converted from PHP need SSR so crawlers see fully populated HTML on first load, while still giving the React Query developer experience:

  • /v2 API endpoint creation — each legacy PHP page currently renders its data server-side with no JSON endpoint behind it. Before a page can move to Phase 2, add a corresponding /v2/... endpoint (see API spec) that returns the same data the PHP template used, so fetchPost/fetchComments-style client functions have something to call from both Server and Client Components.
  • Server Component data fetching — fetch data directly inside Next.js Server Components so the initial HTML payload is fully populated.
  • TanStack Query hydration — prefetch data on the server with a server-side QueryClient, matching the client-side React Query setup.
  • Dehydration and boundary wrapping — dehydrate the prefetched server cache and pass it to the client via <HydrationBoundary>, so useQuery hooks on the client consume the SSR data instantly with no extra network request or loading spinner.

Fetching on the server (Server Component):

import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/get-query-client';
import { fetchPost } from '@/lib/api';
import PostContent from './PostContent';

export default async function PostPage({ params }: { params: { id: string } }) {
  const queryClient = getQueryClient();

  // Prefetching data on the server
  await queryClient.prefetchQuery({
    queryKey: ['post', params.id],
    queryFn: () => fetchPost(params.id),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <PostContent id={params.id} />
    </HydrationBoundary>
  );
}

Retrieving content on the component (Client Component):

'use client';
import { useQuery } from '@tanstack/react-query';
import { fetchPost } from '@/lib/api';

export default function PostContent({ id }: { id: string }) {
  // Retrieves data from the cache instantly without a new network request
  const { data } = useQuery({
    queryKey: ['post', id],
    queryFn: () => fetchPost(id),
  });

  return <div>{data?.title}</div>;
}

Fetching on demand (e.g. on user interaction):

'use client';
import { useQueryClient } from '@tanstack/react-query';
import { fetchComments } from '@/lib/api';

export default function LoadCommentsButton({ postId }: { postId: string }) {
  const queryClient = useQueryClient();

  const handleLoadComments = async () => {
    // Fetch and cache data on demand
    await queryClient.fetchQuery({
      queryKey: ['comments', postId],
      queryFn: () => fetchComments(postId),
    });
  };

  return <button onClick={handleLoadComments}>Load Comments</button>;
}

Execution & infrastructure

We adopt a Big Bang migration: cut over all pages from legacy PHP to Next.js in a single unified deployment, rather than maintaining proxy routing rules between old and new systems during an incremental rollout.

The current EC2 host OS does not natively support the Node.js versions Next.js requires. Containerization plus CI/CD bypasses this without an immediate OS upgrade:

  • Dockerization — a Docker image encapsulates the Next.js app and its required Node.js runtime, ensuring environment consistency independent of the host OS.
  • AWS ECR storage — built images are pushed to Amazon Elastic Container Registry.
  • CI/CD via GitHub Actions — on commits to main, build the Docker image, authenticate with AWS, and push the tagged image to ECR.
  • EC2 deployment — the EC2 instance periodically pulls the latest image from ECR and runs the container.

GitHub Actions pipeline (.github/workflows/deploy.yml):

name: Build and Push Next.js Image to Amazon ECR

on:
  push:
    branches:
      - main

jobs:
  build-and-push:
    name: Build Docker Image and Push to ECR
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    steps:
      - name: Checkout Code
        uses: actions/checkout@v4

      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2

      - name: Build, Tag, and Push Image
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          ECR_REPOSITORY: my-nextjs-app
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG

          # Tag as latest and push again
          docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:latest
          docker push $ECR_REGISTRY/$ECR_REPOSITORY:latest

EC2 deployment execution (run on the instance to pull and run the updated container):

# 1. Authenticate Docker with your ECR registry
aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin <your-aws-account-id>.dkr.ecr.us-east-1.amazonaws.com

# 2. Pull the latest Next.js image
docker pull <your-aws-account-id>.dkr.ecr.us-east-1.amazonaws.com/my-nextjs-app:latest

# 3. Stop and remove the old container
docker stop nextjs-app || true
docker rm nextjs-app || true

# 4. Run the new container, mapping port 80 to the internal Next.js port
docker run -d -p 80:3000 --name nextjs-app <your-aws-account-id>.dkr.ecr.us-east-1.amazonaws.com/my-nextjs-app:latest

Consequences

Positive

  • Single unified frontend stack (Next.js) instead of PHP + separately built React assets.
  • Native SSR gives SEO-critical pages server-rendered HTML without a separate rendering path.
  • Existing Redux/React Query code and developer experience carry over largely unchanged.
  • Docker + ECR decouples the app's Node.js version from the EC2 host OS.

Negative

  • Big Bang cutover has no incremental rollback path — a bad deploy affects the whole site at once.
  • All pages must be migrated before cutover, which is a large, high-risk single release.
  • Requires the EC2 instance to run Docker and reliably pull/restart the container on deploy.
  • Every legacy PHP page needs a new /v2 API endpoint before it can be converted, adding backend work beyond the frontend rewrite.

Alternatives considered

Option Pros Cons Why rejected
Big Bang (chosen) Single cutover, no dual-system maintenance No incremental rollback, all-at-once risk Accepted risk to avoid proxy complexity
Incremental / strangler-fig (page-by-page) Lower risk per release, gradual rollout Requires proxy routing rules between PHP and Next.js during the transition Adds architectural complexity we want to avoid

Follow-up tasks

  • [ ] Inventory every legacy PHP page and the data each one currently renders
  • [ ] Create a /v2 API endpoint for each legacy PHP page's data (prerequisite for Phase 2)
  • [ ] Write the Next.js Dockerfile
  • [ ] Provision the my-nextjs-app ECR repository
  • [ ] Add .github/workflows/deploy.yml to the frontend repo
  • [ ] Set up the EC2 pull/restart step (cron or systemd unit)
  • [ ] Update Tech stack, React rewrite, and API spec once new endpoints and cutover are complete
  • [ ] Retire the legacy PHP frontend docs after migration