diff --git a/server/.gitignore b/server/.gitignore
index 4f2bbcf8a9..4a66f04b4f 100644
--- a/server/.gitignore
+++ b/server/.gitignore
@@ -11,6 +11,8 @@ yarn-debug.log*
 yarn-error.log*
 lerna-debug.log*
 
+www/
+
 # OS
 .DS_Store
 
diff --git a/server/src/domain/auth/auth.service.ts b/server/src/domain/auth/auth.service.ts
index b092ea7d31..a18b312ba3 100644
--- a/server/src/domain/auth/auth.service.ts
+++ b/server/src/domain/auth/auth.service.ts
@@ -371,7 +371,7 @@ export class AuthService {
     return cookies[IMMICH_ACCESS_COOKIE] || null;
   }
 
-  private async validateSharedLink(key: string | string[]): Promise<AuthDto> {
+  async validateSharedLink(key: string | string[]): Promise<AuthDto> {
     key = Array.isArray(key) ? key[0] : key;
 
     const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
diff --git a/server/src/domain/domain.util.ts b/server/src/domain/domain.util.ts
index 890ea3a8b5..5ef6da6a94 100644
--- a/server/src/domain/domain.util.ts
+++ b/server/src/domain/domain.util.ts
@@ -16,6 +16,12 @@ import { CronJob } from 'cron';
 import { basename, extname } from 'node:path';
 import sanitize from 'sanitize-filename';
 
+export interface OpenGraphTags {
+  title: string;
+  description: string;
+  imageUrl?: string;
+}
+
 export type Options = {
   optional?: boolean;
   each?: boolean;
diff --git a/server/src/domain/shared-link/shared-link.service.spec.ts b/server/src/domain/shared-link/shared-link.service.spec.ts
index cb50f9ba57..6d95d2831f 100644
--- a/server/src/domain/shared-link/shared-link.service.spec.ts
+++ b/server/src/domain/shared-link/shared-link.service.spec.ts
@@ -256,4 +256,27 @@ describe(SharedLinkService.name, () => {
       expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
     });
   });
+
+  describe('getMetadataTags', () => {
+    it('should return null when auth is not a shared link', async () => {
+      await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
+      expect(shareMock.get).not.toHaveBeenCalled();
+    });
+
+    it('should return null when shared link has a password', async () => {
+      await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null);
+      expect(shareMock.get).not.toHaveBeenCalled();
+    });
+
+    it('should return metadata tags', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.individual);
+      await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
+        description: '1 shared photos & videos',
+        imageUrl:
+          '/api/asset/thumbnail/asset-id?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0',
+        title: 'Public Share',
+      });
+      expect(shareMock.get).toHaveBeenCalled();
+    });
+  });
 });
