Symfony + React: Building a Fullstack App with API Platform
AI generated
SF
{ }
Symfony · React · API Platform · Fullstack · TypeScript
Symfony + React:
Building a Fullstack App with API Platform

Symfony as the backend framework, API Platform as the REST layer and React as the frontend form a stack where every layer plays to its strengths. The result: a type-safe fullstack application where the TypeScript client is generated from the OpenAPI schema and JWT authentication is cleanly integrated into both layers.

20 min. read JWT · TypeScript codegen · React Query · CORS · Deployment Symfony 7.x · API Platform 4.x · React 19 · PHP 8.4

1. Why Symfony + React + API Platform Fit Together So Well

The combination of Symfony, API Platform and React is no accident, it is the result of a clear division of labor. Symfony takes care of everything that happens on the server: business logic, database access, authentication and authorization. API Platform declaratively turns Symfony domain classes into REST endpoints with automatic OpenAPI documentation. React renders the user interface, manages local state and communicates exclusively over HTTP with the Symfony API. Each layer can be developed, tested and scaled independently.

The decisive advantage over a monolithic Symfony frontend built with Twig templates lies in the clear interface definition. The OpenAPI schema that API Platform generates automatically is the single contract definition between backend and frontend. A TypeScript codegen tool reads this schema and generates type-safe client functions, so typos in API paths or wrong parameter types are caught immediately at build time, not later in production. Symfony as the backend remains completely untouched by frontend decisions: React could be swapped for Vue.js or a mobile app at any time without changing a single line of Symfony code.

2. Project Structure: Monorepo vs. Separate Repositories

With a Symfony-React fullstack app, the question of repository structure comes up right away. The monorepo model places backend and frontend in the same repository: /backend for the Symfony project, /frontend for the React app. The advantage is atomic commits that version backend and frontend together, plus a single CI/CD pipeline for the whole stack. The downside is that teams which strictly separate backend and frontend end up being pulled into each other's code reviews.

Separate repositories make sense when backend and frontend are owned by different teams, or when the Symfony backend serves several frontend clients at once, for example a web app and a mobile app simultaneously. In that case the OpenAPI schema coordinates the interface: the backend team publishes schema changes, the frontend team regenerates the TypeScript client and adjusts the implementation. For most mid-sized projects the monorepo is the more practical choice, because it reduces coordination overhead and allows local development with a single docker compose up.


<?php
// Monorepo structure for Symfony + React fullstack app
// backend/ -> Symfony 7 application with API Platform
// frontend/ -> React 19 application with TypeScript

// backend/composer.json (relevant packages)
// "require": {
//   "api-platform/core": "^4.0",
//   "api-platform/doctrine-orm": "^4.0",
//   "lexik/jwt-authentication-bundle": "^3.0",
//   "nelmio/cors-bundle": "^2.5",
//   "symfony/security-bundle": "^7.0"
// }

// frontend/package.json (relevant packages)
// "dependencies": {
//   "react": "^19.0",
//   "@tanstack/react-query": "^5.0",
//   "react-hook-form": "^7.0",
//   "zod": "^3.0"
// },
// "devDependencies": {
//   "@hey-api/openapi-ts": "^0.50.0",
//   "typescript": "^5.4"
// }

// docker-compose.yml brings both services up:
// backend: symfony local server or php-fpm + nginx on port 8000
// frontend: vite dev server on port 5173 with proxy to backend

3. Configuring CORS Correctly

The most common error the first time you start a Symfony-React app is a CORS error in the browser. React runs on port 5173 (Vite) or 3000 (Create React App), Symfony on port 8000. The browser blocks cross-origin requests if the server does not send back the appropriate CORS headers. The nelmio/cors-bundle is the standard solution in Symfony for CORS configuration and integrates cleanly into the Symfony request lifecycle.

