Files
twenty/packages/twenty-website/next.config.ts
T
Félix Malfait f630ce34fe feat(website): mirror prod hostname pattern on dev (apex + www) (#20753)
## Summary
Drops the `website.` subdomain on dev entirely and serves the marketing
site from the bare zone + `www`, mirroring how prod is served at
`twenty.com` + `www.twenty.com`.

Also fixes a latent root-path substitution bug in the existing www→apex
redirect that was masked on prod by a CF-level redirect.

## What changes
- `wrangler.jsonc` env.dev routes: `twenty-main.com` (apex) +
`www.twenty-main.com` (was `website.twenty-main.com`)
- `next.config.ts`: extends host-based www→apex redirect to also cover
`www.twenty-main.com`, and adds explicit `source: '/'` rules for both
prod + dev before the catch-all `source: '/:path*'` (the `:path*`
parameter doesn't substitute properly when it matches the empty root
path against an absolute destination URL — Next.js leaves the literal
`:path*` in the `Location` header)

## Live verification (after redeploy)
| URL | Status | Notes |
|---|---|---|
| `https://twenty-main.com/` | 200 | `x-opennext: 1`, `x-nextjs-cache:
HIT` |
| `https://twenty-main.com/pricing` | 200 | Worker SSR |
| `https://www.twenty-main.com/` | 308 → `https://twenty-main.com/` |
Root-redirect fix applied |
| `https://www.twenty-main.com/pricing` | 308 →
`https://twenty-main.com/pricing` | Path preserved |
| `https://twenty.com/` | 200 | Unchanged |
| `https://www.twenty.com/` | 301 → `https://twenty.com/` | Still routed
via CF-level redirect, now also covered by the new explicit Next rule as
a defense-in-depth |
| `https://website.twenty-main.com/` | 503 | DNS record removed by
wrangler when route was deleted; hostname effectively retired |

## Companion infra PR
https://github.com/twentyhq/twenty-infra/pull/__ —
`cloudflare/website/dev.env` + `docs/4-environments.md` updated to the
new URL; also bundles the CI fix that should have landed in #683 (was
pushed too late).
2026-05-20 09:54:44 +02:00

256 lines
7.5 KiB
TypeScript

