|
4 | 4 | [the Caddy web server](https://caddyserver.com). The build contains the |
5 | 5 | [Mercure](../core/mercure.md) and the [Vulcain](https://vulcain.rocks) Caddy modules. |
6 | 6 |
|
7 | | -Caddy is positioned in front of the web API and of the Progressive Web App. It routes requests to |
8 | | -either service depending on the value of the `Accept` HTTP header or the extension of the requested |
9 | | -file. |
| 7 | +Caddy is positioned in front of the web API and of the Progressive Web App (PWA). It routes requests |
| 8 | +to either service depending on the value of the `Accept` HTTP header or the path of the request. |
10 | 9 |
|
11 | 10 | Using the same domain to serve the API and the PWA |
12 | 11 | [improves performance by preventing unnecessary CORS preflight requests and encourages embracing the REST principles](https://dunglas.fr/2022/01/preventing-cors-preflight-requests-using-content-negotiation/). |
13 | 12 |
|
14 | | -By default, requests having an `Accept` request header containing the `text/html` media type are |
15 | | -routed to the Next.js application, except for some paths known to be resources served by the API |
16 | | -(e.g. the Swagger UI documentation, static files provided by bundles...). Other requests are routed |
17 | | -to the API. |
| 13 | +## Why `route {}` Is Required |
18 | 14 |
|
19 | | -Sometimes, you may want to let the PHP application generate HTML responses. For instance, when you |
20 | | -create your own Symfony controllers serving HTML pages, or when using bundles such as EasyAdmin or |
21 | | -SonataAdmin. |
| 15 | +Caddy processes directives in a |
| 16 | +[predefined global order](https://caddyserver.com/docs/caddyfile/directives#directive-order), not in |
| 17 | +the order they appear in the Caddyfile. In that order, `rewrite` runs **before** `reverse_proxy`. |
| 18 | +Without explicit ordering, a browser request to `/` would match the `@phpRoute` rewrite condition |
| 19 | +and be rewritten to `index.php` before Caddy ever evaluated whether the request should be proxied to |
| 20 | +Next.js. |
22 | 21 |
|
23 | | -To do so, you have to tweak the rules used to route the requests. Open |
24 | | -`api-platform/api/frankenphp/Caddyfile` and modify the expression. You can use |
25 | | -[any CEL (Common Expression Language) expression](https://caddyserver.com/docs/caddyfile/matchers#expression) |
26 | | -supported by Caddy. |
| 22 | +Wrapping the directives in a `route {}` block enforces **strict first-match-wins evaluation in file |
| 23 | +order**. The first directive that matches a request wins, and Caddy stops evaluating the rest. This |
| 24 | +is what makes the `@pwa` proxy check run before the PHP rewrite: |
| 25 | + |
| 26 | +```caddy |
| 27 | +route { |
| 28 | + # 1. Check @pwa first — proxy to Next.js if matched |
| 29 | + reverse_proxy @pwa http://{$PWA_UPSTREAM} |
| 30 | +
|
| 31 | + # 2. Only if @pwa did not match, rewrite to index.php |
| 32 | + @phpRoute { not path /.well-known/mercure*; not file {path} } |
| 33 | + rewrite @phpRoute index.php |
| 34 | +
|
| 35 | + # 3. Run PHP for index.php |
| 36 | + @frontController path index.php |
| 37 | + php @frontController |
| 38 | +
|
| 39 | + # 4. Serve remaining static files |
| 40 | + file_server { hide *.php } |
| 41 | +} |
| 42 | +``` |
27 | 43 |
|
28 | | -For instance, if you want to route all requests to a path starting with `/admin` to the API, modify |
29 | | -the existing expression like this: |
| 44 | +## The `@pwa` Matcher |
30 | 45 |
|
31 | | -```patch |
32 | | -# Matches requests for HTML documents, for static files and for Next.js files, |
33 | | -# except for known API paths and paths with extensions handled by API Platform |
| 46 | +The `@pwa` named matcher is a |
| 47 | +[CEL (Common Expression Language) expression](https://caddyserver.com/docs/caddyfile/matchers#expression) |
| 48 | +that decides which requests are forwarded to the Next.js application: |
| 49 | + |
| 50 | +```caddy |
34 | 51 | @pwa expression `( |
35 | | - {header.Accept}.matches("\\btext/html\\b") |
36 | | -- && !{path}.matches("(?i)(?:^/docs|^/graphql|^/bundles/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))") |
37 | | -+ && !{path}.matches("(?i)(?:^/admin|^/docs|^/graphql|^/bundles/|^/_profiler|^/_wdt|\\.(?:json|html$|csv$|ya?ml$|xml$))") |
38 | | - )` |
| 52 | + header({'Accept': '*text/html*'}) |
| 53 | + && !path( |
| 54 | + '/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*', |
| 55 | + '*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml' |
| 56 | + ) |
| 57 | + ) |
| 58 | + || path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*') |
| 59 | + || query({'_rsc': '*'})` |
| 60 | +``` |
| 61 | + |
| 62 | +The expression has three independent clauses joined by `||`. A request matches `@pwa` if **any** |
| 63 | +clause is true. |
| 64 | + |
| 65 | +### Clause 1: HTML requests that are not API paths |
| 66 | + |
| 67 | +A browser navigating to any URL sends `Accept: text/html, */*`. This clause forwards those requests |
| 68 | +to Next.js unless the path is known to be served by the API or carries an extension that API |
| 69 | +Platform handles through [content negotiation](../core/content-negotiation.md). |
| 70 | + |
| 71 | +Paths excluded from Next.js (handled by PHP instead): |
| 72 | + |
| 73 | +| Pattern | Reason | |
| 74 | +| -------------------------------------------------------- | --------------------------------------------------- | |
| 75 | +| `/docs*` | Swagger UI and OpenAPI documentation | |
| 76 | +| `/graphql*` | GraphQL endpoint | |
| 77 | +| `/bundles*` | Symfony bundle assets published by `assets:install` | |
| 78 | +| `/contexts*` | JSON-LD context documents | |
| 79 | +| `/_profiler*`, `/_wdt*` | Symfony Web Debug Toolbar and Profiler | |
| 80 | +| `*.json*`, `*.html`, `*.csv`, `*.yml`, `*.yaml`, `*.xml` | Content-negotiated formats served by the API | |
| 81 | + |
| 82 | +### Clause 2: Next.js static assets and well-known files |
| 83 | + |
| 84 | +```caddy |
| 85 | +path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*') |
| 86 | +``` |
| 87 | + |
| 88 | +These paths are forwarded to Next.js unconditionally, regardless of the `Accept` header. `/_next/*` |
| 89 | +and `/__next/*` are the internal asset paths used by the Next.js runtime for JavaScript chunks, CSS, |
| 90 | +images, and hot module replacement updates in development. |
| 91 | + |
| 92 | +### Clause 3: React Server Components requests |
| 93 | + |
| 94 | +```caddy |
| 95 | +query({'_rsc': '*'}) |
| 96 | +``` |
| 97 | + |
| 98 | +Next.js uses the `_rsc` query parameter internally for |
| 99 | +[React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) |
| 100 | +data fetching. These requests do not carry `text/html` in their `Accept` header, so they would miss |
| 101 | +clause 1 without this dedicated check. |
| 102 | + |
| 103 | +## The `Link` Header |
| 104 | + |
| 105 | +```caddy |
| 106 | +header ?Link `</docs.jsonld>; rel="http://www.w3.org/ns/hydra/core#apiDocumentation", </.well-known/mercure>; rel="mercure"` |
| 107 | +``` |
| 108 | + |
| 109 | +This directive is placed at the **site block level**, outside the `route {}` block, so it applies to |
| 110 | +every response regardless of whether it came from PHP or Next.js. The `?` prefix means the header is |
| 111 | +only set when not already present in the response — a PHP response that sets its own `Link` header |
| 112 | +is not overwritten. |
| 113 | + |
| 114 | +Setting this at the Caddy level serves two purposes: |
| 115 | + |
| 116 | +1. **API discoverability**: every response advertises the Hydra API documentation URL, allowing |
| 117 | + clients to auto-discover the API. |
| 118 | +2. **Mercure subscription**: every response advertises the Mercure hub URL, so clients can subscribe |
| 119 | + to real-time updates without any application code. |
| 120 | + |
| 121 | +The Next.js application does not need to set these headers itself — they arrive on every response |
| 122 | +automatically. |
| 123 | + |
| 124 | +## The `PWA_UPSTREAM` Environment Variable |
| 125 | + |
| 126 | +```caddy |
| 127 | +reverse_proxy @pwa http://{$PWA_UPSTREAM} |
| 128 | +``` |
| 129 | + |
| 130 | +`PWA_UPSTREAM` is resolved at runtime from the container environment. In `compose.yaml` it is set to |
| 131 | +`pwa:3000`, where `pwa` is the Docker Compose service name and `3000` is the default port of the |
| 132 | +Next.js server. |
| 133 | + |
| 134 | +When the `pwa` service is not running (for example in an API-only project), Caddy returns a |
| 135 | +`502 Bad Gateway` for any request matching `@pwa`. To run without a Next.js frontend, comment out |
| 136 | +that line in the Caddyfile: |
| 137 | + |
| 138 | +```caddy |
| 139 | +route { |
| 140 | + # Comment the following line if you don't want Next.js to catch requests for HTML documents. |
| 141 | + # In this case, they will be handled by the PHP app. |
| 142 | + # reverse_proxy @pwa http://{$PWA_UPSTREAM} |
| 143 | +
|
| 144 | + @phpRoute { not path /.well-known/mercure*; not file {path} } |
| 145 | + rewrite @phpRoute index.php |
| 146 | + @frontController path index.php |
| 147 | + php @frontController |
| 148 | + file_server { hide *.php } |
| 149 | +} |
| 150 | +``` |
| 151 | + |
| 152 | +## Adjusting the Routing Rules |
| 153 | + |
| 154 | +### Routing an admin path to PHP |
| 155 | + |
| 156 | +If you use EasyAdmin, SonataAdmin, or a custom Symfony controller that serves HTML pages, add the |
| 157 | +path prefix to the exclusion list inside clause 1 so those requests bypass Next.js: |
| 158 | + |
| 159 | +```caddy |
| 160 | +@pwa expression `( |
| 161 | + header({'Accept': '*text/html*'}) |
| 162 | + && !path( |
| 163 | + '/admin*', |
| 164 | + '/docs*', '/graphql*', '/bundles*', '/contexts*', '/_profiler*', '/_wdt*', |
| 165 | + '*.json*', '*.html', '*.csv', '*.yml', '*.yaml', '*.xml' |
| 166 | + ) |
| 167 | + ) |
| 168 | + || path('/favicon.ico', '/manifest.json', '/robots.txt', '/sitemap*', '/_next*', '/__next*') |
| 169 | + || query({'_rsc': '*'})` |
| 170 | +``` |
| 171 | + |
| 172 | +You can use [any CEL expression](https://caddyserver.com/docs/caddyfile/matchers#expression) |
| 173 | +supported by Caddy. |
| 174 | + |
| 175 | +### Adding a custom API prefix |
| 176 | + |
| 177 | +If your API is mounted under a prefix such as `/api`, add it to the exclusion list: |
| 178 | + |
| 179 | +```caddy |
| 180 | +&& !path( |
| 181 | + '/api*', |
| 182 | + '/docs*', '/graphql*', ... |
| 183 | +) |
39 | 184 | ``` |
0 commit comments