The configuration in config/packages/nelmio_cors.yaml defines which origins are allowed, which HTTP methods are accepted and whether credentials (cookies, authorization header) may be sent along. For the development environment you explicitly allow the local Vite dev server. In the production environment you enter the final domain. Important: allow_credentials: true requires that allow_origin is not a wildcard (*) but an explicit domain. The Authorization header must be listed in allow_headers so that JWT tokens can pass through Symfony requests.

4. JWT Authentication in Symfony and React

JWT authentication is the standard for Symfony APIs with React frontends, because JWT tokens are stateless and require no session management on the server. The lexik/jwt-authentication-bundle handles token generation and validation in Symfony. After a successful login through the /auth/token endpoint, Symfony returns a JWT that the React client stores in localStorage or an HttpOnly cookie. Every subsequent API request includes the token in the Authorization: Bearer header.

On the React side you implement an API client wrapper that automatically attaches the token to every request. With React Query you define a global queryClient whose defaultOptions use a shared fetch wrapper. When Symfony returns a 401 status, React Query automatically triggers a redirect to the login page or renews the token through a refresh endpoint. Token refresh logic belongs in a central interceptor rather than in every individual React component, to keep the code maintainable.


<?php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\User;
use Lexik\Bundle\JWTAuthenticationBundle\Services\JWTTokenManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Doctrine\ORM\EntityManagerInterface;

/**
 * Handles JWT token issuance for React frontend authentication.
 */
#[Route('/auth')]
final class AuthController extends AbstractController
{
    public function __construct(
        private readonly JWTTokenManagerInterface $jwtManager,
        private readonly UserPasswordHasherInterface $passwordHasher,
        private readonly EntityManagerInterface $entityManager,
    ) {}

    /**
     * Issue a JWT token for valid credentials.
     * POST /auth/token { "email": "...", "password": "..." }
     */
    #[Route('/token', methods: ['POST'])]
    public function token(Request $request): JsonResponse
    {
        $data = json_decode($request->getContent(), true);
        $email = $data['email'] ?? '';
        $password = $data['password'] ?? '';

        $user = $this->entityManager
            ->getRepository(User::class)
            ->findOneBy(['email' => $email]);

        if (!$user || !$this->passwordHasher->isPasswordValid($user, $password)) {
            throw new BadCredentialsException('Invalid credentials.');
        }

        // Token contains user roles, expiry and custom claims
        return $this->json([
            'token'      => $this->jwtManager->create($user),
            'expires_in' => 3600,
            'user'       => ['email' => $user->getEmail(), 'roles' => $user->getRoles()],
        ]);
    }
}

5. Generating a TypeScript Client from OpenAPI

One of the biggest advantages of the Symfony-React combination with API Platform is automatic code generation. The tool @hey-api/openapi-ts reads the OpenAPI JSON that API Platform serves under /api/docs.json and generates type-safe TypeScript interfaces and client functions for every endpoint. That means: when a new field is added to an entity in Symfony, it appears automatically in the TypeScript type after the next codegen run, no manual synchronization required.

The codegen script is registered as an npm script in package.json and runs in the CI pipeline after every backend deployment. The generated code lands in a separate folder (src/api/generated) that is never edited manually. Your own wrapper hooks in src/api/hooks use the generated types and functions but add React Query integration, caching configuration and error handling. That way the generated code stays clean and your own logic is clearly separated. Symfony validation errors in RFC-7807 format (the API Platform standard) are parsed type-safely by the frontend wrapper and passed on as form errors.

6. React Query for API State and Caching

React Query (TanStack Query) is the standard tool for server-state management in React apps that talk to a Symfony API. The basic principle: every API call is defined as a query with a unique key. React Query caches the result, shows stale data immediately, refetches in the background and syncs automatically when the browser tab becomes active again. For an app backed by Symfony, that means the product list is rendered immediately from the cache while a fresh request goes to the Symfony API in the background.

