npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@infuro/cms-core

v1.0.6

Published

Infuro CMS core - headless CMS library for Next.js

Readme

@infuro/cms-core

A headless CMS framework built on Next.js and TypeORM. It provides a ready-to-use admin panel, CRUD API layer, authentication, plugin system, and UI components — so you only write what's unique to your website.

Overview

What you need to set up a new site:

  • Next.js 14+ app with TypeScript and Tailwind
  • PostgreSQL and env vars: DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL
  • A few files in your app: data-source (TypeORM), auth-helpers, cms (plugins), API catch-all route, NextAuth route, admin layout + catch-all page, middleware, providers (Session + Theme + Toaster)
  • Tailwind config that includes the package in content and extends theme (shadcn-style colors)
  • Optional: custom admin nav, custom CRUD configs, custom admin pages, plugins

Install from npm:

npm install @infuro/cms-core typeorm reflect-metadata bcryptjs next-auth next-themes sonner
npm install -D @types/node

For local development with the core package in a sibling folder:

"@infuro/cms-core": "file:../core"

Then follow the setup steps below.

Architecture

core/                         # This package
├── src/
│   ├── entities/             # TypeORM entities (User, Blog, Form, etc.)
│   ├── api/                  # API handlers (CRUD, auth, CMS-specific)
│   ├── auth/                 # NextAuth config, middleware, helpers
│   ├── admin/                # Admin panel (layout, pages, page resolver)
│   ├── plugins/              # Plugin system (email, storage, analytics, ERP, etc.)
│   ├── components/           # Shared UI (shadcn/ui + Admin components)
│   ├── hooks/                # React hooks (analytics, mobile, plugin)
│   └── lib/                  # Utilities (cn, etc.)

your-website/                 # Your Next.js app
├── src/
│   ├── app/
│   │   ├── admin/            # 2 files: layout.tsx + [[...slug]]/page.tsx
│   │   └── api/
│   │       ├── auth/         # NextAuth route
│   │       └── [[...path]]/  # Single catch-all mounting core's API
│   ├── lib/
│   │   ├── data-source.ts    # TypeORM DataSource init
│   │   ├── auth-helpers.ts   # Wire core auth to NextResponse
│   │   └── cms.ts            # CmsApp init with plugins
│   ├── middleware.ts          # Wire core middleware to Next.js
│   └── ...                   # Your custom pages, components, etc.

Getting Started

1. Create a Next.js app

npx create-next-app@latest my-website --typescript --tailwind --app --src-dir
cd my-website

2. Install core

npm install @infuro/cms-core typeorm reflect-metadata bcryptjs next-auth next-themes sonner
npm install -D @types/node

Peer dependencies (Next.js app usually has these): next ≥14, react ≥18, react-dom ≥18, next-auth ^4.24. For local development, use "@infuro/cms-core": "file:../core" in package.json and run npm install.

3. Configure next.config.js

const nextConfig = {
  reactStrictMode: false,
  serverExternalPackages: ['@infuro/cms-core', 'typeorm'],
};
module.exports = nextConfig;

serverExternalPackages is required so TypeORM decorators and reflect-metadata work correctly on the server.

4. Set up the database

Create a .env file:

DATABASE_URL=postgres://user:password@localhost:5432/mydb
NEXTAUTH_SECRET=your-random-secret
NEXTAUTH_URL=http://localhost:3000

Create src/lib/data-source.ts:

import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { CMS_ENTITY_MAP } from '@infuro/cms-core';

let dataSource: DataSource | null = null;

export function getDataSource(): DataSource {
  if (!dataSource) {
    dataSource = new DataSource({
      type: 'postgres',
      url: process.env.DATABASE_URL,
      entities: Object.values(CMS_ENTITY_MAP),
      synchronize: false,
    });
  }
  return dataSource;
}

export async function getDataSourceInitialized(): Promise<DataSource> {
  const ds = getDataSource();
  if (!ds.isInitialized) await ds.initialize();
  return ds;
}

