diff --git a/app/feed.json/route.ts b/app/feed.json/route.ts
new file mode 100644
index 00000000..8b8f64f8
--- /dev/null
+++ b/app/feed.json/route.ts
@@ -0,0 +1,28 @@
+import { getPhotosCached } from '@/photo/cache';
+import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo';
+import { formatPhotoForFeed } from '@/app/feed';
+import {
+ BASE_URL,
+ PUBLIC_FEED_ENABLED,
+ META_TITLE,
+} from '@/app/config';
+
+export const dynamic = 'force-static';
+
+export async function GET() {
+ if (PUBLIC_FEED_ENABLED) {
+ const photos = await getPhotosCached({
+ limit: INFINITE_SCROLL_FEED_INITIAL,
+ sortBy: 'createdAt',
+ });
+ return Response.json({
+ meta: {
+ title: META_TITLE,
+ url: BASE_URL,
+ },
+ photos: photos.map(formatPhotoForFeed),
+ });
+ } else {
+ return new Response('Feed disabled', { status: 404 });
+ }
+}
diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts
new file mode 100644
index 00000000..bffa4e6b
--- /dev/null
+++ b/app/rss.xml/route.ts
@@ -0,0 +1,48 @@
+import { INFINITE_SCROLL_FEED_INITIAL } from '@/photo';
+import { getPhotosCached } from '@/photo/cache';
+import {
+ BASE_URL,
+ META_DESCRIPTION,
+ META_TITLE,
+ PUBLIC_FEED_ENABLED,
+} from '@/app/config';
+import { feedPhotoToXml, formatPhotoForFeed } from '@/app/feed';
+
+export const dynamic = 'force-static';
+
+export async function GET() {
+ if (PUBLIC_FEED_ENABLED) {
+ const photos = await getPhotosCached({
+ limit: INFINITE_SCROLL_FEED_INITIAL,
+ sortBy: 'createdAt',
+ });
+ const items = photos.map(formatPhotoForFeed).map(feedPhotoToXml);
+
+ return new Response(
+ `
+
+
+
+ ${META_TITLE}
+
+ ${BASE_URL}
+ ${META_DESCRIPTION}
+
+ ${items.join('\n\n ')}
+
+
+
+ `,
+ {
+ headers: {
+ 'Content-Type': 'text/xml',
+ },
+ },
+ );
+ } else {
+ return new Response('RSS feed access disabled', { status: 404 });
+ }
+}
diff --git a/src/app/feed.ts b/src/app/feed.ts
new file mode 100644
index 00000000..5ccbe104
--- /dev/null
+++ b/src/app/feed.ts
@@ -0,0 +1,87 @@
+import { Photo } from '@/photo';
+import { absolutePathForPhoto } from './paths';
+import { getNextImageUrlForRequest } from '@/platforms/next-image';
+import { formatDate } from '@/utility/date';
+
+export const FEED_PHOTO_WIDTH_CONTENT = 1080;
+export const FEED_PHOTO_WIDTH_THUMB = 640;
+
+export interface PublicFeed {
+ meta: {
+ title: string
+ url: string
+ }
+ photos: PublicFeedPhoto[]
+}
+
+interface PublicFeedMedia {
+ url: string
+ width: number
+ height: number
+}
+
+interface PublicFeedPhoto {
+ id: string
+ title?: string
+ description?: string
+ link: string
+ publicationDate: Date
+ media: Record<
+ 'content' | 'thumb',
+ PublicFeedMedia
+ >
+}
+
+export const formatPhotoForFeed = (photo: Photo): PublicFeedPhoto => ({
+ id: photo.id,
+ title: photo.title,
+ description: photo.caption,
+ link: absolutePathForPhoto({ photo }),
+ publicationDate: photo.createdAt,
+ media: {
+ content: {
+ url: getNextImageUrlForRequest({
+ imageUrl: photo.url,
+ size: FEED_PHOTO_WIDTH_CONTENT,
+ }),
+ width: FEED_PHOTO_WIDTH_CONTENT,
+ height: Math.round(FEED_PHOTO_WIDTH_CONTENT / photo.aspectRatio),
+ },
+ thumb: {
+ url: getNextImageUrlForRequest({
+ imageUrl: photo.url,
+ size: FEED_PHOTO_WIDTH_THUMB,
+ }),
+ width: FEED_PHOTO_WIDTH_THUMB,
+ height: Math.round(FEED_PHOTO_WIDTH_THUMB / photo.aspectRatio),
+ },
+ },
+});
+
+export const feedPhotoToXml = (photo: PublicFeedPhoto): string => {
+ const formattedDate = formatDate({
+ date: photo.publicationDate,
+ length: 'rss',
+ });
+ const description = photo.description ?
+ `
+
+ ` : '';
+
+ return ` -
+ ${photo.title}
+ ${photo.link}
+ ${formattedDate}
+ ${photo.link}
+ ${description}
+
+
+
+
`;
+};