Mutations in React Query encapsulate POST, PUT, PATCH and DELETE requests against Symfony. After a successful mutation you invalidate the affected queries so React Query refetches the data. The classic pattern: after creating a product via POST /api/products, the query key ['products'] is invalidated, React Query automatically refetches the list, and the UI shows the new product without a manual state update. Optimistic updates show the result immediately in the UI and roll back automatically on a Symfony error.


// React Query + generated Symfony API client - frontend/src/api/hooks/useProducts.ts

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
// Import from generated client (from Symfony OpenAPI schema)
import { ProductsService, type Product } from '../generated';

// Query key factory - centralised to avoid typos
export const productKeys = {
  all:    () => ['products'] as const,
  detail: (id: number) => ['products', id] as const,
};

// Fetch product list - React Query caches and revalidates automatically
export function useProducts() {
  return useQuery({
    queryKey: productKeys.all(),
    queryFn:  () => ProductsService.getProductCollection(),
    staleTime: 60_000, // 60 seconds before background refetch
  });
}

// Create product - invalidates list after success
export function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: Omit<Product, 'id'>) =>
      ProductsService.postProductCollection({ requestBody: data }),

    onSuccess: () => {
      // Symfony returned 201 - invalidate the list so it refetches
      queryClient.invalidateQueries({ queryKey: productKeys.all() });
    },

    onError: (error) => {
      // Symfony API Platform returns RFC-7807 violation format on 422
      console.error('Symfony validation error:', error);
    },
  });
}

7. Forms with React Hook Form and Symfony Validation

Form validation in a Symfony-React app happens on two levels: client-side with React Hook Form and Zod, server-side with Symfony's validator component. Both levels check the same rules, NotBlank, minimum lengths, email format, but for different reasons. Client-side validation gives instant feedback without a network request. Server-side validation in Symfony is the real security line, because client-side code can always be bypassed.

When Symfony returns a validation violation as HTTP 422, the response in API Platform format contains a violations array with propertyPath and message. The React Hook Form wrapper parses this array and sets the errors with form.setError(propertyPath, { message }) directly on the affected fields. The form then shows the Symfony validation error in the correct language beneath each input field, with no manual error mapping in the frontend code. Zod schemas on the client mirror the Symfony constraints and catch the most obvious errors before the request is even sent.

8. Error Handling: Type-Safe API Errors in the Frontend

API Platform on Symfony returns errors in RFC-7807 format: a JSON object with type, title, status and detail. Validation errors (422) additionally contain a violations array. Authentication errors (401) have a standard body. Authorization errors (403) and not-found errors (404) follow the same format. The frontend code must process these responses type-safely instead of relying on generic any types.

A central API error handler defines TypeScript interfaces for all Symfony error formats and a type guard function that checks whether a fetch response matches the RFC-7807 format. React Query's onError callback receives the parsed error and decides: validation errors are passed on to the form, 401 errors trigger a token refresh, 403 errors show an access-denied message. This central error handler avoids every React component implementing its own Symfony error-parsing logic.

9. Stack Comparison: Symfony+React vs. Alternatives

The choice of fullstack stack depends on team expertise, scaling requirements and maintainability. Symfony with React is not the only reasonable approach, but it is one with clear strengths for mid-sized to large projects with complex business logic.

Criterion Symfony + React Next.js (Fullstack) Laravel + Inertia
Type safety OpenAPI -> TypeScript codegen TypeScript end-to-end PHP + TypeScript separate
Scalability Backend and frontend scale independently Server components + edge Monolith, harder to split
API documentation Automatic via API Platform Manual or tRPC Manual or Scribe
Complex business logic Symfony DI, events, Messenger Node.js limits Laravel is good, but PHP is less strict
Entry barrier Higher (learn two stacks) Lower (one stack) Medium

The table shows: Symfony with React is the right stack when business logic is complex, the API serves multiple clients and type safety through codegen is a priority. Next.js as a fullstack framework gets you started faster but is less suited to projects with extensive server logic that relies on PHP libraries or existing PHP code. Laravel with Inertia is a good choice for teams that do not want an API layer and accept tight coupling between backend and frontend.