Note: synchronize: false — use TypeORM migrations (see Migrations).

5. Set up auth helpers

Create src/lib/auth-helpers.ts:

import { getServerSession } from 'next-auth';
import { NextResponse } from 'next/server';
import { createAuthHelpers } from '@infuro/cms-core/auth';

const helpers = createAuthHelpers(
  async () => {
    const s = await getServerSession();
    return s ? { user: s.user } : null;
  },
  NextResponse
);

export const requireAuth = helpers.requireAuth;
export const requirePermission = helpers.requirePermission;
export const getAuthenticatedUser = helpers.getAuthenticatedUser;

6. Set up CMS with plugins

Create src/lib/cms.ts:

import {
  createCmsApp,
  localStoragePlugin,
  type CmsApp,
} from '@infuro/cms-core';
import { getDataSourceInitialized } from './data-source';

let cmsPromise: Promise<CmsApp> | null = null;

export async function getCms(): Promise<CmsApp> {
  if (cmsPromise) return cmsPromise;
  const dataSource = await getDataSourceInitialized();
  cmsPromise = createCmsApp({
    dataSource,
    config: process.env as unknown as Record<string, string>,
    plugins: [
      localStoragePlugin({ dir: 'public/uploads' }),
      // Add more: emailPlugin({...}), analyticsPlugin({...}), etc.
    ],
  });
  return cmsPromise;
}

7. Mount the API

Create src/app/api/[[...path]]/route.ts:

import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { createCmsApiHandler } from '@infuro/cms-core/api';
import { CMS_ENTITY_MAP } from '@infuro/cms-core';
import { getDataSourceInitialized } from '@/lib/data-source';
import { requireAuth } from '@/lib/auth-helpers';
import { getCms } from '@/lib/cms';
import bcrypt from 'bcryptjs';

const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';

let handlerPromise: Promise<ReturnType<typeof createCmsApiHandler>> | null = null;

async function getHandler() {
  if (!handlerPromise) {
    const dataSource = await getDataSourceInitialized();
    handlerPromise = Promise.resolve(
      createCmsApiHandler({
        dataSource,
        entityMap: CMS_ENTITY_MAP,
        requireAuth,
        json: NextResponse.json.bind(NextResponse),
        getCms,
        userAuth: {
          dataSource,
          entityMap: CMS_ENTITY_MAP,
          json: NextResponse.json.bind(NextResponse),
          baseUrl,
          hashPassword: (p) => Promise.resolve(bcrypt.hashSync(p, 12)),
          comparePassword: (p, h) => Promise.resolve(bcrypt.compareSync(p, h)),
          resetExpiryHours: 1,
          getSession: () =>
            getServerSession().then((s) => (s ? { user: s.user } : null)),
        },
        dashboard: {
          dataSource,
          entityMap: CMS_ENTITY_MAP,
          json: NextResponse.json.bind(NextResponse),
          requireAuth,
          requirePermission: requireAuth,
        },
        upload: {
          json: NextResponse.json.bind(NextResponse),
          requireAuth,
          storage: () => getCms().then((cms) => cms.getPlugin('storage')),
          localUploadDir: 'public/uploads',
        },
        blogBySlug: {
          dataSource,
          entityMap: CMS_ENTITY_MAP,
          json: NextResponse.json.bind(NextResponse),
          requireAuth: async () => null,
        },
        formBySlug: {
          dataSource,
          entityMap: CMS_ENTITY_MAP,
          json: NextResponse.json.bind(NextResponse),
          requireAuth: async () => null,
        },
        usersApi: {
          dataSource,
          entityMap: CMS_ENTITY_MAP,
          json: NextResponse.json.bind(NextResponse),
          requireAuth,
          baseUrl,
        },
      })
    );
  }
  return handlerPromise;
}

