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
36 changes: 36 additions & 0 deletions solutions/platforms-blog-neon/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# --- Neon (from the Neon Console for your project) ---

# Neon Auth service URL.
# Neon Console -> your project -> Auth -> Configuration -> copy the URL.
# Example: https://ep-cool-name-123456.neonauth.c-12.us-east-1.aws.neon.tech/neondb/auth
NEXT_PUBLIC_NEON_AUTH_URL=

# Neon Data API URL (PostgREST-compatible). Powers the signed-in dashboard.
# Neon Console -> your project -> Postgres database -> Data API -> copy the API URL.
# Example: https://ep-cool-name-123456.apirest.c-12.us-east-1.aws.neon.tech/neondb/rest/v1
NEXT_PUBLIC_NEON_DATA_API_URL=

# Direct Postgres connection string. Used on the server to render public tenant
# blogs, and to apply db/schema.sql. Never exposed to the browser.
DATABASE_URL=

# JWKS endpoint for verifying Neon Auth JWTs on the server (the image upload
# route checks the caller's token against this).
# Usually your auth URL + /.well-known/jwks.json
NEON_JWKS_URL=

# --- Neon Object Storage (S3-compatible) for cover-image uploads ---
# Neon Console -> your project -> Object Storage. Server-only credentials.
AWS_ENDPOINT_URL_S3=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=us-east-2
AWS_S3_BUCKET=

# --- App ---

# The root domain tenants live under. Locally, tenant subdomains resolve
# automatically (e.g. acme.localhost:3000). In production, set this to your
# domain (e.g. yourapp.com) and point a wildcard DNS record (*.yourapp.com) at
# Vercel.
NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000
4 changes: 4 additions & 0 deletions solutions/platforms-blog-neon/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"root": true,
"extends": "next/core-web-vitals"
}
44 changes: 44 additions & 0 deletions solutions/platforms-blog-neon/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# Dependencies
/node_modules
/.pnp
.pnp.js

# Testing
/coverage

# Next.js
/.next/
/out/
next-env.d.ts

# Production
build
dist

# Misc
.DS_Store
*.pem

# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Local ENV files
.env.local
.env.development.local
.env.test.local
.env.production.local

# Vercel
.vercel

# Turborepo
.turbo

# typescript
*.tsbuildinfo
.env*
!.env.example
88 changes: 88 additions & 0 deletions solutions/platforms-blog-neon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
name: Multi-tenant blog platform with Neon
slug: platforms-blog-neon
description: A Platforms-style multi-tenant blog where each tenant gets a subdomain, powered end to end by Neon Postgres, Auth, Data API, and Object Storage.
framework: Next.js
useCase: Starter
css: Tailwind
deployUrl: https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/solutions/platforms-blog-neon&project-name=platforms-blog-neon&repo-name=platforms-blog-neon&env=NEXT_PUBLIC_NEON_AUTH_URL,NEXT_PUBLIC_NEON_DATA_API_URL,DATABASE_URL,NEON_JWKS_URL,NEXT_PUBLIC_ROOT_DOMAIN,AWS_ENDPOINT_URL_S3,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_S3_BUCKET
demoUrl: https://platforms-blog-neon.vercel.app
relatedTemplates:
- platforms-starter-kit
- domains-api
---

# Multi-tenant blog platform with Neon

