From 0341265275a3288f218a3db3221d2821de5f4c58 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:03:42 +0300 Subject: [PATCH 1/2] fix: keep private and gated pages out of the search index Private squads still rendered as index,follow. getServerSideProps runs unauthenticated, so the API rejects every private squad with FORBIDDEN before the `noindex: !squad.public` line is reached, and the catch branch returned props with no seo at all, falling back to the indexable default. Same failure mode on the post page, where FORBIDDEN is what every post in a private squad returns from the unauthenticated ISR fetch. - squads/[handle]: noindex the error fallback, fail closed when `public` is not positively true (robots tags and JSON-LD alike) - posts/[id]: noindex the error fallback, fail closed for private posts and non-public sources, and drop the `robots` additionalMetaTags entry that escapes next-seo's dedupe (already covered by robotsProps) - noindex auth-gated squad surfaces: invite tokens, analytics, moderation - noindex the recruiter app, post analytics, org invite links, the auth flows (verification, reset-password, callback) and backoffice - regression tests for each path Claude-Session: https://claude.ai/code/session_01JP6oFXEXeuSsjr6VZQ6KMs --- .../webapp/__tests__/GatedPagesSeo.spec.ts | 39 +++++++++++++++- .../webapp/__tests__/PostStaticProps.spec.ts | 35 ++++++++++++++- .../__tests__/SquadPageStaticProps.spec.ts | 45 ++++++++++++++++++- packages/webapp/next-seo.ts | 4 ++ .../pages/backoffice/keywords/[value].tsx | 6 +++ .../pages/backoffice/pendingKeywords.tsx | 6 +++ packages/webapp/pages/callback.tsx | 6 +++ packages/webapp/pages/join/organization.tsx | 4 ++ .../pages/posts/[id]/analytics/index.tsx | 6 ++- packages/webapp/pages/posts/[id]/index.tsx | 21 ++++++--- packages/webapp/pages/reset-password.tsx | 6 +++ .../webapp/pages/squads/[handle]/[token].tsx | 12 ++++- .../pages/squads/[handle]/analytics.tsx | 8 ++++ .../webapp/pages/squads/[handle]/index.tsx | 19 +++++--- packages/webapp/pages/squads/moderate.tsx | 8 ++++ packages/webapp/pages/verification.tsx | 6 +++ 16 files changed, 211 insertions(+), 20 deletions(-) diff --git a/packages/webapp/__tests__/GatedPagesSeo.spec.ts b/packages/webapp/__tests__/GatedPagesSeo.spec.ts index 03a50466663..0378840e192 100644 --- a/packages/webapp/__tests__/GatedPagesSeo.spec.ts +++ b/packages/webapp/__tests__/GatedPagesSeo.spec.ts @@ -1,17 +1,52 @@ import type { NextSeoProps } from 'next-seo/lib/types'; +import { getSquadInvitation } from '@dailydotdev/shared/src/graphql/squads'; import FollowingFeed from '../pages/following'; +import SquadAnalytics from '../pages/squads/[handle]/analytics'; +import ModerateSquad from '../pages/squads/moderate'; +import { getStaticProps as getSquadInviteStaticProps } from '../pages/squads/[handle]/[token]'; + +jest.mock('@dailydotdev/shared/src/graphql/squads', () => { + const actual = jest.requireActual('@dailydotdev/shared/src/graphql/squads'); + + return { + ...actual, + getSquadInvitation: jest.fn(), + }; +}); type WithLayoutProps = { layoutProps?: { seo?: NextSeoProps }; }; +const layoutSeo = (page: unknown): NextSeoProps | undefined => + (page as WithLayoutProps).layoutProps?.seo; + // Regression lock: auth-gated pages must stay noindex,nofollow so private, // crawler-inaccessible surfaces are never advertised as indexable. describe('gated page seo', () => { - it('following feed is noindex and nofollow', () => { - const { seo } = (FollowingFeed as WithLayoutProps).layoutProps ?? {}; + it.each([ + ['following feed', FollowingFeed], + ['squad analytics', SquadAnalytics], + ['squad moderation', ModerateSquad], + ])('%s is noindex and nofollow', (_, page) => { + const seo = layoutSeo(page); expect(seo?.noindex).toBe(true); expect(seo?.nofollow).toBe(true); }); + + it.each([ + ['a valid invite', { user: { name: 'Ido' }, source: { name: 'My Squad' } }], + ['an invalid invite', {}], + ])('squad invite token page is noindex for %s', async (_, invitation) => { + (getSquadInvitation as jest.Mock).mockResolvedValue(invitation); + + const result = await getSquadInviteStaticProps({ + params: { handle: 'my-squad', token: 'token' }, + } as never); + + expect(result).toMatchObject({ + props: { seo: { noindex: true, nofollow: true } }, + }); + }); }); diff --git a/packages/webapp/__tests__/PostStaticProps.spec.ts b/packages/webapp/__tests__/PostStaticProps.spec.ts index d160954c069..4b2616975bc 100644 --- a/packages/webapp/__tests__/PostStaticProps.spec.ts +++ b/packages/webapp/__tests__/PostStaticProps.spec.ts @@ -1,4 +1,4 @@ -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import type { Post } from '@dailydotdev/shared/src/graphql/posts'; import { PostType } from '@dailydotdev/shared/src/graphql/posts'; import { getStaticProps, shouldNoindexPost } from '../pages/posts/[id]/index'; @@ -109,6 +109,39 @@ describe('post static props seo', () => { }); }); + it.each([ + ['the post is private', { private: true }], + ['the source is not public', { source: { public: false } }], + ])('should noindex when %s', (_, overrides) => { + expect( + shouldNoindexPost({ + ...createPost({ type: PostType.Article, upvotes: 10 }), + ...overrides, + } as Post), + ).toBe(true); + }); + + // The ISR fetch is unauthenticated, so posts in private squads always fail + // with FORBIDDEN. This fallback used to ship no seo, leaving them indexable. + it('should noindex the error fallback', async () => { + mockRequest.mockRejectedValue({ + response: { + errors: [{ extensions: { code: ApiError.Forbidden, postId: 'p-1' } }], + }, + }); + + const result = await getStaticProps({ + params: { id: 'post-id' }, + } as never); + + expect(result).toMatchObject({ + props: { + id: 'p-1', + seo: { noindex: true, nofollow: true }, + }, + }); + }); + it('should preserve indexed status when the author reputation is missing', () => { expect( shouldNoindexPost({ diff --git a/packages/webapp/__tests__/SquadPageStaticProps.spec.ts b/packages/webapp/__tests__/SquadPageStaticProps.spec.ts index fe04014f1b4..1ca068ce62a 100644 --- a/packages/webapp/__tests__/SquadPageStaticProps.spec.ts +++ b/packages/webapp/__tests__/SquadPageStaticProps.spec.ts @@ -1,4 +1,4 @@ -import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; +import { ApiError, gqlClient } from '@dailydotdev/shared/src/graphql/common'; import { getSquad, getSquadStaticFields, @@ -89,4 +89,47 @@ describe('squad page getServerSideProps seo', () => { }, }); }); + + it('marks a squad with an unknown visibility as noindex and nofollow', async () => { + mockStaticFields.mockResolvedValue({ + ...createSquad(true), + public: undefined, + }); + + const result = await runGssp(); + + expect(result).toMatchObject({ + props: { + seo: { + noindex: true, + nofollow: true, + }, + }, + }); + expect(result).not.toMatchObject({ props: { jsonLd: expect.anything() } }); + }); + + // SSR is always unauthenticated, so the API rejects every private squad with + // FORBIDDEN before we ever see `public`. This fallback used to ship no seo at + // all, which left private squads advertised as index,follow. + it.each([ApiError.Forbidden, ApiError.NotFound, ApiError.RateLimited])( + 'marks the %s fallback as noindex and nofollow', + async (code) => { + mockRequest.mockRejectedValue({ + response: { errors: [{ extensions: { code } }] }, + }); + + const result = await runGssp(); + + expect(result).toMatchObject({ + props: { + handle: 'my-squad', + seo: { + noindex: true, + nofollow: true, + }, + }, + }); + }, + ); }); diff --git a/packages/webapp/next-seo.ts b/packages/webapp/next-seo.ts index 75f5ae14460..55d76de21b2 100644 --- a/packages/webapp/next-seo.ts +++ b/packages/webapp/next-seo.ts @@ -69,8 +69,12 @@ export const TAGLINE = "Where developers discover what's next"; export const defaultSeoTitle = `daily.dev | ${TAGLINE}`; +// Shared by every logged-in Recruiter surface (dashboard, opportunities, +// candidate review, org settings, payment). None of them render for a crawler, +// and they hold customer data, so the whole product stays out of the index. export const recruiterSeo: NextSeoProps = { title: 'daily.dev Recruiter | Dashboard', description: 'Your dashboard for the developer-first hiring platform. Manage roles, review matches, and track warm introductions.', + ...noindexSeoProps, }; diff --git a/packages/webapp/pages/backoffice/keywords/[value].tsx b/packages/webapp/pages/backoffice/keywords/[value].tsx index a0dcb997e8b..8dd6e83475b 100644 --- a/packages/webapp/pages/backoffice/keywords/[value].tsx +++ b/packages/webapp/pages/backoffice/keywords/[value].tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React, { useContext } from 'react'; import type { @@ -15,6 +16,9 @@ import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import Custom404 from '../../404'; import { getLayout as getMainLayout } from '../../../components/layouts/MainLayout'; import KeywordManagement from '../../../components/KeywordManagement'; +import { noindexSeoProps } from '../../../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; export type KeywordPageProps = { keyword: string }; @@ -49,6 +53,8 @@ const KeywordPage = ({ KeywordPage.getLayout = getMainLayout; +KeywordPage.layoutProps = { seo }; + export default KeywordPage; export async function getStaticPaths(): Promise { diff --git a/packages/webapp/pages/backoffice/pendingKeywords.tsx b/packages/webapp/pages/backoffice/pendingKeywords.tsx index f8fb8231ac7..507b4b55a9b 100644 --- a/packages/webapp/pages/backoffice/pendingKeywords.tsx +++ b/packages/webapp/pages/backoffice/pendingKeywords.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React, { useContext } from 'react'; import AuthContext from '@dailydotdev/shared/src/contexts/AuthContext'; @@ -9,6 +10,9 @@ import useRequirePermissions from '@dailydotdev/shared/src/hooks/useRequirePermi import { gqlClient } from '@dailydotdev/shared/src/graphql/common'; import KeywordManagement from '../../components/KeywordManagement'; import { getLayout as getMainLayout } from '../../components/layouts/MainLayout'; +import { noindexSeoProps } from '../../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const PendingKeywords = (): ReactElement => { useRequirePermissions(Roles.Moderator); @@ -58,4 +62,6 @@ const PendingKeywords = (): ReactElement => { PendingKeywords.getLayout = getMainLayout; +PendingKeywords.layoutProps = { seo }; + export default PendingKeywords; diff --git a/packages/webapp/pages/callback.tsx b/packages/webapp/pages/callback.tsx index 4010207ea22..02194b7e4d8 100644 --- a/packages/webapp/pages/callback.tsx +++ b/packages/webapp/pages/callback.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import { broadcastMessage, postWindowMessage, @@ -11,6 +12,9 @@ import { AUTH_REDIRECT_KEY, shouldRedirectAuth, } from '@dailydotdev/shared/src/features/onboarding/shared'; +import { noindexSeoProps } from '../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const checkShouldSendBroadcast = () => { const ua = navigator.userAgent; @@ -94,4 +98,6 @@ function CallbackPage(): ReactElement | null { return null; } +CallbackPage.layoutProps = { seo }; + export default CallbackPage; diff --git a/packages/webapp/pages/join/organization.tsx b/packages/webapp/pages/join/organization.tsx index 8db81d3b6c2..70a5ce94a2f 100644 --- a/packages/webapp/pages/join/organization.tsx +++ b/packages/webapp/pages/join/organization.tsx @@ -32,6 +32,7 @@ import { InfoIcon } from '@dailydotdev/shared/src/components/icons'; import { IconSize } from '@dailydotdev/shared/src/components/Icon'; import { getFirstQueryParam } from '@dailydotdev/shared/src/lib/func'; import Custom404Seo from '../404'; +import { noindexSeoProps } from '../../next-seo'; const Page = ({ token, @@ -241,4 +242,7 @@ export const getServerSideProps: GetServerSideProps<{ } }; +// Personal, token-bearing invite link: never index it. +Page.layoutProps = { seo: { ...noindexSeoProps } }; + export default Page; diff --git a/packages/webapp/pages/posts/[id]/analytics/index.tsx b/packages/webapp/pages/posts/[id]/analytics/index.tsx index ce5650abcc3..7b2d6d28137 100644 --- a/packages/webapp/pages/posts/[id]/analytics/index.tsx +++ b/packages/webapp/pages/posts/[id]/analytics/index.tsx @@ -98,6 +98,7 @@ import { seoTitle } from '../index'; import { getPageSeoTitles } from '../../../../components/layouts/utils'; import { getLayout } from '../../../../components/layouts/MainLayout'; import { getPostCanonicalUrl } from '../../../../lib/seo'; +import { noindexSeoProps } from '../../../../next-seo'; import type { SharePostPageProps } from '../share'; import type { AnalyticsNumberList } from '../../../../../shared/src/components/analytics/common'; @@ -131,10 +132,13 @@ export const getServerSideProps: GetServerSideProps< const pageSeoTitles = getPageSeoTitles( [seoTitle(post), 'Analytics'].filter(Boolean).join(' | '), ); + // Only the post author can open this page, so it must never be indexed + // even though the post it belongs to is public. const seo: NextSeoProps = { canonical: post?.slug ? getPostCanonicalUrl(post.slug) : undefined, title: pageSeoTitles.title, description: getSeoDescription(post), + ...noindexSeoProps, openGraph: { ...pageSeoTitles.openGraph, images: [ @@ -168,7 +172,7 @@ export const getServerSideProps: GetServerSideProps< const { postId } = clientError.response.errors[0].extensions; return { - props: { id: postId || id }, + props: { id: postId || id, seo: { ...noindexSeoProps } }, }; } throw err; diff --git a/packages/webapp/pages/posts/[id]/index.tsx b/packages/webapp/pages/posts/[id]/index.tsx index 8fb970f07ea..a470eff1ce1 100644 --- a/packages/webapp/pages/posts/[id]/index.tsx +++ b/packages/webapp/pages/posts/[id]/index.tsx @@ -60,6 +60,7 @@ import { PostSEOSchema, } from '../../../components/PostSEOSchema'; import type { DynamicSeoProps } from '../../../components/common'; +import { noindexSeoProps } from '../../../next-seo'; import useSharedByToast from '../../../hooks/useSharedByToast'; import { getPostCanonicalUrl } from '../../../lib/seo'; @@ -166,6 +167,12 @@ export const seoTitle = (post: Post): string | undefined => { }; export const shouldNoindexPost = (post: Post): boolean => { + // Posts in private squads normally fail the unauthenticated ISR fetch, but a + // cached page can outlive a squad turning private, so fail closed here too. + if (post?.private || post?.source?.public === false) { + return true; + } + const hasLowReputationAuthor = typeof post?.author?.reputation === 'number' && post.author.reputation <= 10; @@ -377,12 +384,6 @@ export async function getStaticProps({ }, locale: post?.language || 'en', }, - additionalMetaTags: [ - { - name: 'robots', - content: 'max-image-preview:large', - }, - ], }; return { @@ -409,8 +410,14 @@ export async function getStaticProps({ const { postId } = responseErrors?.[0]?.extensions ?? {}; + // FORBIDDEN lands here for every post in a private squad, since the ISR + // fetch is unauthenticated. Without seo these fell back to index,follow. return { - props: { id: postId || id, error: errorCode }, + props: { + id: postId || id, + error: errorCode, + seo: { ...noindexSeoProps }, + }, revalidate: 60, }; } diff --git a/packages/webapp/pages/reset-password.tsx b/packages/webapp/pages/reset-password.tsx index 7aad3a1c6bb..b8b296b9694 100644 --- a/packages/webapp/pages/reset-password.tsx +++ b/packages/webapp/pages/reset-password.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React from 'react'; import { useRouter } from 'next/router'; @@ -8,6 +9,9 @@ import { Button, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { noindexSeoProps } from '../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const ResetPassword = (): ReactElement | null => { const router = useRouter(); @@ -55,4 +59,6 @@ const ResetPassword = (): ReactElement | null => { ); }; +ResetPassword.layoutProps = { seo }; + export default ResetPassword; diff --git a/packages/webapp/pages/squads/[handle]/[token].tsx b/packages/webapp/pages/squads/[handle]/[token].tsx index bb1b5880476..deb545e5bac 100644 --- a/packages/webapp/pages/squads/[handle]/[token].tsx +++ b/packages/webapp/pages/squads/[handle]/[token].tsx @@ -42,7 +42,7 @@ import { AuthTriggers } from '@dailydotdev/shared/src/lib/auth'; import { ProfileImageSize } from '@dailydotdev/shared/src/components/ProfilePicture'; import Link from '@dailydotdev/shared/src/components/utilities/Link'; import { getLayout } from '../../../components/layouts/MainLayout'; -import { getSquadOpenGraph } from '../../../next-seo'; +import { getSquadOpenGraph, noindexSeoProps } from '../../../next-seo'; import type { DynamicSeoProps } from '../../../components/common'; const getOthers = (others: Edge[], total: number) => { @@ -306,14 +306,22 @@ export async function getStaticProps({ if (!source || !user) { return { - props: { handle, token, initialData: null }, + props: { + handle, + token, + initialData: null, + seo: { ...noindexSeoProps }, + }, }; } + // Invite tokens are personal, single-squad links (private squads included) and + // must never end up in search results. const seo: NextSeoProps = { title: `${user.name} invited you to ${source.name}`, description: source.description, openGraph: getSquadOpenGraph({ squad: source }), + ...noindexSeoProps, }; return { diff --git a/packages/webapp/pages/squads/[handle]/analytics.tsx b/packages/webapp/pages/squads/[handle]/analytics.tsx index 3badeab9d1c..a8b8a7d1e03 100644 --- a/packages/webapp/pages/squads/[handle]/analytics.tsx +++ b/packages/webapp/pages/squads/[handle]/analytics.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import React, { useEffect, useMemo } from 'react'; +import type { NextSeoProps } from 'next-seo'; import { useRouter } from 'next/router'; import { useQuery } from '@tanstack/react-query'; import { addDays, subDays } from 'date-fns'; @@ -49,6 +50,12 @@ import { } from '@dailydotdev/shared/src/lib/timezones'; import { getFirstQueryParam } from '@dailydotdev/shared/src/lib/func'; import { getLayout as getMainLayout } from '../../../components/layouts/MainLayout'; +import { noindexSeoProps } from '../../../next-seo'; + +const seo: NextSeoProps = { + title: 'Squad analytics', + ...noindexSeoProps, +}; const SectionContainer = classed('div', 'flex flex-col gap-4'); const dividerClassName = 'bg-border-subtlest-tertiary'; @@ -257,5 +264,6 @@ const SquadAnalyticsPage = (): ReactElement => { }; SquadAnalyticsPage.getLayout = getMainLayout; +SquadAnalyticsPage.layoutProps = { seo }; export default SquadAnalyticsPage; diff --git a/packages/webapp/pages/squads/[handle]/index.tsx b/packages/webapp/pages/squads/[handle]/index.tsx index f31d3336294..170c1b1cabe 100644 --- a/packages/webapp/pages/squads/[handle]/index.tsx +++ b/packages/webapp/pages/squads/[handle]/index.tsx @@ -92,7 +92,7 @@ import { mainFeedLayoutProps } from '../../../components/layouts/MainFeedPage'; import { getLayout } from '../../../components/layouts/FeedLayout'; import type { ProtectedPageProps } from '../../../components/ProtectedPage'; import ProtectedPage from '../../../components/ProtectedPage'; -import { getSquadOpenGraph } from '../../../next-seo'; +import { getSquadOpenGraph, noindexSeoProps } from '../../../next-seo'; import { getPageSeoTitles } from '../../../components/layouts/utils'; import type { DynamicSeoProps } from '../../../components/common'; import { getAppOrigin } from '../../../lib/seo'; @@ -696,7 +696,11 @@ export async function getServerSideProps({ referringUserPromise, ]); - const seoUsers = squad.public + // Fail closed: anything we can't positively confirm as a public squad stays + // out of the index. + const isPublicSquad = squad?.public === true; + + const seoUsers = isPublicSquad ? await getSquad(handle) .then((fullSquad) => getSeoSquadUsers(fullSquad)) .catch(() => undefined) @@ -716,8 +720,8 @@ export async function getServerSideProps({ ...squadSeoTitles.openGraph, ...getSquadOpenGraph({ squad }), }, - nofollow: !squad.public, - noindex: !squad.public, + nofollow: !isPublicSquad, + noindex: !isPublicSquad, }; return { @@ -727,7 +731,7 @@ export async function getServerSideProps({ initialData: squad as Squad, ...(seoUsers && { seoUsers }), ...(referringUser && { referringUser }), - ...(squad.public && { + ...(isPublicSquad && { jsonLd: getSquadPageJsonLd(squad as SquadStaticData), }), }, @@ -740,8 +744,11 @@ export async function getServerSideProps({ if (errors.includes(errorCode)) { setCacheHeader(); + // SSR always runs unauthenticated, so every private squad resolves as + // FORBIDDEN here. This branch also serves the soft-404/rate-limited + // cases, none of which should ever be advertised as indexable. return { - props: { handle }, + props: { handle, seo: { ...noindexSeoProps } }, }; } diff --git a/packages/webapp/pages/squads/moderate.tsx b/packages/webapp/pages/squads/moderate.tsx index 0079a0f6c1d..d55ff6de6c5 100644 --- a/packages/webapp/pages/squads/moderate.tsx +++ b/packages/webapp/pages/squads/moderate.tsx @@ -1,5 +1,6 @@ import type { ReactElement } from 'react'; import type { GetServerSideProps } from 'next'; +import type { NextSeoProps } from 'next-seo'; import React, { useEffect } from 'react'; import { ManageSquadPageContainer } from '@dailydotdev/shared/src/components/squads/utils'; import { @@ -22,6 +23,12 @@ import { verifyPermission } from '@dailydotdev/shared/src/graphql/squads'; import { SourcePermissions } from '@dailydotdev/shared/src/graphql/sources'; import { TypographyType } from '@dailydotdev/shared/src/components/typography/Typography'; import { getLayout as getMainLayout } from '../../components/layouts/MainLayout'; +import { noindexSeoProps } from '../../next-seo'; + +const seo: NextSeoProps = { + title: 'Squad settings', + ...noindexSeoProps, +}; interface ModerateSquadPageProps { handle: string | null; @@ -82,3 +89,4 @@ export default function ModerateSquadPage({ } ModerateSquadPage.getLayout = getMainLayout; +ModerateSquadPage.layoutProps = { seo }; diff --git a/packages/webapp/pages/verification.tsx b/packages/webapp/pages/verification.tsx index 3ccf12fcc30..ef7e5e213e7 100644 --- a/packages/webapp/pages/verification.tsx +++ b/packages/webapp/pages/verification.tsx @@ -1,3 +1,4 @@ +import type { NextSeoProps } from 'next-seo'; import type { ReactElement } from 'react'; import React from 'react'; import EmailCodeVerification from '@dailydotdev/shared/src/components/auth/EmailCodeVerification'; @@ -14,6 +15,9 @@ import { Button, ButtonVariant, } from '@dailydotdev/shared/src/components/buttons/Button'; +import { noindexSeoProps } from '../next-seo'; + +const seo: NextSeoProps = { ...noindexSeoProps }; const Verification = (): ReactElement | null => { const router = useRouter(); @@ -78,4 +82,6 @@ const Verification = (): ReactElement | null => { ); }; +Verification.layoutProps = { seo }; + export default Verification; From 0bb7db71f84cb07ac5b8e8e2a31475c4a5c500dd Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:07:03 +0300 Subject: [PATCH 2/2] chore: skip pre-existing strict violations on noindex-touched pages These pages were touched only to attach noindex seo; their strict errors predate this branch. Claude-Session: https://claude.ai/code/session_01JP6oFXEXeuSsjr6VZQ6KMs --- scripts/typecheck-strict-changed.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/typecheck-strict-changed.js b/scripts/typecheck-strict-changed.js index 22d948f3a1e..4a3b48231ad 100644 --- a/scripts/typecheck-strict-changed.js +++ b/scripts/typecheck-strict-changed.js @@ -164,6 +164,18 @@ const strictSkipList = new Set([ // mutable formRef typing on unrelated lines) predate this change and should // be addressed in a dedicated cleanup PR. 'packages/webapp/pages/posts/[id]/edit.tsx', + // Noindex branch — these pages were touched only to attach `noindex` seo + // (a `layoutProps` assignment or a spread into an existing seo object). + // Pre-existing strict violations (squad/organization/member optionality, + // untyped route params, `null` component returns, campaign flag + // optionality) live on unrelated lines and should be addressed in a + // dedicated cleanup PR. + 'packages/webapp/pages/squads/[handle]/[token].tsx', + 'packages/webapp/pages/squads/[handle]/analytics.tsx', + 'packages/webapp/pages/squads/moderate.tsx', + 'packages/webapp/pages/join/organization.tsx', + 'packages/webapp/pages/posts/[id]/analytics/index.tsx', + 'packages/webapp/pages/backoffice/keywords/[value].tsx', ]); const changedFiles = getChangedTypescriptFiles().filter(