async function handle(method: string, req: Request, context: { params: Promise<{ path?: string[] }> }) {
  try {
    const handler = await getHandler();
    const { path = [] } = await context.params;
    return handler.handle(method, path, req);
  } catch {
    return NextResponse.json({ error: 'Server Error' }, { status: 500 });
  }
}

export async function GET(req: Request, ctx: { params: Promise<{ path?: string[] }> }) { return handle('GET', req, ctx); }
export async function POST(req: Request, ctx: { params: Promise<{ path?: string[] }> }) { return handle('POST', req, ctx); }
export async function PUT(req: Request, ctx: { params: Promise<{ path?: string[] }> }) { return handle('PUT', req, ctx); }
export async function PATCH(req: Request, ctx: { params: Promise<{ path?: string[] }> }) { return handle('PATCH', req, ctx); }
export async function DELETE(req: Request, ctx: { params: Promise<{ path?: string[] }> }) { return handle('DELETE', req, ctx); }

8. Mount NextAuth

Create src/app/api/auth/[...nextauth]/route.ts:

import NextAuth from 'next-auth';
import { getNextAuthOptions } from '@infuro/cms-core/auth';
import { getDataSourceInitialized } from '@/lib/data-source';
import { CMS_ENTITY_MAP } from '@infuro/cms-core';
import bcrypt from 'bcryptjs';

async function getOptions() {
  const dataSource = await getDataSourceInitialized();
  const userRepo = dataSource.getRepository(CMS_ENTITY_MAP.users);
  return getNextAuthOptions({
    getUserByEmail: async (email: string) => {
      return userRepo.findOne({
        where: { email },
        relations: ['group', 'group.permissions'],
        select: ['id', 'email', 'name', 'password', 'blocked', 'deleted', 'groupId'],
      }) as any;
    },
    comparePassword: (plain, hash) => Promise.resolve(bcrypt.compareSync(plain, hash)),
    signInPage: '/admin/signin',
  });
}

let handler: ReturnType<typeof NextAuth> | null = null;

async function getHandler() {
  if (!handler) handler = NextAuth(await getOptions());
  return handler;
}

export async function GET(req: Request) {
  return ((await getHandler()) as any).GET(req);
}
export async function POST(req: Request) {
  return ((await getHandler()) as any).POST(req);
}

9. Mount the admin panel

Create src/app/admin/layout.tsx:

'use client';
import '@infuro/cms-core/admin.css';
import AdminLayout from '@infuro/cms-core/admin';

export default function AdminLayoutWrapper({ children }: { children: React.ReactNode }) {
  return (
    <AdminLayout
      customNavItems={[]}
      customNavSections={[]}
      customCrudConfigs={{}}
    >
      {children}
    </AdminLayout>
  );
}

Create src/app/admin/[[...slug]]/page.tsx:

import { AdminPageResolver } from '@infuro/cms-core/admin';

export default async function AdminPage({ params }: { params: Promise<{ slug?: string[] }> }) {
  const { slug } = await params;
  return <AdminPageResolver slug={slug} />;
}

The admin at /admin is rendered by core (layout, sidebar, header, built-in pages). Pass customNavSections and customCrudConfigs to add your own sidebar links and CRUD list pages (see Adding custom pages and admin nav).

10. Configure Tailwind

Core's admin components use Tailwind classes. Include the package in content so those classes aren't purged:

content: [
  "./src/**/*.{js,ts,jsx,tsx,mdx}",
  // When using from npm:
  "./node_modules/@infuro/cms-core/dist/**/*.{js,cjs}",
  // When using file:../core (local):
  // "../core/src/**/*.{js,ts,jsx,tsx}",
],

You also need the shadcn/ui color mappings in theme.extend.colors — see the Tailwind Config section below.

11. Add middleware

Create src/middleware.ts:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { createCmsMiddleware } from '@infuro/cms-core/auth';

