Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/assets/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
170 changes: 100 additions & 70 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,104 +1,134 @@
# remcostoeten.nl
<div align="center">

Source for [remcostoeten.nl](https://remcostoeten.nl), a personal website built with Next.js.
It combines a portfolio-style marketing site, an MDX blog, a protected admin area, and a small activity system that pulls GitHub and Spotify data into Postgres.
# remcostoeten.nl

![Preview of remcostoeten.nl](./public/images/readme/site-preview.png)
<a href="https://remcostoeten.nl"><img src=".github/assets/preview.png" alt="remcostoeten.nl — home page" width="100%" /></a>

## What this project includes
**My corner of the internet.** Part portfolio, part blog, part playground for ideas
I want to ship before I talk myself out of them.

- A homepage with work experience, featured projects, and personal profile content
- A filesystem-based MDX blog with topics, RSS, syntax highlighting, and draft support
- An admin area for managing project visibility and internal content workflows
- GitHub and Spotify integrations for recent activity and listening data
- A contact flow backed by server actions and database persistence
- Optional analytics and email integrations for production deployments
[**→ Visit the live site**](https://remcostoeten.nl) &nbsp;·&nbsp; [Read the blog](https://remcostoeten.nl/posts) &nbsp;·&nbsp; [About me](https://remcostoeten.nl/about)

## Stack
<a href="https://shieldcn.dev/github/stars/remcostoeten/remcostoeten.nl.svg"><img src="https://shieldcn.dev/github/stars/remcostoeten/remcostoeten.nl.svg?variant=secondary" alt="Stars" /></a>
<a href="https://github.com/remcostoeten/remcostoeten.nl/commits"><img src="https://shieldcn.dev/github/last-commit/remcostoeten/remcostoeten.nl.svg?variant=secondary" alt="Last commit" /></a>
<a href="./LICENSE"><img src="https://shieldcn.dev/github/license/remcostoeten/remcostoeten.nl.svg?variant=secondary" alt="License" /></a>

- Next.js 16, React 19 and TypeScript
- Tailwind v4
- Drizzle ORM + Postgres cloud (neon.tech)
- GitHub OAuth (better-auth)
- oxlint/oxfmt
</div>

- PostHog, Vercel Analytics, GitHub API, Spotify API
---

## Requirements
It started as a place to park a CV and quietly grew into a small system: an MDX blog,
a GitHub + Spotify activity feed, an admin area, and a pile of things I built mostly
because I was curious whether I could. The code is open — poke around, steal what's useful.

- Node.js `24.x`
- Bun
- A Postgres database
---

## Quick Start
## Stack

1. Clone the repository
| Layer | Tech |
| ------------ | -------------------------------------------------- |
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5.9 |
| Styling | Tailwind v4 |
| Database | Drizzle ORM + Neon Postgres |
| Auth | better-auth (GitHub + Google OAuth) |
| Blog | MDX (filesystem-based, next-mdx-remote) |
| Integrations | GitHub API, Spotify API, PostHog, Vercel Analytics |
| Tooling | Bun, oxlint, oxfmt, Vitest |

## Features

- **Marketing / home**: work history, featured projects, profile
- **Blog**: MDX posts with topics, reading time, RSS, syntax highlighting, draft support
- **Search**: full client-side search across blog posts
- **Admin area**: manage project visibility and content workflows (GitHub OAuth gated)
- **Activity feed**: syncs GitHub commits and Spotify listening data into Postgres via cron
- **Contact**: server action backed, persisted to database
- **OG images**: dynamic edge-rendered open graph images
- **Analytics**: PostHog + Vercel Analytics + Speed Insights

## Project structure

```bash
git clone https://github.com/remcostoeten/remcostoeten.nl.git
cd remcostoeten.nl
```

2. Install dependencies

```bash
bun install
src/
├── app/
│ ├── (admin)/ # protected admin routes
│ ├── (auth)/ # sign-in / callback
│ ├── (marketing)/ # public-facing pages
│ ├── api/ # thin API route handlers
│ └── og/ # OG image generation
├── components/ # shared UI components
├── core/ # analytics, config, metadata
├── features/ # domain modules (auth, blog, github, spotify)
├── server/ # DB, queries, actions, security (never imported client-side)
├── shared/lib/ # utilities shared across server + client
└── views/ # page-level view components
```

3. Create your local environment file
## Getting started

**Requirements:** Node.js `24.x`, Bun

```bash
git clone https://github.com/remcostoeten/remcostoeten.nl.git
cd remcostoeten.nl
bun install
cp .env.example .env.local
```

4. Fill in the minimum required variables in `.env.local`
Fill in the required env vars (see below), then:

```bash
DATABASE_URL="<https://neon.tech>"
BETTER_AUTH_URL="http://localhost:3000"
BETTER_AUTH_SECRET="<random string>"
bun db:push # push schema to your database
bun dev # start dev server -> http://localhost:3000
```

5. Push the schema to your database

```bash
bun drizzle-kit push
```
## Environment variables

6. Start the development server
**Required**

```bash
bun run dev
```env
DATABASE_URL=
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=
```

Open `http://localhost:3000`.

## Environment Variables

Required for a basic local setup:

- `DATABASE_URL`
- `BETTER_AUTH_URL`
- `BETTER_AUTH_SECRET`

Optional, depending on which features you want enabled:

- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` for GitHub OAuth
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` for Google OAuth
- `GITHUB_TOKEN` for server-side GitHub API access
- `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, `SPOTIFY_REFRESH_TOKEN`, `SPOTIFY_REDIRECT_URI` for Spotify data
- `IP_INFO_TOKEN` for IP geolocation https://ipinfo.io/
- `CRON_SECRET` for protected sync endpoints
<small>💡 For automated GitHub (and Google) OAuth creation view <a target="_blank" href="https://github.com/remcostoeten/oauth-app-automator">OAuth App Automator</a></small>
**Optional** (features degrade gracefully when missing)

```env
# OAuth providers
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# Integrations
GITHUB_TOKEN= # server-side GitHub API
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REFRESH_TOKEN=
SPOTIFY_REDIRECT_URI=

# Infrastructure
IP_INFO_TOKEN= # ipinfo.io geolocation
CRON_SECRET= # protects sync endpoints
```

If an optional integration is missing, the related feature will be limited or disabled rather than preventing the whole app from running.
> Tip: automate GitHub/Google OAuth app creation with [oauth-app-automator](https://github.com/remcostoeten/oauth-app-automator).

## Architecture Notes
## Scripts

- Server code stays under `src/server` to keep runtime boundaries explicit.
- API routes are intentionally thin and delegate work to server domains or actions.
- Blog posts are read from the filesystem, parsed from frontmatter, and enriched with derived metadata like topic and reading time.
- Draft visibility is enforced in the blog layer so unpublished posts stay private to authorized users.
```bash
bun dev # development server
bun run build:next # production build
bun test # run tests
bun run check # lint + typecheck + test
bun db:generate # generate Drizzle migrations
bun db:push # push schema
bun db:studio # Drizzle Studio
bun run lint:fix # auto-fix lint errors
bun run format:fix # auto-format
```

## License

Expand Down
4 changes: 1 addition & 3 deletions __tests__/activity-section-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@ import { shouldLoadActivitySection } from '@/components/landing/activity/activit

describe('shouldLoadActivitySection', () => {
it('loads activity only when the section intersects the viewport', () => {
expect(shouldLoadActivitySection({ isIntersecting: false })).toBe(
false
)
expect(shouldLoadActivitySection({ isIntersecting: false })).toBe(false)
expect(shouldLoadActivitySection({ isIntersecting: true })).toBe(true)
})
})
Loading
Loading