import path from 'path';
import { initOpenNextCloudflareForDev } from '@opennextjs/cloudflare';
import withLinaria, { type LinariaConfig } from 'next-with-linaria';
import { APP_LOCALES } from 'twenty-shared/translations';
// Locale URL segments that are actually served (others are normalised away).
// Mirrors LOCALE_BY_URL_SEGMENT keys in src/lib/i18n/utils/website-locale-segments.ts.
const DEPLOYED_LOCALE_URL_SEGMENTS = ['en', 'fr'] as const;
// Raw locale codes (e.g. fr-FR, de-DE) that should redirect to the un-prefixed
// path. Excludes pseudo-* locales and the deployed URL segments themselves.
const RAW_LOCALE_PREFIXES_TO_STRIP = (
Object.values(APP_LOCALES) as string[]
).filter(
(locale) =>
!locale.startsWith('pseudo-') &&
!(DEPLOYED_LOCALE_URL_SEGMENTS as readonly string[]).includes(locale),
);
const SECURITY_HEADERS = [
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=(), payment=()',
},
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Content-Security-Policy', value: "frame-ancestors 'none'" },
] as const;
const nextConfig: LinariaConfig = {
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{
hostname: 'avatars.githubusercontent.com',
pathname: '/**',
protocol: 'https',
},
{
hostname: 'twenty-icons.com',
pathname: '/**',
protocol: 'https',
},
],
},
linaria: {
configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'),
},
reactCompiler: true,
experimental: {
swcPlugins: [
[
'@lingui/swc-plugin',
{
runtimeModules: {
i18n: ['@lingui/core', 'i18n'],
trans: ['@lingui/react', 'Trans'],
},
},
],
],
},
async headers() {
return [
{
source: '/:path*',
headers: SECURITY_HEADERS.map((h) => ({ ...h })),
},
{
source: '/(images|illustrations|lottie)/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=31536000, immutable',
},
],
},
];
},
async rewrites() {
return {
beforeFiles: [
// Root rewrites to the source locale.
{ source: '/', destination: '/en' },
// Any path that isn't already locale-prefixed (en/, fr/), an internal
// Next.js path, a static asset folder, or a file with an extension
// rewrites to the source locale prefix. Mirrors proxy.ts Rule 4.
{
source:
'/:rest((?!en$|en/|fr$|fr/|api|_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|illustrations|lottie|fonts|.+\\..+).+)',
destination: '/en/:rest',
},
],
};
},
async redirects() {
return [
// Canonicalise www → apex. Host-based; fires before any locale logic.
// The root-path rule must come before the :path* one — Next.js's
// path-to-regexp leaves a literal `:path*` in the Location header
// when the parameter matches empty against an absolute destination.
{
source: '/',
has: [{ type: 'host', value: 'www.twenty.com' }],
destination: 'https://twenty.com/',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'www.twenty.com' }],
destination: 'https://twenty.com/:path*',
permanent: true,
},
{
source: '/',
has: [{ type: 'host', value: 'www.twenty-main.com' }],
destination: 'https://twenty-main.com/',
permanent: true,
},
{
source: '/:path*',
has: [{ type: 'host', value: 'www.twenty-main.com' }],
destination: 'https://twenty-main.com/:path*',
permanent: true,
},
// Strip the source-locale prefix: /en/foo → /foo (301). Mirrors proxy.ts Rule 1.
{ source: '/en', destination: '/', statusCode: 301 },
{ source: '/en/:path*', destination: '/:path*', statusCode: 301 },
// Normalise raw locale codes that aren't deployed URL segments
// (e.g. /fr-FR/foo → /foo, /de-DE/foo → /foo). Mirrors proxy.ts Rule 3.
...RAW_LOCALE_PREFIXES_TO_STRIP.flatMap((locale) => [
{ source: `/${locale}`, destination: '/', permanent: true },
{
source: `/${locale}/:path*`,
destination: '/:path*',
permanent: true,
},
]),
{
source: '/user-guide',
destination: 'https://docs.twenty.com/user-guide/introduction',
permanent: true,
},
{
source: '/user-guide/section/:folder/:slug*',
destination: 'https://docs.twenty.com/user-guide/:folder/:slug*',
permanent: true,
},
{
source: '/user-guide/:folder/:slug*',
destination: 'https://docs.twenty.com/user-guide/:folder/:slug*',
permanent: true,
},
{
source: '/developers',
destination: 'https://docs.twenty.com/developers/introduction',
permanent: true,
},
{
source: '/developers/section/:folder/:slug*',
destination: 'https://docs.twenty.com/developers/:folder/:slug*',
permanent: true,
},
{
source: '/developers/:folder/:slug*',
destination: 'https://docs.twenty.com/developers/:folder/:slug*',
permanent: true,
},
{
source: '/developers/:slug',
destination: 'https://docs.twenty.com/developers/:slug',
permanent: true,
},
{
source: '/twenty-ui',
destination: 'https://docs.twenty.com/twenty-ui/introduction',
permanent: true,
},
{
source: '/twenty-ui/section/:folder/:slug*',
destination: 'https://docs.twenty.com/twenty-ui/:folder/:slug*',
permanent: true,
},
{
source: '/twenty-ui/:folder/:slug*',
destination: 'https://docs.twenty.com/twenty-ui/:folder/:slug*',
permanent: true,
},
{
source: '/twenty-ui/:slug',
destination: 'https://docs.twenty.com/twenty-ui/:slug',
permanent: true,
},
{
source: '/resources/why-twenty',
destination: '/why-twenty',
permanent: true,
},
{
source: '/story',
destination: '/why-twenty',
permanent: true,
},
{
source: '/legal/privacy',
destination: '/privacy-policy',
permanent: true,
},
{
source: '/legal/terms',
destination: '/terms',
permanent: true,
},
{
source: '/legal/dpa',
destination: '/terms',
permanent: true,
},
{
source: '/case-studies/9-dots-story',
destination: '/customers/9dots',
permanent: true,
},
{
source: '/case-studies/act-immi-story',
destination: '/customers/act-education',
permanent: true,
},
{
source: '/case-studies/:slug*',
destination: '/customers',
permanent: true,
},
{
source: '/implementation-services',
destination: '/partners',
permanent: true,
},
{
source: '/onboarding-packages',
destination: '/partners',
permanent: true,
},
];
},
};
initOpenNextCloudflareForDev();
module.exports = withLinaria(nextConfig);