const cmsMiddleware = createCmsMiddleware({
  // Optional: allow unauthenticated access to specific API paths/methods (e.g. public form submit)
  publicApiMethods: {
    '/api/contacts': ['POST'],
    '/api/form-submissions': ['POST'],
    '/api/blogs': ['GET'],
    '/api/forms': ['GET'],
    '/api/auth': ['GET', 'POST'],
    '/api/users/forgot-password': ['POST'],
    '/api/users/set-password': ['POST'],
    '/api/users/invite': ['POST'],
  },
});

export function middleware(request: NextRequest) {
  const result = cmsMiddleware({
    nextUrl: request.nextUrl,
    url: request.url,
    method: request.method,
    cookies: request.cookies,
  });

  if (result.type === 'next') return NextResponse.next();
  if (result.type === 'redirect') return NextResponse.redirect(result.url);
  if (result.type === 'json') return NextResponse.json(result.body, { status: result.status });
  return NextResponse.next();
}

export const config = {
  matcher: ['/admin/:path*', '/api/:path*'],
};

12. Add providers

Wrap your root layout with session and theme providers:

// src/app/providers.tsx
"use client";
import { ThemeProvider } from "next-themes";
import { SessionProvider } from "next-auth/react";
import { Toaster } from "sonner";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SessionProvider>
      <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
        {children}
        <Toaster position="top-right" />
      </ThemeProvider>
    </SessionProvider>
  );
}

Use it in src/app/layout.tsx:

import { Providers } from './providers';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

Adding custom pages and admin nav

Custom sidebar links: Pass customNavItems or customNavSections to AdminLayout in your src/app/admin/layout.tsx. Each item has href, label, and optional icon. Use href like /admin/locations so the link opens under the admin.

Custom CRUD (list + optional add/edit): Define a CustomCrudConfig (title, apiEndpoint, columns, addEditPageUrl, optional filters) and pass it as customCrudConfigs={{ myResource: config }} to AdminLayout. You must have a corresponding API (e.g. under your catch-all or a custom route) and entity. The first path segment (e.g. locations) is the key; add a nav item with href: '/admin/locations'.

