diff --git a/frontend/__tests__/unit/utils/structuredData.test.ts b/frontend/__tests__/unit/utils/structuredData.test.ts
new file mode 100644
index 0000000000..27ba2d730e
--- /dev/null
+++ b/frontend/__tests__/unit/utils/structuredData.test.ts
@@ -0,0 +1,141 @@
+import type { UserDetails } from 'types/user'
+import { generateProfilePageStructuredData } from 'utils/structuredData'
+
+describe('generateProfilePageStructuredData', () => {
+ const mockUser: UserDetails = {
+ avatarUrl: 'https://example.com/avatar.jpg',
+ bio: 'Security researcher and OWASP contributor',
+ company: 'Security Corp',
+ contributionsCount: 150,
+ createdAt: '2020-01-01T00:00:00Z',
+ email: 'user@example.com',
+ followersCount: 500,
+ followingCount: 200,
+ key: 'testuser',
+ location: 'San Francisco, CA, USA',
+ login: 'testuser',
+ name: 'Test User',
+ publicRepositoriesCount: 25,
+ updatedAt: '2021-02-03T00:00:00Z',
+ url: 'https://github.com/testuser',
+ }
+
+ it('should generate valid ProfilePage structured data', () => {
+ const result = generateProfilePageStructuredData(mockUser)
+
+ expect(result).toEqual({
+ '@context': 'https://schema.org',
+ '@type': 'ProfilePage',
+ dateCreated: '1970-01-01T00:33:40.000Z',
+ dateModified: '1970-01-01T00:33:41.000Z',
+ mainEntity: {
+ '@type': 'Person',
+ address: 'San Francisco, CA, USA',
+ description: 'Security researcher and OWASP contributor',
+ identifier: 'testuser',
+ image: 'https://example.com/avatar.jpg',
+ interactionStatistic: [
+ {
+ '@type': 'InteractionCounter',
+ interactionType: 'https://schema.org/FollowAction',
+ userInteractionCount: 500,
+ },
+ ],
+ memberOf: {
+ '@type': 'Organization',
+ name: 'OWASP Community',
+ url: 'https://nest.owasp.org/members',
+ },
+ name: 'Test User',
+ sameAs: ['https://github.com/testuser'],
+ url: 'https://nest.owasp.org/members/testuser',
+ worksFor: {
+ '@type': 'Organization',
+ name: 'Security Corp',
+ },
+ },
+ })
+ })
+
+ it('should handle user without optional fields', () => {
+ const minimalUser: UserDetails = {
+ avatarUrl: 'https://example.com/avatar.jpg',
+ contributionsCount: 0,
+ createdAt: '2020-01-01T00:00:00Z',
+ followersCount: 0,
+ followingCount: 0,
+ key: 'basicuser',
+ login: 'basicuser',
+ publicRepositoriesCount: 0,
+ updatedAt: '2021-02-03T00:00:00Z',
+ url: 'https://github.com/basicuser',
+ }
+
+ const result = generateProfilePageStructuredData(minimalUser)
+
+ expect(result.mainEntity.name).toBe('basicuser')
+ expect(result.mainEntity.description).toBeUndefined()
+ expect(result.mainEntity.worksFor).toBeUndefined()
+ expect(result.mainEntity.address).toBeUndefined()
+ expect(result.mainEntity.interactionStatistic).toBeUndefined()
+
+ // These should always be present
+ expect(result.mainEntity.memberOf).toBeDefined()
+ })
+
+ it('should include interaction statistics only when followers count > 0', () => {
+ const userWithFollowers = { ...mockUser, followersCount: 100 }
+ const userWithoutFollowers = { ...mockUser, followersCount: 0 }
+
+ const resultWithFollowers = generateProfilePageStructuredData(userWithFollowers)
+ const resultWithoutFollowers = generateProfilePageStructuredData(userWithoutFollowers)
+
+ expect(resultWithFollowers.mainEntity.interactionStatistic).toBeDefined()
+ expect(resultWithFollowers.mainEntity.interactionStatistic?.[0].userInteractionCount).toBe(100)
+
+ expect(resultWithoutFollowers.mainEntity.interactionStatistic).toBeUndefined()
+ })
+
+ it('should use custom base URL when provided', () => {
+ const result = generateProfilePageStructuredData(mockUser, 'https://custom.example.com')
+
+ expect(result.mainEntity.url).toBe('https://custom.example.com/members/testuser')
+ })
+
+ it('should handle user with company but no location', () => {
+ const userWithCompanyOnly = {
+ ...mockUser,
+ company: 'Tech Corp',
+ location: undefined,
+ }
+
+ const result = generateProfilePageStructuredData(userWithCompanyOnly)
+
+ expect(result.mainEntity.worksFor).toEqual({
+ '@type': 'Organization',
+ name: 'Tech Corp',
+ })
+ expect(result.mainEntity.address).toBeUndefined()
+ })
+
+ it('should handle user with location but no company', () => {
+ const userWithLocationOnly = {
+ ...mockUser,
+ company: undefined,
+ location: 'New York, NY',
+ }
+
+ const result = generateProfilePageStructuredData(userWithLocationOnly)
+
+ expect(result.mainEntity.address).toEqual('New York, NY')
+ expect(result.mainEntity.worksFor).toBeUndefined()
+ })
+
+ it('should fallback to login when name is not provided', () => {
+ const userWithoutName = { ...mockUser, name: undefined }
+
+ const result = generateProfilePageStructuredData(userWithoutName)
+
+ expect(result.mainEntity.name).toBe('testuser')
+ })
+})
diff --git a/frontend/src/app/members/[memberKey]/layout.tsx b/frontend/src/app/members/[memberKey]/layout.tsx
index 8b79b5c936..bc78d19cdd 100644
--- a/frontend/src/app/members/[memberKey]/layout.tsx
+++ b/frontend/src/app/members/[memberKey]/layout.tsx
@@ -1,8 +1,10 @@
import { Metadata } from 'next'
import React from 'react'
import { apolloClient } from 'server/apolloClient'
-import { GET_USER_METADATA } from 'server/queries/userQueries'
+import { GET_USER_METADATA, GET_USER_DATA } from 'server/queries/userQueries'
import { generateSeoMetadata } from 'utils/metaconfig'
+import { generateProfilePageStructuredData } from 'utils/structuredData'
+import StructuredDataScript from 'components/StructuredDataScript'
export async function generateMetadata({
params,
@@ -29,6 +31,30 @@ export async function generateMetadata({
: null
}
-export default function UserDetailsLayout({ children }: { children: React.ReactNode }) {
- return children
+export default async function UserDetailsLayout({
+ children,
+ params,
+}: {
+ children: React.ReactNode
+ params: Promise<{ memberKey: string }>
+}) {
+ const { memberKey } = await params
+
+ const { data } = await apolloClient.query({
+ query: GET_USER_DATA,
+ variables: {
+ key: memberKey,
+ },
+ })
+
+ if (!data?.user?.login) {
+ return children
+ }
+
+ return (
+ <>
+
+ {children}
+ >
+ )
}
diff --git a/frontend/src/components/StructuredDataScript.tsx b/frontend/src/components/StructuredDataScript.tsx
new file mode 100644
index 0000000000..3e70b99ad7
--- /dev/null
+++ b/frontend/src/components/StructuredDataScript.tsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import { ProfilePageStructuredData } from 'types/profilePageStructuredData'
+
+interface StructuredDataScriptProps {
+ data: ProfilePageStructuredData
+}
+
+// dangerouslySetInnerHTML injects the JSON data as a script tag.
+const StructuredDataScript: React.FC = ({ data }) => {
+ return (
+
+ )
+}
+
+export default StructuredDataScript
diff --git a/frontend/src/server/queries/userQueries.ts b/frontend/src/server/queries/userQueries.ts
index 7782a51c14..c171be9b4a 100644
--- a/frontend/src/server/queries/userQueries.ts
+++ b/frontend/src/server/queries/userQueries.ts
@@ -72,6 +72,7 @@ export const GET_USER_DATA = gql`
name
publicRepositoriesCount
releasesCount
+ updatedAt
url
}
}
diff --git a/frontend/src/types/profilePageStructuredData.ts b/frontend/src/types/profilePageStructuredData.ts
new file mode 100644
index 0000000000..e48e56e0cd
--- /dev/null
+++ b/frontend/src/types/profilePageStructuredData.ts
@@ -0,0 +1,31 @@
+export interface ProfilePageStructuredData {
+ '@context': string
+ '@type': string
+ dateCreated?: string
+ dateModified?: string
+ mainEntity: {
+ '@type': string
+ address?: string
+ description?: string
+ identifier?: string
+ image?: string
+ interactionStatistic?: Array<{
+ '@type': string
+ interactionType: string
+ userInteractionCount: number
+ }>
+ knowsAbout?: string[]
+ memberOf?: {
+ '@type': string
+ name: string
+ url: string
+ }
+ name: string
+ sameAs?: string[]
+ url?: string
+ worksFor?: {
+ '@type': string
+ name: string
+ }
+ }
+}
diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts
index 92cc4dfa80..eeae8b7229 100644
--- a/frontend/src/types/user.ts
+++ b/frontend/src/types/user.ts
@@ -26,6 +26,7 @@ export type User = {
releases?: Release[]
releasesCount?: number
topRepositories?: RepositoryCardProps[]
+ updatedAt?: T
url: string
}
diff --git a/frontend/src/utils/structuredData.ts b/frontend/src/utils/structuredData.ts
new file mode 100644
index 0000000000..a43f60f27b
--- /dev/null
+++ b/frontend/src/utils/structuredData.ts
@@ -0,0 +1,55 @@
+import { ProfilePageStructuredData } from 'types/profilePageStructuredData'
+import type { UserDetails } from 'types/user'
+
+/**
+ * JSON-LD structure data for ProfilePage
+ * https://developers.google.com/search/docs/appearance/structured-data/profile-page
+ *
+ * - @context: "https://schema.org"
+ * - @type: "ProfilePage"
+ * - mainEntity: A Person or Organization (using type Person for OWASP community members)
+ *
+ */
+export function generateProfilePageStructuredData(
+ user: UserDetails,
+ baseUrl = 'https://nest.owasp.org'
+): ProfilePageStructuredData {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'ProfilePage',
+ dateCreated: new Date(parseInt(user.createdAt) * 1000).toISOString(),
+ dateModified: new Date(parseInt(user.updatedAt) * 1000).toISOString(),
+ mainEntity: {
+ '@type': 'Person',
+ ...(user.location && {
+ address: user.location,
+ }),
+ description: user.bio,
+ identifier: user.login,
+ image: user.avatarUrl,
+ ...(user.followersCount > 0 && {
+ interactionStatistic: [
+ {
+ '@type': 'InteractionCounter',
+ interactionType: 'https://schema.org/FollowAction',
+ userInteractionCount: user.followersCount,
+ },
+ ],
+ }),
+ memberOf: {
+ '@type': 'Organization',
+ name: 'OWASP Community',
+ url: 'https://nest.owasp.org/members',
+ },
+ name: user.name || user.login,
+ sameAs: [user.url],
+ url: `${baseUrl}/members/${user.login}`,
+ ...(user.company && {
+ worksFor: {
+ '@type': 'Organization',
+ name: user.company,
+ },
+ }),
+ },
+ }
+}