Mironsoft

Symfony API development, React integration and fullstack architecture

Building a Symfony + React fullstack app?

We build type-safe fullstack applications with Symfony, API Platform and React, from JWT authentication through TypeScript codegen to production-ready deployment for your stack.

API architecture

Configuring a Symfony backend with API Platform, JWT and CORS for React frontends

TypeScript codegen

Building an OpenAPI-to-TypeScript pipeline and integrating it into CI

React integration

Connecting React Query, form validation and error handling with the Symfony API

10. Summary

The Symfony-React fullstack app with API Platform is a stack that unites the strengths of both worlds: Symfony provides robust business logic, strict typing in PHP 8.4 and a mature ecosystem for authentication, messaging and database access. API Platform declaratively generates REST endpoints, OpenAPI documentation and optional GraphQL from PHP classes. React handles the frontend with modern state management through React Query and type-safe form validation. The TypeScript codegen step closes the loop: changes to the Symfony backend automatically propagate as types into the React frontend.

The most important lever is automating the codegen pipeline. As long as backend and frontend stay synchronized through the OpenAPI schema, no interface errors arise in production. JWT authentication, CORS configuration and centralized error handling are infrastructure tasks that need to be implemented cleanly once, after which development can focus entirely on domain logic in Symfony and the user interface in React.

Symfony + React Fullstack - The Essentials at a Glance

OpenAPI -> TypeScript

API Platform generates the OpenAPI schema automatically. @hey-api/openapi-ts turns it into type-safe TypeScript clients, no manual synchronization needed.

JWT + CORS

lexik/jwt-authentication-bundle for token issuance, nelmio/cors-bundle for CORS headers. Include the Authorization header in allow_headers.

React Query

Manage server state with React Query: queries for GET, mutations for POST/PUT/DELETE. Invalidation after mutations keeps the UI consistent.

Error handling

Parse RFC-7807 errors from Symfony type-safely in the frontend. Set validation violations as form errors via React Hook Form.

11. FAQ: Symfony + React Fullstack with API Platform

1Is a monorepo necessary for Symfony + React?
Not mandatory. A monorepo simplifies atomic commits and CI. Separate repos make sense with several clients or different teams.
2Generating TypeScript types from API Platform?
@hey-api/openapi-ts reads /api/docs.json and generates type-safe clients. Integrate it into the CI pipeline after backend deploy.
3Fixing CORS errors with React and Symfony?
Configure nelmio/cors-bundle. allow_credentials: true requires an explicit domain. Include Authorization in allow_headers.
4Implementing JWT with Symfony and React?
lexik/jwt-authentication-bundle in Symfony. React sends the token as Authorization: Bearer. A central API wrapper attaches the token automatically. 401 triggers token refresh or a login redirect.
5Is React Query useful for Symfony APIs?
Yes. Caching, background refetch and automatic invalidation after mutations keep the UI consistent, replacing manual state management entirely.
6Symfony validation errors in a React form?
Parse HTTP 422 with a violations array. Map propertyPath onto React Hook Form fields via setError(). No manual error mapping per component needed.
7Several frontend clients on one Symfony API?
Yes. The same API Platform API serves web, mobile and third-party clients. Each client generates its own TypeScript client from the same OpenAPI schema.
8Deploying a Symfony + React fullstack app?
Backend: nginx + php-fpm. Frontend: static build served via CDN or nginx. Set the API URL as an environment variable in the frontend build process.
9REST or GraphQL for React?
REST with OpenAPI codegen is enough for most apps. GraphQL is worthwhile with strongly varying data shapes and a measurable over-fetching problem.
10How do I test Symfony API + React together?
PHPUnit for Symfony endpoints. Cypress/Playwright for E2E. React Testing Library + msw for frontend unit tests without a real API connection.