Tech Verse Logo
Enable dark mode
Structuring a Laravel and Next.js Monorepo

Structuring a Laravel and Next.js Monorepo

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

4 min read

Monorepo Layout: PNPM Workspaces Meets Composer

Putting PHP and JavaScript in one repository makes traditional frontend developers uneasy. JavaScript tools expect a single root node_modules or PNPM workspaces, while Laravel assumes it owns the top-level directory structure. If you dump Laravel into the root and stick Next.js inside a frontend/ subfolder, your root scripts get messy fast.

The cleanest layout separates runtimes cleanly under an apps/ directory while sharing TypeScript types inside a packages/ folder. Here's the layout we've been running in production with Laravel 12 (PHP 8.3) and Next.js 16 (React 19):

my-project/
├── apps/
│   ├── api/          # Laravel application
│   └── web/          # Next.js application
├── packages/
│   └── types/        # Shared generated TypeScript definitions
├── pnpm-workspace.yaml
└── package.json

Your root package.json doesn't run the application logic; it manages workspace scripts using PNPM. Here is what pnpm-workspace.yaml looks like in the repository root:

packages:
  - 'apps/*'
  - 'packages/*'

This separation lets Next.js 16 consume TS interfaces from @my-project/types directly without relative path hacks like ../../apps/api/....

Syncing PHP DTOs with TypeScript Types

Writing TypeScript interfaces manually to match Laravel API responses is a recipe for silent runtime bugs. When someone changes a column name or morphs a nullable string into an array on the backend, the frontend breaks silently until a user hits the page.

To fix this, we use Spatie's laravel-typescript-transformer package inside apps/api. It reads PHP 8.3 Data Transfer Objects (DTOs) and outputs valid .d.ts files straight into packages/types/src.

Here is a PHP DTO in apps/api/app/Data/UserData.php using PHP 8.3 readonly classes:

<?php

namespace App\Data;

use Spatie\LaravelData\Data;

readonly class UserData extends Data
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public ?string $avatarUrl,
        public bool $isAdmin,
    ) {}
}

Running php artisan typescript:transform parses this class and updates packages/types/src/generated.ts automatically:

export type UserData = {
    id: number;
    name: string;
    email: string;
    avatarUrl: string | null;
    isAdmin: boolean;
};

In apps/web/package.json, add the package reference: "@my-project/types": "workspace:*". Now, in your Next.js 16 Server Components or React 19 Client Components, you import the exact type directly:

import type { UserData } from '@my-project/types';

export default async function ProfilePage() {
  const res = await fetch(`${process.env.INTERNAL_API_URL}/api/user`, {
    headers: { Accept: 'application/json' },
  });
  const user: UserData = await res.json();

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

Don't commit stale types. Add the transformation command to your Composer post-autoload-dump hook or run it before running frontend build steps.

Environment Variables Across Two Runtimes

Managing environment files across Node and PHP creates friction if you try to share a single .env file. Laravel expects variables like DB_PASSWORD and APP_KEY, while Next.js 16 expects NEXT_PUBLIC_ prefixes for variables exposed to the browser.

Sharing one root .env file via symlinks sounds convenient, but it breaks security boundaries. You end up accidentally leaking backend secrets into the Next.js bundle when someone forgets a prefix rule. Keep two separate files: apps/api/.env and apps/web/.env.local.

To avoid host mismatch headaches during local development, explicitly set up predictable internal port bindings in your dev scripts:

  • Laravel API: http://localhost:8000

  • Next.js Web: http://localhost:3000

In Next.js 16, distinguish between server-side fetch calls and client-side fetch calls. Server Components fetching from Laravel should call INTERNAL_API_URL (like http://127.0.0.1:8000), avoiding public DNS round-trips. Client components must call NEXT_PUBLIC_API_URL (like http://localhost:8000 or your public domain).

Validate Next.js environment variables at build time using Zod inside apps/web/src/env.ts. If NEXT_PUBLIC_API_URL is missing, fail the build immediately rather than throwing rendering errors in production.

CI Pipeline Strategy with GitHub Actions

Running CI on a polyglot monorepo can slow down quickly if you build everything sequentially. A single pull request shouldn't wait 10 minutes for PHPUnit tests before starting Next.js linting.

We split CI into parallel GitHub Actions jobs using paths filtering. PHP steps run when apps/api/** changes, Node steps run when apps/web/** or packages/** change. If both change, both run concurrently.

Here is a production-ready GitHub Actions workflow file (.github/workflows/ci.yml):

name: CI Pipeline

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  backend-tests:
    runs-on: ubuntu-24.04
    defaults:
      run:
        working-directory: apps/api
    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP 8.3
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          extensions: mbstring, pdo_sqlite, bcmath
          coverage: none

      - name: Cache Composer packages
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ runner.os }}-${{ hashFiles('apps/api/composer.lock') }}

      - name: Install Dependencies
        run: composer install --prefer-dist --no-progress --no-interaction

      - name: Run Tests
        run: ./vendor/bin/pest

  frontend-checks:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Install pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 9

      - name: Setup Node.js 22
        uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: 'pnpm'

      - name: Install Dependencies
        run: pnpm install --frozen-lockfile

      - name: Generate TypeScript Types from PHP DTOs
        run: |
          cd apps/api
          php -r "copy('.env.example', '.env');"
          composer install --prefer-dist
          php artisan key:generate
          php artisan typescript:transform

      - name: Run ESLint and Build
        run: pnpm --filter web build

Production Gotchas: CORS and Cookies

The trickiest bug in this setup involves authentication state between React 19 client components and Laravel Sanctum or Session cookies.

If Next.js runs on app.example.com and Laravel runs on api.example.com, standard SameSite cookies won't work out of the box without specific browser configurations. You must configure SESSION_DOMAIN=.example.com in Laravel's .env and set SANCTUM_STATEFUL_DOMAINS=app.example.com.

Avoid cross-domain cookie headaches entirely by routing requests through a single domain using Nginx or Caddy. Route example.com/api/* to Laravel and example.com/* to Next.js. This eliminates CORS preflight overhead (saving about 40ms down from 120ms per initial request) and makes HttpOnly session cookies straightforward.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Structuring a Laravel and Next.js Monorepo

    Structuring a Laravel and Next.js Monorepo

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Next.js ISR: Revalidate, Cache Tags, and Laravel Webhooks

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Nextjs Core Web Vitals: Diagnosing LCP and CLS

    Laravel API Versioning: URI vs Header Strategies

    Laravel API Versioning: URI vs Header Strategies

    Server Components vs Client Components: Boundary Rules

    Server Components vs Client Components: Boundary Rules

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Service Container: When DI Helps and When It Hurts

    Laravel Form Request Architecture: Rules, Hooks, & Arrays

    Laravel Form Request Architecture: Rules, Hooks, & Arrays

    Prevent Data Leaks in Laravel API Resources

    Prevent Data Leaks in Laravel API Resources

    Advanced Laravel 12 API Rate Limiting and Tiered Limits

    Advanced Laravel 12 API Rate Limiting and Tiered Limits

    Laravel S3 Signed URLs and Private Filesystem Storage

    Laravel S3 Signed URLs and Private Filesystem Storage