Content management system for the Nitriansky technologický inkubátor
Manage localized landing-page content, marketing pages, news articles, and media used by the NTI frontend.
NTI CMS is the content-management service for the public website of the Nitriansky technologický inkubátor.
It gives authorized editors a Payload admin panel for managing:
- the NTI landing page;
- the About, Partners, and Mentors pages;
- localized news articles;
- images and other website media;
- English and Slovak versions of public content.
The CMS is built with Payload 3 inside a Next.js application. It stores structured content in PostgreSQL, exposes REST and GraphQL APIs, and can store uploaded media locally or in Cloudflare R2.
The main consumer of this service is the NTI_frontend application.
- Secure Payload admin panel
- Authentication for CMS users
- Structured marketing-page content
- Localized English and Slovak fields
- Draft and published states for news
- Automatic news slug generation
- Rich-text article content
- Media uploads with generated image sizes
- Seed data for all marketing globals
- REST and GraphQL APIs
- PostgreSQL through the Payload Vercel Postgres adapter
- Local media storage during development
- Optional Cloudflare R2 media storage
- Configurable frontend and CORS origins
- Docker Compose setup for local development
- Strict TypeScript
- ESLint and Prettier
- Pre-commit validation with simple-git-hooks
- Vitest integration tests
- Playwright end-to-end tests
- Generated Payload TypeScript types
- Generated Payload admin import map
| Technology | Purpose |
|---|---|
| Payload CMS 3.84 | Admin panel, collections, globals, authentication, REST and GraphQL APIs |
| Next.js 16 | Application runtime and Payload integration |
| React 19 | Admin and application UI |
| TypeScript | Strict static typing |
| PostgreSQL | Persistent structured-content database |
| Lexical | Rich-text editing |
| Sharp | Image processing and generated image sizes |
| Cloudflare R2 | Optional S3-compatible media storage |
| Vitest | Integration testing |
| Playwright | Admin-panel end-to-end testing |
| Docker Compose | Reproducible local CMS and PostgreSQL environment |
flowchart LR
E[Content editor] --> A[Payload Admin]
A --> P[Payload CMS]
P --> DB[(PostgreSQL)]
P --> L[Local media storage]
P --> R2[Cloudflare R2]
F[NTI Frontend] --> REST[Payload REST API]
F --> GQL[Payload GraphQL API]
REST --> P
GQL --> P
The application uses a single Payload configuration as the source of truth for:
- collections;
- globals;
- localization;
- access control;
- database connectivity;
- media processing;
- optional R2 storage;
- generated TypeScript definitions.
Authentication-enabled collection for CMS administrators and content editors.
| Property | Description |
|---|---|
| Authentication | Enabled |
| Admin title | User email |
| Admin access | Authenticated CMS users |
Payload adds the core authentication fields automatically.
Upload-enabled collection for public website images.
| Field | Type | Localized | Required |
|---|---|---|---|
alt |
Text | Yes | Yes |
caption |
Textarea | Yes | No |
Public read access is enabled so the frontend can display uploaded media.
Generated image sizes:
| Size | Dimensions | Usage |
|---|---|---|
card |
640 × 480 | Cards, listings, and admin thumbnails |
hero |
1440 × 1440 | Large marketing and hero images |
Accepted MIME types:
image/jpeg
image/png
image/webp
image/svg+xml
Localized news and article collection.
| Field | Type | Localized | Notes |
|---|---|---|---|
title |
Text | Yes | Required |
slug |
Text | No | Required, unique, indexed |
status |
Select | No | draft or published |
publishedAt |
Date | No | Used for display and ordering |
category |
Text | Yes | Optional short category label |
author |
Text | Yes | Optional |
coverImage |
Media relation | No | Optional |
excerpt |
Textarea | Yes | Listing and preview summary |
content |
Rich text | Yes | Full article body |
News access rules:
- authenticated CMS users can read all articles;
- public API clients can read only articles whose status is
published; - slugs are normalized and can be generated automatically from the title.
Globals represent single editable page documents rather than lists of records.
All marketing globals:
- are grouped under Website in the Payload admin panel;
- allow public read access;
- contain localized English and Slovak fields;
- can be populated through the seed script.
Content for the main NTI landing page:
- hero section;
- Program A and Program B cards;
- infrastructure section;
- ecosystem and mentor highlights;
- success metric;
- final call to action.
Optional CMS images can be left empty because the frontend provides built-in fallback images.
Content for the About page:
- hero;
- “what NTI is” section;
- feature list;
- values;
- call to action.
Content for the Partners page:
- hero;
- reasons to partner with NTI;
- cooperation formats;
- partner labels/logos;
- call to action.
Content for the Mentors page:
- hero;
- mentoring value proposition;
- feature list;
- mentor profiles;
- call to action.
Mentor photos are optional. The frontend displays a default portrait when no CMS image is assigned.
The CMS currently supports:
| Code | Language |
|---|---|
en |
English |
sk |
Slovak |
Configuration:
- default locale:
en; - fallback behavior: enabled;
- text, textarea, rich-text, alt-text, captions, and other editorial fields are localized where appropriate.
Example REST requests:
GET /api/globals/landing-page?locale=en
GET /api/globals/landing-page?locale=sk
GET /api/news?locale=en
GET /api/news?locale=sk
When adding new fields, decide explicitly whether their values should be localized.
NTI_cms/
├── src/
│ ├── app/
│ │ └── (payload)/ # Payload admin, REST, and GraphQL routes
│ ├── collections/
│ │ ├── Media.ts # Public media library and image sizes
│ │ ├── News.ts # Localized news collection
│ │ └── Users.ts # CMS authentication
│ ├── globals/
│ │ ├── About.ts # About-page content
│ │ ├── LandingPage.ts # Main landing-page content
│ │ ├── Mentors.ts # Mentors-page content
│ │ ├── Partners.ts # Partners-page content
│ │ └── marketingFields.ts # Shared marketing field helpers
│ ├── lib/
│ │ ├── aboutSeed.ts # About-page seed content
│ │ ├── landingPageSeed.ts # Landing-page seed content
│ │ ├── mentorsSeed.ts # Mentors-page seed content
│ │ └── partnersSeed.ts # Partners-page seed content
│ ├── scripts/
│ │ └── seed.ts # Localized global seeding
│ ├── payload.config.ts # Main Payload configuration
│ └── payload-types.ts # Generated Payload TypeScript types
├── tests/
│ ├── e2e/ # Playwright admin-panel tests
│ ├── helpers/ # Test-user and login helpers
│ └── int/ # Vitest API integration tests
├── .env.example
├── docker-compose.yml
├── Dockerfile
├── next.config.ts
├── package.json
├── playwright.config.ts
├── tsconfig.json
└── vitest.config.mts
- Register new collections and globals in
src/payload.config.ts. - Keep reusable field definitions in shared helper files.
- Do not edit Payload-generated route files manually.
- Regenerate Payload types after changing collections or globals.
- Regenerate the admin import map after adding or changing custom admin components.
- Use the
@/*alias for imports fromsrc. - Use
@payload-configwhen a direct Payload configuration import is required.
- Node.js
^18.20.2or>=20.9.0 - pnpm
9or10 - A reachable PostgreSQL database
- Cloudflare R2 credentials only when remote media storage is required
Node.js 20 or newer is recommended for consistency with the Docker Compose environment.
Check installed versions:
node --version
pnpm --versionEnable pnpm through Corepack when needed:
corepack enable
corepack prepare pnpm@latest --activategit clone https://github.com/YDVPWebDevTeam/NTI_cms.git
cd NTI_cmspnpm installThe installation runs the prepare script and configures the repository's Git hooks.
macOS or Linux:
cp .env.example .envWindows PowerShell:
Copy-Item .env.example .envAt minimum, set:
POSTGRES_URL=postgresql://user:password@localhost:5432/nti_cms
PAYLOAD_SECRET=replace-with-a-long-random-secret
FRONTEND_URL=http://localhost:3000
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000Generate a strong secret, for example:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"Do not commit the generated secret.
pnpm devThe development server runs on:
http://localhost:3002
Payload admin panel:
http://localhost:3002/admin
On the first run, create the initial CMS administrator through the admin panel.
After the database is available:
pnpm seedThe script updates these globals in both English and Slovak:
landing-page
partners
about
mentors
The seeding implementation preserves Payload array-row identifiers between locales so localized values remain attached to the same rows.
| Variable | Required | Description |
|---|---|---|
POSTGRES_URL |
Yes | PostgreSQL connection string |
PAYLOAD_SECRET |
Yes | Long secret used by Payload for authentication and cryptographic operations |
FRONTEND_URL |
Recommended | Main frontend origin allowed to access the CMS |
CORS_ORIGINS |
No | Comma-separated list of additional allowed origins |
R2_ENDPOINT |
For R2 | Cloudflare R2 S3-compatible endpoint |
R2_BUCKET_NAME |
For R2 | Media bucket name |
R2_ACCESS_KEY_ID |
For R2 | R2 access-key identifier |
R2_SECRET_ACCESS_KEY |
For R2 | R2 secret access key |
R2_REGION |
No | R2 region, defaults to auto |
R2_PUBLIC_BASE_URL |
For R2 | Public base URL used to construct media URLs |
PLAYWRIGHT_BASE_URL |
No | CMS URL used by Playwright, defaults to http://localhost:3002 |
Outside production, the CMS automatically allows:
http://localhost:3000
http://127.0.0.1:3000
http://localhost:3002
http://127.0.0.1:3002
Production deployments should explicitly configure FRONTEND_URL and/or CORS_ORIGINS.
R2 storage is optional.
The plugin is enabled only when all required values are present:
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_BUCKET_NAME=nti-media
R2_ACCESS_KEY_ID=replace-me
R2_SECRET_ACCESS_KEY=replace-me
R2_REGION=auto
R2_PUBLIC_BASE_URL=https://pub-xxxxxxxx.r2.devWhen R2 is fully configured:
- uploads are stored in the configured bucket;
- local file storage is disabled;
- public media URLs are generated from
R2_PUBLIC_BASE_URL; - generated image variants use their own generated filenames.
When the configuration is incomplete, the R2 plugin remains disabled and Payload uses local storage.
Do not commit real R2 credentials.
The provided docker-compose.yml starts:
- the Payload application on port
3002; - PostgreSQL 16 on port
5432; - persistent PostgreSQL and
node_modulesvolumes.
For Docker Compose, use the PostgreSQL service hostname:
POSTGRES_URL=postgresql://postgres:postgres@postgres:5432/payload
PAYLOAD_SECRET=replace-with-a-long-random-secret
FRONTEND_URL=http://localhost:3000
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000Start the environment:
docker compose upRun it in the background:
docker compose up -dSeed content:
docker compose exec payload pnpm seedView logs:
docker compose logs -f payloadStop the environment:
docker compose downRemove the database volume as well:
docker compose down -vRemoving volumes permanently deletes the local Docker database.
When the CMS runs directly on your computer but only PostgreSQL runs in Docker, use localhost rather than the Docker service name:
POSTGRES_URL=postgresql://postgres:postgres@localhost:5432/payloadPayload exposes both REST and GraphQL APIs.
Base URL during local development:
http://localhost:3002/api
Examples:
GET http://localhost:3002/api/globals/landing-page?locale=en
GET http://localhost:3002/api/globals/about?locale=sk
GET http://localhost:3002/api/globals/partners?locale=en
GET http://localhost:3002/api/globals/mentors?locale=sk
GET http://localhost:3002/api/news?locale=en
GET http://localhost:3002/api/media
Published English news example:
GET http://localhost:3002/api/news?locale=en&where[status][equals]=published
Single article by slug:
GET http://localhost:3002/api/news?locale=en&where[slug][equals]=article-slug
The public API cannot retrieve draft news articles. An authenticated CMS user can access both draft and published records.
Endpoint:
POST http://localhost:3002/api/graphql
Example query:
query PublishedNews {
News(where: { status: { equals: published } }) {
docs {
id
title
slug
excerpt
publishedAt
}
}
}Payload-generated API route files are located under src/app/(payload)/api and should not be edited manually.
The NTI frontend should point its CMS URL to this service:
NEXT_PUBLIC_CMS_URL=http://localhost:3002Typical local ports:
| Service | URL |
|---|---|
| NTI frontend | http://localhost:3000 |
| NTI backend API | http://localhost:3001 |
| NTI CMS | http://localhost:3002 |
| Payload admin | http://localhost:3002/admin |
The CMS intentionally allows public reads for website globals, media, and published news. Write operations and draft access remain protected by Payload authentication.
When changing a field used by the frontend:
- update the Payload collection or global;
- regenerate Payload types;
- update seed data where necessary;
- update the frontend CMS type or normalizer;
- verify both English and Slovak responses;
- test static fallback behavior in the frontend.
| Command | Description |
|---|---|
pnpm dev |
Start the development server on port 3002 |
pnpm build |
Create a production Next.js build |
pnpm start |
Start the production server on port 3002 |
pnpm lint |
Run ESLint across the project |
pnpm typescript |
Run TypeScript checking without emitting files |
pnpm payload |
Run the Payload CLI |
pnpm generate:types |
Regenerate src/payload-types.ts |
pnpm generate:importmap |
Regenerate the Payload admin import map |
pnpm seed |
Seed English and Slovak marketing content |
pnpm test:int |
Run Vitest integration tests |
pnpm test:e2e |
Run Playwright end-to-end tests |
pnpm test |
Run integration and end-to-end tests |
pnpm lint:staged |
Lint staged JavaScript and TypeScript files |
pnpm prepare |
Install the simple-git-hooks hooks |
- Create a collection file under
src/collections. - Export a
CollectionConfig. - Register it in
collectionsinsidesrc/payload.config.ts. - Generate updated types.
- Update access rules and tests.
- Update the frontend integration when the collection is public.
Example:
import type { CollectionConfig } from 'payload'
export const Events: CollectionConfig = {
slug: 'events',
access: {
read: () => true,
},
fields: [
{
name: 'title',
type: 'text',
localized: true,
required: true,
},
],
}Then register it:
collections: [Users, Media, News, Events],Generate types:
pnpm generate:types- Create a
GlobalConfigundersrc/globals. - Register it in the
globalsarray. - Add seed data when starter content is required.
- Generate types.
- Add frontend loading and fallback behavior.
Run:
pnpm generate:types
pnpm lint
pnpm typescript
pnpm test:int
pnpm buildGenerated file:
src/payload-types.ts
Regenerate it after changing:
- collections;
- globals;
- fields;
- relationships;
- localization settings.
Command:
pnpm generate:typesPayload uses a generated import map for custom admin components.
Command:
pnpm generate:importmapDo not manually edit generated import-map files.
Vitest integration tests run from:
tests/int/**/*.int.spec.ts
Current coverage verifies that Payload can:
- query users;
- read the landing-page global;
- list news articles.
Run:
pnpm test:intPlaywright tests exercise the Payload admin panel in Chromium.
Current coverage verifies that an authenticated test user can:
- open the dashboard;
- open the users collection;
- open a user edit view.
Install the Playwright browser on a new machine:
pnpm exec playwright install chromiumRun:
pnpm test:e2ePlaywright starts pnpm dev automatically unless a compatible server is already running.
pnpm testUse a dedicated development or test database. End-to-end tests create and remove a test user.
The repository uses simple-git-hooks.
Before each commit, the configured hook runs:
lint-staged
TypeScript validation
Staged JavaScript and TypeScript files are automatically checked with ESLint and --fix.
Recommended manual validation before opening a pull request:
pnpm lint
pnpm typescript
pnpm test:int
pnpm buildFor changes affecting admin behavior, also run:
pnpm test:e2eRecommended commit style:
feat(cms): add events collection
fix(news): restrict public access to published articles
content(seed): update Slovak mentors content
docs(readme): document CMS setup
Create a production build:
pnpm buildStart the CMS:
pnpm startThe production server listens on port 3002.
Production should provide:
NODE_ENV=production
POSTGRES_URL=postgresql://...
PAYLOAD_SECRET=...
FRONTEND_URL=https://frontend.example.com
CORS_ORIGINS=https://frontend.example.comAdd all R2 variables when production media should be stored remotely.
- use a strong, unique
PAYLOAD_SECRET; - connect to the intended production PostgreSQL database;
- configure the exact production frontend origin;
- configure persistent media storage;
- verify the public R2 URL;
- create a CMS administrator securely;
- seed or enter both English and Slovak content;
- verify public news access;
- verify draft articles remain private;
- run lint, TypeScript, tests, and a production build;
- back up the database and uploaded media.
The repository also contains a multi-stage production Dockerfile. It expects Next.js standalone output, while the current next.config.ts does not enable output: 'standalone'. Its exposed port is also 3000, whereas the project scripts use 3002.
Use docker-compose.yml for local development. Before relying on the standalone Dockerfile in production, align the Next.js output configuration and runtime port.
Check:
POSTGRES_URLis present;- the hostname is correct for the execution environment;
- PostgreSQL is running;
- the database exists;
- the credentials are correct;
- required SSL options are included for hosted databases.
Use postgres as the hostname inside Docker Compose and localhost when connecting from the host machine.
Set a non-empty secret:
PAYLOAD_SECRET=your-long-random-secretRestart the CMS after changing it.
Check:
- the CMS is running on port
3002; NEXT_PUBLIC_CMS_URLis correct in the frontend;- the frontend origin appears in
FRONTEND_URLorCORS_ORIGINS; - the requested content has been seeded or entered;
- the requested locale is
enorsk; - a news article is marked
published.
Local media files require persistent filesystem storage.
For production, configure all Cloudflare R2 variables or mount a persistent media volume. R2 remains disabled when even one required value is missing.
Verify:
R2_PUBLIC_BASE_URLis publicly reachable;- the value does not contain an incorrect path;
- the bucket or custom domain allows public reads;
- CORS is configured for the frontend when required;
- all R2 variables were available when the CMS started.
Run:
pnpm generate:typesThen restart the TypeScript server or development server.
Run:
pnpm generate:importmapRestart the development server afterward.
Install it:
pnpm exec playwright install chromiumWindows PowerShell:
Get-NetTCPConnection -LocalPort 3002macOS or Linux:
lsof -i :3002Stop the conflicting process or change the development/start scripts consistently.
- Never commit
.envor real database and R2 credentials. - Treat
PAYLOAD_SECRETas a production secret. - Keep public access rules limited to content intentionally consumed by the public frontend.
- Draft news content must remain unavailable to unauthenticated requests.
- Review collection access whenever adding new content models.
- Restrict production CORS origins to trusted applications.
- Use separate development, test, staging, and production databases.
- Keep Payload and its adapters on compatible versions.
- Back up PostgreSQL and media storage before destructive schema or content changes.
NTI_frontend— public website and role-based application frontendNTI_backend— authentication, program workflows, applications, teams, organizations, and platform business logic
The package metadata declares the project under the MIT License.
The repository currently does not include a standalone LICENSE file. Add one to make the licensing terms explicit for contributors and external users.
Built for the Nitriansky technologický inkubátor innovation ecosystem.