A [Platforms](https://vercel.com/docs/platforms)-style, multi-tenant app with [Neon](https://neon.com) powering the entire backend. A user signs up, creates a site, and that site gets its own subdomain (`acme.yourapp.com`) serving a public blog. It runs entirely on Neon and Vercel:

- **Serverless Postgres** stores the sites and posts.
- **Neon Auth** handles sign in, sign up, and sessions.
- **Data API** is the PostgREST-compatible query layer the dashboard uses.
- **Neon Object Storage** holds post cover images over an S3-compatible API.
- **Vercel** as the deployment and hosting platform.

## Demo

[https://platforms-blog-neon.vercel.app/](https://platforms-blog-neon.vercel.app/)

## Environment variables

Copy `.env.example` to `.env.local` and set these (all come from the Neon
Console):

| Variable | Scope | Purpose |
| -------------------------------------------- | ------- | -------------------------------------------------------------------------------------- |
| `NEXT_PUBLIC_NEON_AUTH_URL` | browser | Neon Auth service URL |
| `NEXT_PUBLIC_NEON_DATA_API_URL` | browser | Data API (PostgREST) URL for the dashboard |
| `NEXT_PUBLIC_ROOT_DOMAIN` | browser | Domain tenants live under (`localhost:3000` locally, your domain in production) |
| `DATABASE_URL` | server | Direct connection, used to render public blogs and to apply `db/schema.sql` |
| `NEON_JWKS_URL` | server | Verifies the JWT on image uploads (usually the auth URL plus `/.well-known/jwks.json`) |
| `AWS_ENDPOINT_URL_S3` | server | Neon Object Storage endpoint |
| `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | server | Object Storage credentials |
| `AWS_REGION` | server | Object Storage region, e.g. `us-east-2` |
| `AWS_S3_BUCKET` | server | Bucket that holds cover images |

## Deploy your own

### One-Click Deploy

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/solutions/platforms-blog-neon&project-name=platforms-blog-neon&repo-name=platforms-blog-neon&env=NEXT_PUBLIC_NEON_AUTH_URL,NEXT_PUBLIC_NEON_DATA_API_URL,DATABASE_URL,NEON_JWKS_URL,NEXT_PUBLIC_ROOT_DOMAIN,AWS_ENDPOINT_URL_S3,AWS_ACCESS_KEY_ID,AWS_SECRET_ACCESS_KEY,AWS_REGION,AWS_S3_BUCKET)

Point a wildcard DNS record (`*.yourapp.com`) at Vercel so tenant subdomains
resolve.

### Clone and run locally

```bash
pnpm create next-app --example https://github.com/vercel/examples/tree/main/solutions/platforms-blog-neon platforms-blog-neon
```

1. In the [Neon Console](https://console.neon.tech), create a project and enable **Neon Auth**, the **Data API**, and **Object Storage** (create a bucket).
2. Copy `.env.example` to `.env.local` and fill in the values (all are shown in the Neon Console). Locally, leave `NEXT_PUBLIC_ROOT_DOMAIN=localhost:3000` so tenant subdomains like `acme.localhost:3000` resolve automatically.
3. Apply the schema once:

```bash
psql "$DATABASE_URL" -f db/schema.sql
```

(or paste [`db/schema.sql`](./db/schema.sql) into the Neon SQL Editor).

4. Start the app and open http://localhost:3000:

```bash
pnpm dev
```

Create a site, write a post, publish it, then visit `http://<subdomain>.localhost:3000`.

## A branch per preview deployment

Connect your project with the [Neon integration on Vercel](https://neon.com/docs/guides/vercel-overview) and every preview deployment is wired to its own **Neon branch**, a copy-on-write clone of the database, its auth and storage, so each pull request gets an isolated backend to test against.

## Built on open source

- [Next.js](https://nextjs.org) (App Router) as the React framework
- [Tailwind](https://tailwindcss.com/) for CSS styling
- [Neon](https://neon.com) for Postgres, Auth, the Data API, and Object Storage
- [Vercel](http://vercel.com/) for deployment
30 changes: 30 additions & 0 deletions solutions/platforms-blog-neon/app/api/file/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { getUserIdFromRequest } from '@/lib/auth-server'
import { isPublishedImageKey } from '@/lib/db'
import { isRemoteUrl, isUserObjectKey } from '@/lib/images'
import { presignGet } from '@/lib/storage'

// Serves a stored image via a short-lived presigned URL.
// Published covers/author photos: allowed after a DB lookup (owner RLS only
// sees published posts). Drafts: allowed only if the JWT `sub` matches the
// `posts/<userId>/` prefix written at upload time. Unknown or remote keys 404.
export async function GET(request: Request) {
const url = new URL(request.url)
const key = url.searchParams.get('key')
if (!key || isRemoteUrl(key) || key.includes('..') || key.startsWith('/')) {
return NextResponse.json({ error: 'not found' }, { status: 404 })
}
const published = await isPublishedImageKey(key)
if (!published) {
const userId = await getUserIdFromRequest(request)
if (!userId || !isUserObjectKey(key, userId))
return NextResponse.json({ error: 'not found' }, { status: 404 })
}
const signed = await presignGet(key)
if (url.searchParams.get('format') === 'json')
return NextResponse.json({ url: signed })
return NextResponse.redirect(signed, {
status: 302,
headers: { 'cache-control': 'private, max-age=1800' },
})
}
35 changes: 35 additions & 0 deletions solutions/platforms-blog-neon/app/api/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { getUserIdFromRequest } from '@/lib/auth-server'
import { putObject } from '@/lib/storage'

const MAX_BYTES = 5 * 1024 * 1024 // 5 MB
const EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
}

export async function POST(request: Request) {
// Only signed-in users may upload. The client sends its Neon Auth JWT, which
// we verify against the project's JWKS.
const userId = await getUserIdFromRequest(request)
if (!userId)
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const form = await request.formData()
const file = form.get('file')
if (!(file instanceof File))
return NextResponse.json({ error: 'no file' }, { status: 400 })
const ext = EXT[file.type]
if (!ext)
return NextResponse.json(
{ error: 'unsupported image type' },
{ status: 400 }
)
if (file.size > MAX_BYTES)
return NextResponse.json({ error: 'file too large' }, { status: 413 })
// Namespaced by user id so keys never collide across tenants.
const key = `posts/${userId}/${crypto.randomUUID()}.${ext}`
await putObject(key, await file.arrayBuffer(), file.type)
return NextResponse.json({ key })
}
98 changes: 98 additions & 0 deletions solutions/platforms-blog-neon/app/dashboard/[subdomain]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use client'

import Link from 'next/link'
import { useParams, useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { Button, Page, Text, Link as UILink } from '@vercel/examples-ui'
import { AuthorManager } from '@/components/author-manager'
import { PostManager } from '@/components/post-manager'
import { neon, type Author, type Site } from '@/lib/neon'
import { useSession } from '@/lib/session'

const ROOT_DOMAIN = process.env.NEXT_PUBLIC_ROOT_DOMAIN ?? 'localhost:3000'

export default function ManageSitePage() {
const router = useRouter()
const params = useParams<{ subdomain: string }>()
const { user, loading } = useSession()
const [site, setSite] = useState<Site | null>(null)
const [authors, setAuthors] = useState<Author[]>([])
const [notFound, setNotFound] = useState(false)

const loadSite = useCallback(async () => {
// RLS only returns the site if the signed-in user owns it.
const { data } = await neon
.from('sites')
.select('*')
.eq('subdomain', params.subdomain)
.limit(1)
const found = (data as Site[])?.[0] ?? null
setSite(found)
setNotFound(!found)
if (!found) return
const { data: authorRows } = await neon
.from('authors')
.select('*')
.eq('site_id', found.id)
.order('created_at', { ascending: true })
setAuthors((authorRows as Author[]) ?? [])
}, [params.subdomain])

useEffect(() => {
if (!loading && !user) router.replace('/login')
}, [loading, user, router])

useEffect(() => {
if (user) loadSite()
}, [user, loadSite])

if (loading || !user) {
return (
<Page>
<Text>Loading…</Text>
</Page>
)
}

if (notFound) {
return (
<Page className="flex flex-col gap-4">
<Text variant="h1">Site not found</Text>
<Text>You do not have a site with that subdomain.</Text>
<Link href="/dashboard">
<Button variant="secondary">Back to your sites</Button>
</Link>
</Page>
)
}

if (!site) {
return (
<Page>
<Text>Loading…</Text>
</Page>
)
}

return (
<Page className="flex flex-col gap-8">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<Text variant="h1">{site.name}</Text>
<UILink
href={`http://${site.subdomain}.${ROOT_DOMAIN}`}
target="_blank"
>
{site.subdomain}.{ROOT_DOMAIN} ↗
</UILink>
</div>
<Link href="/dashboard">
<Button variant="secondary">All sites</Button>
</Link>
</div>

<AuthorManager site={site} authors={authors} onChange={loadSite} />
<PostManager site={site} authors={authors} />
</Page>
)
}
46 changes: 46 additions & 0 deletions solutions/platforms-blog-neon/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
'use client'

import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { Button, Page, Text } from '@vercel/examples-ui'
import { SiteList } from '@/components/site-list'
import { useSession } from '@/lib/session'

export default function DashboardPage() {
const router = useRouter()
const { user, loading, signOut } = useSession()

useEffect(() => {
if (!loading && !user) router.replace('/login')
}, [loading, user, router])

if (loading || !user) {
return (
<Page>
<Text>Loading…</Text>
</Page>
)
}

return (
<Page className="flex flex-col gap-8">
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col">
<Text variant="h1">Your sites</Text>
<Text className="text-gray-500">Signed in as {user.email}</Text>
</div>
<Button
variant="secondary"
onClick={async () => {
await signOut()
router.replace('/login')
}}
>
Sign out
</Button>
</div>

<SiteList />
</Page>
)
}
Loading