├── .env.example ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── (posts) │ ├── [slug] │ │ ├── loading.tsx │ │ ├── opengraph-image.tsx │ │ └── page.tsx │ └── layout.tsx ├── favicon.ico ├── layout.tsx ├── loading.tsx ├── not-found.tsx ├── page.tsx ├── provider.tsx └── sitemap.ts ├── biome.json ├── bun.lock ├── components.json ├── components ├── home │ ├── gallery.tsx │ └── loading.tsx ├── post │ ├── content.tsx │ ├── header.tsx │ ├── loading.tsx │ └── related.tsx ├── shared │ └── newsletter-form.tsx └── ui │ ├── breadcrumb.tsx │ ├── skeleton.tsx │ └── sonner.tsx ├── config.tsx ├── lib ├── actions.ts ├── env.ts ├── notion.ts ├── redis.ts └── utils.ts ├── next-env.d.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── renovate.json ├── styles ├── font.ts └── globals.css ├── tsconfig.json ├── turbo.json └── utils └── react-scan.tsx /.env.example: -------------------------------------------------------------------------------- 1 | # Notion 2 | NOTION_API_KEY= 3 | NOTION_DATABASE_ID= 4 | 5 | # Redis 6 | REDIS_URL= 7 | REDIS_TOKEN= 8 | 9 | # Umami 10 | NEXT_PUBLIC_UMAMI_URL= 11 | NEXT_PUBLIC_UMAMI_WEBSITE_ID= 12 | 13 | # Site 14 | NEXT_PUBLIC_SITE_URL= 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # Turborepo 44 | .turbo 45 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.organizeImports.biome": "explicit" 4 | }, 5 | "i18n-ally.localesPaths": [ 6 | "**/locales" 7 | ] 8 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Harry Yep 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notion Photo React 2 | 3 | > A Notion Photo powered by Next.js 4 | 5 | > See [Notion Blog React](https://github.com/okisdev/Notion-Blog-React) for a similar project that uses Notion as a database for blog posts. 6 | 7 | ## Demo 8 | 9 | - [nbr.okis.dev](https://nbr.okis.dev) 10 | 11 | ## How to deploy 12 | 13 | Please visit Notion Photo React [Documentation](https://docs.okis.dev/docs/notion-photo-react) for more details. 14 | 15 | ## Problem(s) with deployment 16 | 17 | - email: [hi@okis.dev](mailto:hi@okis.dev) 18 | 19 | ## Alternatives 20 | 21 | > Turn Notion to Blog/Page 22 | 23 | - [React-Notion](https://github.com/splitbee/react-notion) 24 | - [React-Notion-X](https://github.com/NotionX/react-notion-x) 25 | - [Super.so](https://super.so/) 26 | - [Fruition](https://fruitionsite.com/) 27 | 28 | ## Credits 29 | 30 | Copyright (c) 2025 Harry Yep -------------------------------------------------------------------------------- /app/(posts)/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { PostLoadingSkeleton } from '@/components/post/loading'; 2 | 3 | export default function PostLoading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /app/(posts)/[slug]/opengraph-image.tsx: -------------------------------------------------------------------------------- 1 | import { env } from '@/lib/env'; 2 | import { getPhotoBySlug } from '@/lib/notion'; 3 | import { format } from 'date-fns'; 4 | import { ImageResponse } from 'next/og'; 5 | 6 | export const size = { 7 | width: 1200, 8 | height: 630, 9 | }; 10 | 11 | export const contentType = 'image/png'; 12 | 13 | // Image generation 14 | export default async function Image({ params }: { params: { slug: string } }) { 15 | const photo = await getPhotoBySlug(params.slug); 16 | 17 | if (!photo) { 18 | return new Response('Not found', { status: 404 }); 19 | } 20 | 21 | return new ImageResponse( 22 |
33 |
43 |

53 | {photo.title} 54 |

55 | 56 |
65 | {photo.date &&
{format(new Date(photo.date), 'MMMM d, yyyy')}
} 66 | {photo.location && photo.location.length > 0 && ( 67 |
68 | {photo.location.map((location) => ( 69 |
78 | {location} 79 |
80 | ))} 81 |
82 | )} 83 |
84 |
85 | 86 |
96 |
{env.NEXT_PUBLIC_SITE_URL}
97 |
View Photo →
98 |
99 |
, 100 | { 101 | ...size, 102 | } 103 | ); 104 | } 105 | -------------------------------------------------------------------------------- /app/(posts)/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { PhotoContent } from '@/components/post/content'; 2 | import { PhotoHeader } from '@/components/post/header'; 3 | import { PhotoContentSkeleton, PhotoHeaderSkeleton, RelatedPhotosSkeleton } from '@/components/post/loading'; 4 | import { RelatedPhotos } from '@/components/post/related'; 5 | import { config } from '@/config'; 6 | import { getPhotoBySlug } from '@/lib/notion'; 7 | import { notFound } from 'next/navigation'; 8 | import { Suspense } from 'react'; 9 | 10 | export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { 11 | const { slug } = await params; 12 | const photo = await getPhotoBySlug(slug); 13 | 14 | if (!photo) return notFound(); 15 | 16 | return { 17 | title: `${photo.title} - ${config.site.name}`, 18 | description: photo.location || `Photo taken on ${photo.date}`, 19 | }; 20 | } 21 | 22 | export default async function PhotoPage({ params }: { params: Promise<{ slug: string }> }) { 23 | const { slug } = await params; 24 | const photo = await getPhotoBySlug(slug); 25 | 26 | if (!photo) return notFound(); 27 | 28 | return ( 29 |
30 | }> 31 | 32 | 33 | 34 | }> 35 | 36 | 37 | 38 |
39 | 40 | }> 41 | 42 | 43 | 44 | {config.post.footer} 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/(posts)/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from '@/components/ui/breadcrumb'; 4 | import { cn } from '@/lib/utils'; 5 | import { Link } from 'next-view-transitions'; 6 | import { usePathname } from 'next/navigation'; 7 | import { Fragment } from 'react'; 8 | 9 | export default function PostLayout({ children }: { children: React.ReactNode }) { 10 | const pathname = usePathname(); 11 | 12 | const paths = pathname.split('/').filter(Boolean); 13 | 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 21 | Home 22 | 23 | 24 | 25 | 26 | 27 | {paths.map((path, index) => { 28 | const pathParts = path.split('-'); 29 | 30 | const pathName = pathParts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(' '); 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | {pathName} 38 | 39 | 40 | 41 | {index !== paths.length - 1 && } 42 | 43 | ); 44 | })} 45 | 46 | 47 | 48 | {children} 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/okisdev/Notion-Photo-React/c46829ce284ef4c34868c4390dcdf157b9bd84b0/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import BodyProvider from '@/app/provider'; 2 | import { cn } from '@/lib/utils'; 3 | import { font } from '@/styles/font'; 4 | import { ReactScan } from '@/utils/react-scan'; 5 | import '@/styles/globals.css'; 6 | import { env } from '@/lib/env'; 7 | import type { Metadata } from 'next'; 8 | import { ViewTransitions } from 'next-view-transitions'; 9 | import Script from 'next/script'; 10 | 11 | export const metadata: Metadata = { 12 | title: { 13 | default: 'Notion Photo React', 14 | template: '%s | Notion Photo React', 15 | }, 16 | description: 'A Notion Photo Gallery powered by Next.js', 17 | }; 18 | 19 | export default async function RootLayout({ children }: { children: React.ReactNode }) { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | {env.NEXT_PUBLIC_UMAMI_URL &&