From 34e3b1b90b326af28216b86a6bd4f765983bc9dc Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Tue, 21 Apr 2026 08:26:11 +0200 Subject: [PATCH] feat(website-new): add robots.txt, sitemap.xml and legacy redirects (#19911) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Application-side preparation so `twenty-website-new` can take over the canonical `twenty.com` hostname from the legacy `twenty-website` (Vercel) deployment without breaking SEO or existing inbound links. ### What's added - **`src/app/robots.ts`** — serves `/robots.txt` and points crawlers at the new `/sitemap.xml`. Honours `NEXT_PUBLIC_WEBSITE_URL` with a `https://twenty.com` fallback. - **`src/app/sitemap.ts`** — serves `/sitemap.xml` listing the canonical public routes of the new website (home, why-twenty, product, pricing, partners, releases, customers + each case study, privacy-policy, terms). - **`next.config.ts` `redirects()`** — adds: - The existing `docs.twenty.com` permanent redirects from the legacy site (`/user-guide`, `/developers`, `/twenty-ui` and their nested variants). - 308-redirects for renamed/restructured pages so existing inbound links and Google results keep working: | From | To | |-------------------------------------|-----------------------------| | `/story` | `/why-twenty` | | `/legal/privacy` | `/privacy-policy` | | `/legal/terms` | `/terms` | | `/legal/dpa` | `/terms` | | `/case-studies/9-dots-story` | `/customers/9dots` | | `/case-studies/act-immi-story` | `/customers/act-education` | | `/case-studies/:slug*` | `/customers` | | `/implementation-services` | `/partners` | | `/onboarding-packages` | `/partners` | ### What's intentionally **not** added Routes that exist on the legacy site but have no equivalent on the new website are left as honest 404s for now (we can decide on landing pages later): - `/jobs`, `/jobs/*` - `/contributors`, `/contributors/*` - `/oss-friends` ## Cutover order 1. Merge this PR. 2. Bump the website-new image tag in `twenty-infra-releases` (`prod-eu`) so the new robots / sitemap / redirects are live on `https://website-new.twenty.com`. 3. Smoke test on `https://website-new.twenty.com`: - `curl -sI https://website-new.twenty.com/robots.txt` - `curl -sI https://website-new.twenty.com/sitemap.xml` - `curl -sI https://website-new.twenty.com/story` — expect 308 to `/why-twenty` - `curl -sI https://website-new.twenty.com/legal/privacy` — expect 308 to `/privacy-policy` 4. Merge the companion `twenty-infra` PR ([twentyhq/twenty-infra#589](https://github.com/twentyhq/twenty-infra/pull/589)) so the ingress accepts `Host: twenty.com` and `Host: www.twenty.com`. 5. Flip the Cloudflare DNS records for `twenty.com` and `www` to the EKS NLB and purge the Cloudflare cache. --- packages/twenty-website-new/next.config.ts | 110 ++++++++++++++++++ packages/twenty-website-new/src/app/robots.ts | 16 +++ .../twenty-website-new/src/app/sitemap.ts | 40 +++++++ 3 files changed, 166 insertions(+) create mode 100644 packages/twenty-website-new/src/app/robots.ts create mode 100644 packages/twenty-website-new/src/app/sitemap.ts diff --git a/packages/twenty-website-new/next.config.ts b/packages/twenty-website-new/next.config.ts index 2e3eeb7e4d2..a12be196248 100644 --- a/packages/twenty-website-new/next.config.ts +++ b/packages/twenty-website-new/next.config.ts @@ -20,6 +20,116 @@ const nextConfig: LinariaConfig = { configFile: path.resolve(__dirname, 'wyw-in-js.config.cjs'), }, reactCompiler: true, + async redirects() { + return [ + // Documentation moved to docs.twenty.com (carried over from the + // legacy twenty-website Next.js app). + { + 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, + }, + + // Renamed/restructured pages on the new website. Mappings derived + // from the old twenty.com sitemap so existing inbound links and + // search results keep working. + { + 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, + }, + ]; + }, }; module.exports = withLinaria(nextConfig); diff --git a/packages/twenty-website-new/src/app/robots.ts b/packages/twenty-website-new/src/app/robots.ts new file mode 100644 index 00000000000..88a32aeee65 --- /dev/null +++ b/packages/twenty-website-new/src/app/robots.ts @@ -0,0 +1,16 @@ +import type { MetadataRoute } from 'next'; + +const SITE_URL = + process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, '') ?? 'https://twenty.com'; + +export default function robots(): MetadataRoute.Robots { + return { + rules: [ + { + userAgent: '*', + allow: '/', + }, + ], + sitemap: `${SITE_URL}/sitemap.xml`, + }; +} diff --git a/packages/twenty-website-new/src/app/sitemap.ts b/packages/twenty-website-new/src/app/sitemap.ts new file mode 100644 index 00000000000..208834ac8f4 --- /dev/null +++ b/packages/twenty-website-new/src/app/sitemap.ts @@ -0,0 +1,40 @@ +import type { MetadataRoute } from 'next'; + +import { CASE_STUDY_CATALOG_ENTRIES } from '@/app/customers/_constants/case-study-catalog'; + +const SITE_URL = + process.env.NEXT_PUBLIC_WEBSITE_URL?.replace(/\/$/, '') ?? 'https://twenty.com'; + +const STATIC_ROUTES: ReadonlyArray<{ + path: string; + changeFrequency: MetadataRoute.Sitemap[number]['changeFrequency']; + priority: number; +}> = [ + { path: '/', changeFrequency: 'weekly', priority: 1.0 }, + { path: '/why-twenty', changeFrequency: 'monthly', priority: 0.8 }, + { path: '/product', changeFrequency: 'monthly', priority: 0.8 }, + { path: '/pricing', changeFrequency: 'monthly', priority: 0.9 }, + { path: '/partners', changeFrequency: 'monthly', priority: 0.7 }, + { path: '/releases', changeFrequency: 'weekly', priority: 0.7 }, + { path: '/customers', changeFrequency: 'monthly', priority: 0.7 }, + { path: '/privacy-policy', changeFrequency: 'yearly', priority: 0.3 }, + { path: '/terms', changeFrequency: 'yearly', priority: 0.3 }, +]; + +export default function sitemap(): MetadataRoute.Sitemap { + const staticEntries = STATIC_ROUTES.map( + ({ path, changeFrequency, priority }) => ({ + url: `${SITE_URL}${path}`, + changeFrequency, + priority, + }), + ); + + const caseStudyEntries = CASE_STUDY_CATALOG_ENTRIES.map((entry) => ({ + url: `${SITE_URL}${entry.href}`, + changeFrequency: 'yearly' as const, + priority: 0.5, + })); + + return [...staticEntries, ...caseStudyEntries]; +}