A dynamic and easily maintainable portfolio website powered by Notion as a CMS. The portfolio fetches data from a Notion database and renders it seamlessly using Next.js.
Built on top of https://magicui.design/docs/templates/portfolio
Notion CMS
| Layer | Technology |
|---|---|
| Framework | Next.js 14 (App Router), deployed on Vercel |
| UI | Magic UI / shadcn/ui (Radix primitives) |
| CMS | Notion (3 databases) |
| Media CDN | Cloudinary |
| Animations | Framer Motion |
| Syntax highlighting | rehype-pretty-code + Shiki |
| Page view counter | Upstash Redis (via Vercel marketplace) |
- Frontend: Vercel
- Create a new Notion integration at https://www.notion.so/profile/integrations with any name and type Internal.
-
Create three Notion databases using the schemas below.
-
In each database, connect your integration via the ... menu → Connect to.
- Copy the integration secret and each database ID into your
.env.
To get a database ID, click Copy link on the database. The characters before ? in the URL are the ID:
www.notion.so/suhayba/19c97a45977480d6b3ffd537e3ca13b1?v=...
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
This is your database ID
- (Optional) Set up Notion automations to POST to
/api/notion-webhookfor automatic Cloudinary media uploads on file property changes.
Single-row table holding your personal info.
| Property | Type | Notes |
|---|---|---|
| Name | Title | |
| Description | Text | |
| Avatar | URL | Cloudinary URL (set via webhook) |
| Resume | URL | Cloudinary URL for PDF (set via webhook) |
| GitHub | URL | |
| URL | ||
| URL | ||
| URL | ||
| YouTube | URL | |
| githubid | Text | Username for the GitHub contribution calendar |
| Skills | Multi-select | |
| Timezone | Text | IANA tz string, e.g. Europe/London |
| Map Image | URL | Cloudinary URL (set via webhook) |
| Map File | Files & media | Upload here; webhook processes and clears |
| Avatar File | Files & media | Same pattern |
| Resume File | Files & media | Same pattern |
One row per work / education / project / volunteering entry.
| Property | Type | Notes |
|---|---|---|
| Title | Title | Company, school, or project name |
| Subtitle | Text | Role title or degree |
| Description | Text | |
| Category | Select | Work / Education / Projects / Volunteering |
| Active | Checkbox | Only checked rows are displayed |
| Start Date | Date | |
| End Date | Date | |
| Logo | URL | Cloudinary URL |
| Logo File | Files & media | Webhook uploads to Cloudinary → writes Logo |
| Media URL | URL | Cloudinary URL for project image or video |
| Media File | Files & media | Webhook uploads to Cloudinary → writes Media URL |
| URL | URL | Live site link |
| Source | URL | GitHub / source link |
| Technologies | Multi-select | |
| Location | Text |
One row per post. Page body holds the actual content as Notion blocks.
| Property | Type | Notes |
|---|---|---|
| Title | Title | |
| Slug | Text | URL path segment, e.g. my-first-post |
| PublishedAt | Date | |
| Summary | Text | Shown in listing and meta description |
| Cover | URL | Cloudinary URL for OG image |
| Cover File | Files & media | Webhook uploads to Cloudinary → writes Cover |
| Status | Select | Published / Draft |
# Notion
NOTION_TOKEN= # Integration secret
NOTION_PERSONAL= # Personal DB ID
NOTION_PORTFOLIO= # Portfolio DB ID
NOTION_BLOG_DB= # Blog DB ID
NOTION_WEBHOOK_SECRET= # HMAC secret for webhook signature verification
# Cloudinary — required for the media webhook (/api/notion-webhook)
CLOUDINARY_CLOUD_NAME=
CLOUDINARY_API_KEY=
CLOUDINARY_API_SECRET=
# ISR
REVALIDATE=86400 # Home page revalidation interval in seconds
# Upstash Redis — required for the page view counter (via Vercel → Storage → Upstash)
KV_REST_API_URL=
KV_REST_API_TOKEN=
KV_REST_API_READ_ONLY_TOKEN=
KV_URL=
REDIS_URL=- Clone the repository:
git clone https://github.com/swebi/portfolio.git
- Install dependencies:
pnpm install
- Set up environment variables in a
.envfile (see above). - Start the dev server:
pnpm dev