Custom full pages: For a page that isn’t a CRUD list, add a Next.js route under admin, e.g. src/app/admin/reports/page.tsx, and render your component there. The admin layout wraps all /admin/* routes, so your page appears inside the same shell. Add a link in customNavItems or customNavSections with href: '/admin/reports'.

Types are exported from @infuro/cms-core/admin: CustomNavItem, CustomNavSection, CustomCrudConfig, CustomCrudColumn, etc.

Database Setup

First-time setup (quick)

For initial development, temporarily set synchronize: true in your data-source.ts, then run the seed script:

npx tsx src/lib/seed.ts

This creates all tables and inserts default data (admin user, categories, tags, forms). Switch synchronize back to false afterwards.

Migrations (production)

TypeORM CLI requires tsx and dotenv/config to load TypeScript data sources with .env support. The TYPEORM_CLI=1 env var enables the migrations path (kept off at runtime to avoid Next.js loading .ts migration files):

# Generate a migration from entity changes
TYPEORM_CLI=1 npx tsx -r dotenv/config node_modules/typeorm/cli.js migration:generate -d src/lib/data-source.ts src/migrations/MyMigration

# Run pending migrations
TYPEORM_CLI=1 npx tsx -r dotenv/config node_modules/typeorm/cli.js migration:run -d src/lib/data-source.ts

# Revert last migration
TYPEORM_CLI=1 npx tsx -r dotenv/config node_modules/typeorm/cli.js migration:revert -d src/lib/data-source.ts

Your data-source.ts should conditionally include migrations and export a default:

export function getDataSource(): DataSource {
  if (!dataSource) {
    dataSource = new DataSource({
      type: 'postgres',
      url: process.env.DATABASE_URL,
      entities: Object.values(CMS_ENTITY_MAP),
      synchronize: false,
      ...(process.env.TYPEORM_CLI && { migrations: ['src/migrations/*.ts'] }),
    });
  }
  return dataSource;
}

export default getDataSource();

Core Entities

| Entity | Table | Purpose | |--------|-------|---------| | User | users | Admin users with groups/permissions | | UserGroup | user_groups | Role-based groups | | Permission | permissions | Granular permissions | | Blog | blogs | Blog posts with slug, SEO, tags, categories | | Category | categories | Blog categories | | Tag | tags | Blog tags (many-to-many with blogs) | | Comment | comments | Blog comments | | Contact | contacts | Contact form submissions | | Form | forms | Dynamic forms | | FormField | form_fields | Form field definitions | | FormSubmission | form_submissions | Form submission data | | Seo | seos | SEO metadata | | Config | configs | Key-value configuration | | PasswordResetToken | password_reset_tokens | Password reset flow |

API Endpoints

All mounted under /api via the single catch-all route:

| Endpoint | Methods | Auth | Description | |----------|---------|------|-------------| | /api/{resource} | GET, POST | Yes | CRUD list/create for any entity in CMS_ENTITY_MAP | | /api/{resource}/{id} | GET, PUT, DELETE | Yes | CRUD get/update/delete by ID | | /api/blogs/slug/{slug} | GET | No | Public blog by slug | | /api/forms/slug/{slug} | GET | No | Public form by slug | | /api/users | GET, POST | Yes | User management | | /api/users/{id} | GET, PUT, DELETE | Yes | User by ID | | /api/users/forgot-password | POST | No | Password reset request | | /api/users/set-password | POST | No | Set new password | | /api/users/invite | POST | No | Accept invite | | /api/dashboard/stats | GET | Yes | Dashboard statistics | | /api/analytics | GET | Yes | Analytics data | | /api/upload | POST | Yes | File upload | | /api/auth/* | GET, POST | No | NextAuth routes |

Plugin System

Plugins are initialized via createCmsApp and accessed with cms.getPlugin('name').

Built-in Plugins

| Plugin | Factory | Purpose | |--------|---------|---------| | Storage (S3) | s3StoragePlugin({...}) | S3 file uploads | | Storage (Local) | localStoragePlugin({dir}) | Local file uploads | | Email | emailPlugin({type, from, ...}) | Email via SMTP/SES/Gmail | | Analytics | analyticsPlugin({...}) | Google Analytics integration | | ERP | erpPlugin({...}) | ERP/CRM integration | | SMS | smsPlugin({...}) | SMS notifications | | Payment | paymentPlugin({...}) | Payment processing |

Custom Plugins

Implement the CmsPlugin interface:

import type { CmsPlugin, PluginContext } from '@infuro/cms-core';

export const myPlugin: CmsPlugin<MyService> = {
  name: 'my-plugin',
  version: '1.0.0',
  async init(context: PluginContext) {
    return new MyService(context.config);
  },
};

Register it in cms.ts:

plugins: [
  localStoragePlugin({ dir: 'public/uploads' }),
  myPlugin,
],

Access it anywhere:

const cms = await getCms();
const service = cms.getPlugin<MyService>('my-plugin');

Package Exports

| Import Path | Contents | |-------------|----------| | @infuro/cms-core | Entities, plugins, registry, utilities | | @infuro/cms-core/api | createCmsApiHandler, CRUD handlers, auth handlers | | @infuro/cms-core/auth | createAuthHelpers, createCmsMiddleware, getNextAuthOptions | | @infuro/cms-core/admin | Admin layout, pages, components (React, 'use client') | | @infuro/cms-core/hooks | useIsMobile, useAnalytics, usePlugin |

Extending

Adding custom entities

  1. Define your TypeORM entity
  2. Add it to a merged entity map:
import { CMS_ENTITY_MAP } from '@infuro/cms-core';
import { Product } from './entities/product.entity';

const ENTITY_MAP = { ...CMS_ENTITY_MAP, products: Product };
  1. Pass the merged map to getDataSource() entities and createCmsApiHandler({ entityMap })

Adding custom API routes

Add files alongside the catch-all (e.g. src/app/api/my-custom/route.ts). Next.js resolves specific routes before the catch-all.

Customizing middleware

Pass config to createCmsMiddleware():

createCmsMiddleware({
  publicAdminPaths: ['/admin/signin', '/admin/custom-public-page'],
  publicApiMethods: {
    '/api/products': ['GET'],
  },
});

Tailwind Config

Core's admin panel and UI components use shadcn/ui which requires CSS variable-based color mappings. Your tailwind.config.js needs these in theme.extend.colors:

module.exports = {
  content: [
    "./src/**/*.{js,ts,jsx,tsx,mdx}",
    "../core/src/**/*.{js,ts,jsx,tsx}",
  ],
  darkMode: "class",
  theme: {
    extend: {
      colors: {
        background: "hsl(var(--background))",
        foreground: "hsl(var(--foreground))",
        card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
        popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
        primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
        secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
        muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
        accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
        destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
        border: "hsl(var(--border))",
        input: "hsl(var(--input))",
        ring: "hsl(var(--ring))",
        sidebar: {
          DEFAULT: "hsl(var(--sidebar-background))",
          foreground: "hsl(var(--sidebar-foreground))",
          primary: "hsl(var(--sidebar-primary))",
          "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
          accent: "hsl(var(--sidebar-accent))",
          "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
          border: "hsl(var(--sidebar-border))",
          ring: "hsl(var(--sidebar-ring))",
        },
      },
      borderRadius: {
        lg: "var(--radius)",
        md: "calc(var(--radius) - 2px)",
        sm: "calc(var(--radius) - 4px)",
      },
    },
  },
  plugins: [require("tailwindcss-animate")],
};