diff --git a/server/src/domain/shared-link/shared-link.service.ts b/server/src/domain/shared-link/shared-link.service.ts
index ee53e95080..b2b488138f 100644
--- a/server/src/domain/shared-link/shared-link.service.ts
+++ b/server/src/domain/shared-link/shared-link.service.ts
@@ -3,6 +3,7 @@ import { BadRequestException, ForbiddenException, Inject, Injectable, Unauthoriz
 import { AccessCore, Permission } from '../access';
 import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
 import { AuthDto } from '../auth';
+import { OpenGraphTags } from '../domain.util';
 import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
 import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
 import { SharedLinkCreateDto, SharedLinkEditDto, SharedLinkPasswordDto } from './shared-link.dto';
@@ -28,7 +29,7 @@ export class SharedLinkService {
       throw new ForbiddenException();
     }
 
-    const sharedLink = await this.findOrFail(auth, auth.sharedLink.id);
+    const sharedLink = await this.findOrFail(auth.user.id, auth.sharedLink.id);
     const response = this.map(sharedLink, { withExif: sharedLink.showExif });
     if (sharedLink.password) {
       response.token = this.validateAndRefreshToken(sharedLink, dto);
@@ -38,7 +39,7 @@ export class SharedLinkService {
   }
 
   async get(auth: AuthDto, id: string): Promise<SharedLinkResponseDto> {
-    const sharedLink = await this.findOrFail(auth, id);
+    const sharedLink = await this.findOrFail(auth.user.id, id);
     return this.map(sharedLink, { withExif: true });
   }
 
@@ -79,7 +80,7 @@ export class SharedLinkService {
   }
 
   async update(auth: AuthDto, id: string, dto: SharedLinkEditDto) {
-    await this.findOrFail(auth, id);
+    await this.findOrFail(auth.user.id, id);
     const sharedLink = await this.repository.update({
       id,
       userId: auth.user.id,
@@ -94,12 +95,13 @@ export class SharedLinkService {
   }
 
   async remove(auth: AuthDto, id: string): Promise<void> {
-    const sharedLink = await this.findOrFail(auth, id);
+    const sharedLink = await this.findOrFail(auth.user.id, id);
     await this.repository.remove(sharedLink);
   }
 
-  private async findOrFail(auth: AuthDto, id: string) {
-    const sharedLink = await this.repository.get(auth.user.id, id);
+  // TODO: replace `userId` with permissions and access control checks
+  private async findOrFail(userId: string, id: string) {
+    const sharedLink = await this.repository.get(userId, id);
     if (!sharedLink) {
       throw new BadRequestException('Shared link not found');
     }
@@ -107,7 +109,7 @@ export class SharedLinkService {
   }
 
   async addAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
-    const sharedLink = await this.findOrFail(auth, id);
+    const sharedLink = await this.findOrFail(auth.user.id, id);
 
     if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
       throw new BadRequestException('Invalid shared link type');
@@ -141,7 +143,7 @@ export class SharedLinkService {
   }
 
   async removeAssets(auth: AuthDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
-    const sharedLink = await this.findOrFail(auth, id);
+    const sharedLink = await this.findOrFail(auth.user.id, id);
 
     if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
       throw new BadRequestException('Invalid shared link type');
@@ -164,6 +166,24 @@ export class SharedLinkService {
     return results;
   }
 
+  async getMetadataTags(auth: AuthDto): Promise<null | OpenGraphTags> {
+    if (!auth.sharedLink || auth.sharedLink.password) {
+      return null;
+    }
+
+    const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id);
+    const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
+    const assetCount = sharedLink.assets.length || sharedLink.album?.assets.length || 0;
+
+    return {
+      title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
+      description: sharedLink.description || `${assetCount} shared photos & videos`,
+      imageUrl: assetId
+        ? `/api/asset/thumbnail/${assetId}?key=${sharedLink.key.toString('base64url')}`
+        : '/feature-panel.png',
+    };
+  }
+
   private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
     return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
   }
diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts
index 0683b65515..4f6a47a482 100644
--- a/server/src/immich/app.service.ts
+++ b/server/src/immich/app.service.ts
@@ -1,16 +1,47 @@
-import { JobService, LibraryService, ONE_HOUR, ServerInfoService, StorageService } from '@app/domain';
+import {
+  AuthService,
+  JobService,
+  ONE_HOUR,
+  OpenGraphTags,
+  ServerInfoService,
+  SharedLinkService,
+  StorageService,
+} from '@app/domain';
 import { Injectable, Logger } from '@nestjs/common';
 import { Cron, CronExpression, Interval } from '@nestjs/schedule';
+import { NextFunction, Request, Response } from 'express';
+import { readFileSync } from 'fs';
+
+const render = (index: string, meta: OpenGraphTags) => {
+  const tags = `
+    <meta name="description" content="${meta.description}" />
+
+    <!-- Facebook Meta Tags -->
+    <meta property="og:type" content="website" />
+    <meta property="og:title" content="${meta.title}" />
+    <meta property="og:description" content="${meta.description}" />
+    ${meta.imageUrl ? `<meta property="og:image" content="${meta.imageUrl}" />` : ''}
+
+    <!-- Twitter Meta Tags -->
+    <meta name="twitter:card" content="summary_large_image" />
+    <meta name="twitter:title" content="${meta.title}" />
+    <meta name="twitter:description" content="${meta.description}" />
+
+    ${meta.imageUrl ? `<meta name="twitter:image" content="${meta.imageUrl}" />` : ''}`;
+
+  return index.replace('<!-- metadata:tags -->', tags);
+};
 
 @Injectable()
 export class AppService {
   private logger = new Logger(AppService.name);
 
   constructor(
+    private authService: AuthService,
     private jobService: JobService,
-    private libraryService: LibraryService,
-    private storageService: StorageService,
     private serverService: ServerInfoService,
+    private sharedLinkService: SharedLinkService,
+    private storageService: StorageService,
   ) {}
 
   @Interval(ONE_HOUR.as('milliseconds'))
@@ -28,4 +59,47 @@ export class AppService {
     await this.serverService.handleVersionCheck();
     this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);
   }
+
+  ssr(excludePaths: string[]) {
+    const index = readFileSync('/usr/src/app/www/index.html').toString();
+
+    return async (req: Request, res: Response, next: NextFunction) => {
+      if (
+        req.url.startsWith('/api') ||
+        req.method.toLowerCase() !== 'get' ||
+        excludePaths.find((item) => req.url.startsWith(item))
+      ) {
+        return next();
+      }
+
+      const targets = [
+        {
+          regex: /^\/share\/(.+)$/,
+          onMatch: async (matches: RegExpMatchArray) => {
+            const key = matches[1];
+            const auth = await this.authService.validateSharedLink(key);
+            return this.sharedLinkService.getMetadataTags(auth);
+          },
+        },
+      ];
+
+      let html = index;
+
+      try {
+        for (const { regex, onMatch } of targets) {
+          const matches = req.url.match(regex);
+          if (matches) {
+            const meta = await onMatch(matches);
+            if (meta) {
+              html = render(index, meta);
+            }
+
+            break;
+          }
+        }
+      } catch {}
+
+      res.type('text/html').header('Cache-Control', 'no-store').send(html);
+    };
+  }
 }
diff --git a/server/src/immich/app.utils.ts b/server/src/immich/app.utils.ts
index a667dce9f5..d7bbd25dbd 100644
--- a/server/src/immich/app.utils.ts
+++ b/server/src/immich/app.utils.ts
@@ -13,7 +13,6 @@ import {
   SwaggerDocumentOptions,
   SwaggerModule,
 } from '@nestjs/swagger';
-import { NextFunction, Request, Response } from 'express';
 import { writeFileSync } from 'fs';
 import path from 'path';
 
@@ -101,14 +100,6 @@ const patchOpenAPI = (document: OpenAPIObject) => {
   return document;
 };
 
-export const indexFallback = (excludePaths: string[]) => (req: Request, res: Response, next: NextFunction) => {
-  if (req.url.startsWith('/api') || req.method.toLowerCase() !== 'get' || excludePaths.indexOf(req.url) !== -1) {
-    next();
-  } else {
-    res.sendFile('/www/index.html', { root: process.cwd() });
-  }
-};
-
 export const useSwagger = (app: INestApplication, isDev: boolean) => {
   const config = new DocumentBuilder()
     .setTitle('Immich')
diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts
index 8711e11d10..afc3a41c6d 100644
--- a/server/src/immich/main.ts
+++ b/server/src/immich/main.ts
@@ -6,7 +6,8 @@ import { NestExpressApplication } from '@nestjs/platform-express';
 import { json } from 'body-parser';
 import cookieParser from 'cookie-parser';
 import { AppModule } from './app.module';
-import { indexFallback, useSwagger } from './app.utils';
+import { AppService } from './app.service';
+import { useSwagger } from './app.utils';
 
 const logger = new Logger('ImmichServer');
 const port = Number(process.env.SERVER_PORT) || 3001;
@@ -27,7 +28,7 @@ export async function bootstrap() {
   const excludePaths = ['/.well-known/immich', '/custom.css'];
   app.setGlobalPrefix('api', { exclude: excludePaths });
   app.useStaticAssets('www');
-  app.use(indexFallback(excludePaths));
+  app.use(app.get(AppService).ssr(excludePaths));
 
   await enablePrefilter();
 
diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts
index 1a24d8cc17..3dbbdcbf12 100644
--- a/server/test/fixtures/auth.stub.ts
+++ b/server/test/fixtures/auth.stub.ts
@@ -104,6 +104,20 @@ export const authStub = {
       showExif: true,
     } as SharedLinkEntity,
   }),
+  passwordSharedLink: Object.freeze<AuthDto>({
+    user: {
+      id: 'admin_id',
+      email: 'admin@test.com',
+      isAdmin: true,
+    } as UserEntity,
+    sharedLink: {
+      id: '123',
+      allowUpload: false,
+      allowDownload: false,
+      password: 'password-123',
+      showExif: true,
+    } as SharedLinkEntity,
+  }),
 };
 
 export const loginResponseStub = {
diff --git a/web/src/app.html b/web/src/app.html
index 5f31df3336..8591848a0a 100644
--- a/web/src/app.html
+++ b/web/src/app.html
@@ -1,6 +1,9 @@
 <!doctype html>
 <html lang="en" class="dark">
   <head>
+    <!-- (used for SSR) -->
+    <!-- metadata:tags -->
+
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     %sveltekit.head%
diff --git a/web/src/routes/(user)/share/[key]/+page.ts b/web/src/routes/(user)/share/[key]/+page.ts
index 21604ed683..d23f393ca3 100644
--- a/web/src/routes/(user)/share/[key]/+page.ts
+++ b/web/src/routes/(user)/share/[key]/+page.ts
@@ -1,4 +1,3 @@
-import featurePanelUrl from '$lib/assets/feature-panel.png';
 import { getAuthUser } from '$lib/utils/auth';
 import { api, ThumbnailFormat } from '@api';
 import { error } from '@sveltejs/kit';
@@ -21,7 +20,9 @@ export const load = (async ({ params }) => {
       meta: {
         title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
         description: sharedLink.description || `${assetCount} shared photos & videos.`,
-        imageUrl: assetId ? api.getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp, sharedLink.key) : featurePanelUrl,
+        imageUrl: assetId
+          ? api.getAssetThumbnailUrl(assetId, ThumbnailFormat.Webp, sharedLink.key)
+          : '/feature-panel.png',
       },
     };
   } catch (e) {
diff --git a/web/src/lib/assets/feature-panel.png b/web/static/feature-panel.png
similarity index 100%
rename from web/src/lib/assets/feature-panel.png
rename to web/static/feature-panel.png