The CSS variables themselves are injected by core's AdminLayout at runtime. Your website's own CSS can also define them in :root if your public pages use shadcn/ui components.

Environment Variables

| Variable | Required | Description | |----------|----------|-------------| | DATABASE_URL | Yes | PostgreSQL connection string | | NEXTAUTH_SECRET | Yes | NextAuth JWT secret | | NEXTAUTH_URL | Yes | App base URL | | STORAGE_TYPE | No | s3 or local (default: local) | | AWS_BUCKET_NAME | If S3 | S3 bucket name | | AWS_REGION | If S3/SES | AWS region | | AWS_ACCESS_KEY_ID | If S3/SES | AWS access key | | AWS_SECRET_ACCESS_KEY | If S3/SES | AWS secret key | | SMTP_TYPE | No | SMTP, AWS, or GMAIL | | SMTP_FROM | If email | Sender email | | SMTP_TO | If email | Default recipient | | SMTP_USER | If SMTP | SMTP username | | SMTP_PASSWORD | If SMTP | SMTP password | | GOOGLE_ANALYTICS_PRIVATE_KEY | If analytics | GA service account key | | GOOGLE_ANALYTICS_CLIENT_EMAIL | If analytics | GA service account email | | GOOGLE_ANALYTICS_VIEW_ID | If analytics | GA property/view ID |

Development

Quick start (existing website using core)

# 1. Build core (once, or use watch mode)
cd core
npm run build

# 2. Install website dependencies (links core via file:../core)
cd ../my-website
npm install

# 3. Set up .env (DATABASE_URL, NEXTAUTH_SECRET, NEXTAUTH_URL)

# 4. Create tables & seed (set synchronize: true in data-source.ts first)
npx tsx src/lib/seed.ts
# Then set synchronize back to false

# 5. Start dev server
npm run dev

Watch mode (developing core + website simultaneously)

Terminal 1:

cd core && npm run dev

Terminal 2:

cd my-website && npm run dev

Changes to core are picked up automatically by the website's dev server.