Documentation

Everything you need to build with Shipr.

Getting Started

Stack

Layer Tech
Framework Next.js 16 (App Router)
Auth Clerk
Database Convex
Styling Tailwind CSS 4 + shadcn/ui + Base UI
Analytics PostHog (reverse-proxied)
Error Tracking Sentry
Fonts Geist Sans / Mono / Pixel Square
Deployment Vercel

Prerequisites

  • Node.js 18+
  • pnpm
  • A Clerk account
  • A Convex project
  • (Optional) PostHog & Sentry accounts

Setup

git clone <repo-url> && cd shipr
pnpm install
cp .env.example .env

Fill in your .env values, then:

npx convex dev   # start Convex dev server
pnpm dev         # start Next.js dev server

Open http://localhost:3000.

Project Structure

src/
├── app/
│   ├── (auth)/          # Sign-in & sign-up pages (Clerk)
│   ├── (dashboard)/     # Protected dashboard
│   ├── (legal)/         # Privacy, terms, cookies
│   ├── (marketing)/     # Landing, features, pricing, about, docs, blog
│   │   └── blog/        # Blog index + [slug] detail pages
│   ├── api/
│   │   ├── chat/        # Vercel AI SDK chat endpoint
│   │   ├── email/       # Send transactional emails via Resend
│   │   └── health/      # Health check endpoint (rate-limited)
│   ├── onboarding/      # Multi-step onboarding flow for new users
│   ├── layout.tsx       # Root layout (providers, metadata, fonts)
│   ├── not-found.tsx    # Custom 404 page
│   ├── error.tsx        # App-level error boundary
│   ├── global-error.tsx # Root error boundary
│   ├── robots.ts        # Robots.txt generation
│   └── sitemap.ts       # Sitemap generation
├── components/
│   ├── ui/              # shadcn/ui primitives
│   ├── billing/         # Upgrade button, plan gating
│   ├── dashboard/       # Dashboard shell, sidebar, top nav
│   ├── posthog-*.tsx    # PostHog provider, pageview, identify
│   ├── theme-toggle.tsx # Light/dark/system theme switcher
│   ├── header.tsx       # Marketing header
│   └── footer-1.tsx     # Marketing footer (includes theme toggle)
├── hooks/
│   ├── use-mobile.ts      # Responsive breakpoint hook
│   ├── use-onboarding.ts  # Check onboarding status & redirect logic
│   ├── use-sync-user.ts   # Syncs Clerk user to Convex
│   └── use-user-plan.ts   # Reads current billing plan
├── lib/
│   ├── blog.ts          # Blog post data & helpers
│   ├── constants.ts     # SEO, routes, structured data config
│   ├── emails/          # Email templates + Resend send helper
│   ├── files/           # Shared file upload limits/types/formatting config
│   ├── ai/              # AI chat config (model, prompt, rate limits)
│   │   └── tools/       # AI tool registry for chat
│   ├── rate-limit.ts    # In-memory sliding window rate limiter
│   ├── structured-data.tsx  # JSON-LD components
│   ├── sentry.ts        # Sentry helper wrappers
│   ├── convex-client-provider.tsx # Convex + Clerk provider
│   └── utils.ts         # cn() class merge utility
convex/
├── schema.ts            # Database schema
├── users.ts             # User queries & mutations
└── auth.config.ts       # Clerk JWT config for Convex

Environment Variables

See .env.example for the full list. Key ones:

Variable Purpose
NEXT_PUBLIC_SITE_URL Canonical site URL
NEXT_PUBLIC_CLERK_* Clerk auth config
NEXT_PUBLIC_CONVEX_URL Convex deployment URL
CLERK_JWT_ISSUER_DOMAIN Clerk JWT issuer (Convex)
AI_GATEWAY_API_KEY Vercel AI Gateway key for /api/chat
AI_CHAT_MODEL Model ID (default: openai/gpt-4.1-mini)
AI_CHAT_SYSTEM_PROMPT Base system prompt for assistant behavior
AI_CHAT_TOOLS Comma-separated enabled tools (default: getCurrentDateTime,calculate)
AI_CHAT_MAX_STEPS Max model steps/tool calls per response (default: 5)
AI_CHAT_RATE_LIMIT_MAX_REQUESTS Max chat requests per window per user/IP (default: 20)
AI_CHAT_RATE_LIMIT_WINDOW_MS Chat rate-limit window in ms (default: 60000)
AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT Enable/disable lifetime per-user message cap (default: true)
AI_CHAT_LIFETIME_MESSAGE_LIMIT Lifetime message cap when enabled (default: 1)
AI_CHAT_HISTORY_ENABLED Enable/disable Convex chat history persistence (default: true)
AI_CHAT_HISTORY_MAX_MESSAGE_LENGTH Max chars per persisted chat message (default: 8000)
AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD Max persisted messages per chat thread (default: 120)
AI_CHAT_HISTORY_MAX_THREADS Max chat threads per user (default: 50)
AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH Max chars for auto-generated chat titles (default: 80)
AI_CHAT_HISTORY_QUERY_LIMIT Max persisted messages returned to chat UI (default: 200)
FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS Max image uploads per user in each window (default: 10)
FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS Image upload rate-limit window in ms (default: 60000)
RESEND_API_KEY Resend API key for transactional emails
RESEND_FROM_EMAIL Sender address (optional, defaults to onboarding@resend.dev)
NEXT_PUBLIC_POSTHOG_KEY PostHog project API key
NEXT_PUBLIC_POSTHOG_HOST PostHog ingest host
SENTRY_ORG / SENTRY_PROJECT Sentry source map uploads

Scripts

pnpm dev        # Start dev server
pnpm build      # Production build
pnpm start      # Start production server
pnpm lint       # ESLint
npx convex dev  # Convex dev mode

Blog

Posts live in src/lib/blog.ts as a simple array, no MDX or CMS needed. Add a new object to BLOG_POSTS and it appears on /blog with its own /blog/[slug] page, sitemap entry, and JSON-LD structured data automatically.

API Routes

  • GET /api/health - returns uptime and a timestamp, rate-limited to 30 req/min per IP.
  • POST /api/chat - streams AI responses for /dashboard/chat via Vercel AI SDK, protected by Clerk auth and rate-limited.
  • POST /api/email - sends a transactional email to the authenticated user via Resend. Protected by Clerk auth and rate-limited to 10 req/min per IP. See the Email (Resend) section below.

AI Chat Tool Registry

Chat tools are defined in src/lib/ai/tools/registry.ts and injected into POST /api/chat.

  • Add a new tool by defining it in allChatTools.
  • Enable/disable tools with AI_CHAT_TOOLS (comma-separated names).
  • Keep route logic unchanged while extending capabilities for builders.

File Module Config

File constraints and formatting are centralized in src/lib/files/config.ts.

  • Set allowed MIME types/extensions in FILE_TYPE_CATALOG.
  • Set limits in FILE_STORAGE_LIMITS (maxFileSizeBytes, maxFilesPerUser).
  • Frontend upload UI, upload hook, and Convex file mutations all consume this config.

Rate Limiting

src/lib/rate-limit.ts provides a sliding window rate limiter. Use it in any API route:

import { rateLimit } from "@/lib/rate-limit";

const limiter = rateLimit({ interval: 60_000, limit: 10 });

export async function GET(req: Request) {
  const ip = req.headers.get("x-forwarded-for") ?? "unknown";
  const { success, remaining, reset } = limiter.check(ip);

  if (!success) {
    return Response.json({ error: "Too many requests" }, { status: 429 });
  }

  return Response.json({ ok: true });
}

Note: This is in-memory and resets on cold starts. For production multi-instance deployments, swap it with Upstash Redis or similar.

Email (Resend)

Transactional emails are sent via Resend. Templates and the send helper live in src/lib/emails/.

Setup

  1. Create an account at resend.com and grab your API key.
  2. Add the key to .env:
    RESEND_API_KEY=re_...
    RESEND_FROM_EMAIL=hello@yourdomain.com
    
  3. If you are testing locally without a verified domain, leave RESEND_FROM_EMAIL unset and Resend will use its sandbox sender (onboarding@resend.dev).

Templates

Each template exports a function returning { subject, html }:

  • welcomeEmail({ name }) - welcome email for new sign-ups
  • planChangedEmail({ name, previousPlan, newPlan }) - plan upgrade/downgrade notification

Sending emails server-side

Use the sendEmail helper in any server context (API routes, server actions):

import { sendEmail, welcomeEmail } from "@/lib/emails";

const { subject, html } = welcomeEmail({ name: "Ege" });
const result = await sendEmail({ to: "ege@example.com", subject, html });

if (!result.success) {
  console.error("Email failed:", result.error);
}

API route

POST /api/email sends a template email to the currently authenticated user. It reads the user's email from Clerk, so the caller only provides the template and its data.

{ "template": "welcome", "name": "Ege" }
{
  "template": "plan-changed",
  "name": "Ege",
  "previousPlan": "free",
  "newPlan": "pro"
}

The route is Clerk-authenticated and rate-limited to 10 requests per minute.

Onboarding

New users are automatically redirected to /onboarding on their first dashboard visit. The onboarding flow is a clean, multi-step process that:

  • Welcomes users and shows what to expect
  • Reviews their profile information from Clerk
  • Shows next steps and tips
  • Tracks completion state in Convex

How it works

  1. Tracking: The onboardingCompleted and onboardingStep fields in the Convex users table track progress.
  2. Hook: The useOnboarding hook checks status and redirects appropriately.
  3. Auto-redirect: The DashboardShell component calls useOnboarding() to enforce the flow.
  4. Skip option: Users can skip onboarding and come back later.

Customizing steps

Edit src/app/(dashboard)/onboarding/page.tsx to add your own steps. The current steps are:

  1. Welcome - Introduction and overview
  2. Profile - Show user info from Clerk
  3. Preferences - Final step before completion

Add new steps by:

  1. Adding the step to the STEPS array
  2. Adding a case to the renderStep() function
  3. Updating the OnboardingStep type in convex/users.ts

Reset onboarding

For testing, you can reset a user's onboarding via the Convex dashboard or by calling the resetOnboarding mutation.

Architecture

Tech Stack

Layer Tool
Framework Next.js 16 (App Router)
Auth Clerk
Database Convex
Styling Tailwind CSS 4
Analytics PostHog + Vercel Analytics
Error Tracking Sentry
Payments Clerk Billing
Email Resend

Route Groups

src/app/
├── (auth)/          # Sign-in, sign-up pages (Clerk)
├── (dashboard)/     # Protected pages (requires auth)
├── (legal)/         # Privacy, terms, cookies
├── (marketing)/     # Landing, features, pricing, about, docs, blog
│   └── blog/        # Blog index + [slug] detail pages
├── api/
│   ├── email/       # Send transactional emails via Resend
│   └── health/      # Health check endpoint (rate-limited)
├── layout.tsx       # Root layout - providers, fonts, metadata
├── not-found.tsx    # Custom 404 page
├── error.tsx        # App-level error boundary
└── global-error.tsx # Root error boundary (catches layout errors)

(marketing)

Public pages with HeroHeader and Footer. No auth required. Includes the blog at /blog with individual post pages at /blog/[slug].

(auth)

Clerk's sign-in/sign-up catch-all routes. Centered layout.

(dashboard)

Protected area. Uses sidebar layout with useSyncUser to keep Convex in sync with Clerk.

(legal)

Static legal pages sharing a minimal layout.

Provider Stack

Providers wrap the app in this order (see layout.tsx):

ThemeProvider          - next-themes (light/dark/system)
  PostHogProvider      - Analytics client
    ClerkProvider      - Auth (adapts to theme)
      PostHogIdentify  - Links Clerk user to PostHog
      PostHogPageview  - Tracks route changes
      TooltipProvider  - UI tooltips
        ConvexProvider - Realtime database (uses Clerk auth)

Data Flow

User Sync

Clerk (auth source of truth)
  > useSyncUser hook (client-side)
    > Convex createOrUpdateUser mutation
      > Convex users table

The useSyncUser hook runs on auth'd pages. It compares the Clerk user with the Convex record and only writes when data has changed (email, name, avatar, plan).

Plan Detection

Clerk Billing (has plan: "pro")
  > useUserPlan hook
    > returns { plan, isPro, isFree, isLoading }

No separate billing table - plan is derived from Clerk's has() check and synced to Convex for server-side access.

API Routes

  • POST /api/email - sends a transactional email to the authenticated user via Resend. Protected by Clerk auth and rate-limited to 10 req/min per IP. Accepts a JSON body with a template field ("welcome" or "plan-changed") and the template's required data.
  • GET /api/health - returns { status, timestamp, uptime }. Rate-limited to 30 req/min per IP via the in-memory sliding window limiter in src/lib/rate-limit.ts.

Blog

Posts are defined as a simple array in src/lib/blog.ts. No MDX or CMS - just add an object to BLOG_POSTS and the blog index + detail page + sitemap + JSON-LD are generated automatically.

Email (Resend)

Transactional emails are sent via Resend. Everything lives in src/lib/emails/:

  • send.ts - sendEmail() helper that wraps the Resend SDK (lazily initialized)
  • welcome.ts - welcomeEmail({ name }) returns { subject, html }
  • plan-changed.ts - planChangedEmail({ name, previousPlan, newPlan }) returns { subject, html }
  • index.ts - barrel exports for all templates and the send helper

Use sendEmail() in any server context (API routes, server actions):

import { sendEmail, welcomeEmail } from "@/lib/emails";

const { subject, html } = welcomeEmail({ name: "Ege" });
await sendEmail({ to: "ege@example.com", subject, html });

Requires RESEND_API_KEY in .env. Optionally set RESEND_FROM_EMAIL to override the default sender address.

Rate Limiting

src/lib/rate-limit.ts provides a sliding window rate limiter for API routes. In-memory - suitable for single-instance or low-traffic Vercel serverless. Swap with Upstash Redis for multi-instance production.

Key Files

File Purpose
src/lib/constants.ts SEO config, routes, structured data
src/lib/structured-data.tsx JSON-LD components for SEO
src/lib/blog.ts Blog post data & helpers
src/lib/rate-limit.ts In-memory rate limiter
src/lib/emails/send.ts Resend sendEmail helper
src/lib/emails/ Email templates (welcome, plan)
src/app/api/email/route.ts Send email API route
src/lib/sentry.ts Error tracking helpers
src/lib/convex-client-provider.tsx Convex + Clerk integration
src/hooks/use-sync-user.ts Clerk to Convex user sync
src/hooks/use-user-plan.ts Plan gating hook
src/hooks/use-mobile.ts Responsive breakpoint detection
convex/schema.ts Database schema
convex/users.ts User CRUD mutations/queries

Authentication

Shipr uses Clerk for authentication and Convex for the backend database.

How It Works

  1. Clerk handles sign-in, sign-up, session management, and billing plans
  2. Convex stores user data synced from Clerk via the useSyncUser hook
  3. Users are identified by their Clerk ID across both systems

Architecture

User signs in via Clerk
        |
ClerkProviderWrapper (adapts to dark/light theme)
        |
ConvexProviderWithClerk (passes Clerk auth to Convex)
        |
useSyncUser hook (syncs Clerk user data to Convex DB)

Providers

Providers are nested in the root layout (src/app/layout.tsx):

<ThemeProvider>
  <PostHogProvider>
    <ClerkProviderWrapper>
      <ConvexClientProvider>{children}</ConvexClientProvider>
    </ClerkProviderWrapper>
  </PostHogProvider>
</ThemeProvider>

ClerkProviderWrapper

src/components/clerk-provider-wrapper.tsx

Wraps ClerkProvider and automatically applies the dark theme based on next-themes.

ConvexClientProvider

src/lib/convex-client-provider.tsx

Creates a singleton ConvexReactClient and bridges Clerk auth into Convex via ConvexProviderWithClerk.

User Sync

The useSyncUser hook (src/hooks/use-sync-user.ts) runs on authenticated pages and:

  • Reads the current Clerk user + billing plan
  • Checks if the Convex user record exists and is up-to-date
  • Creates or patches the Convex record only when data has changed

Convex Schema

Users table (convex/schema.ts):

Field Type Description
clerkId string Clerk user ID (indexed)
email string Primary email
name string? Full name
imageUrl string? Profile image URL
plan string? "free" or "pro"

Environment Variables

Variable Description
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Clerk publishable key
CLERK_SECRET_KEY Clerk secret key
NEXT_PUBLIC_CONVEX_URL Convex deployment URL
CLERK_JWT_ISSUER_DOMAIN Clerk JWT issuer for Convex

Route Groups

  • (auth) - Sign-in and sign-up pages using Clerk's prebuilt components
  • (dashboard) - Protected pages that require authentication
  • (marketing) - Public pages (no auth required)

Security

All Convex mutations and queries enforce ownership checks - users can only read, update, or delete their own records. The identity.subject from Clerk JWT is compared against the clerkId argument on every operation.

AI

Overview

Shipr includes a production-ready chat module powered by Vercel AI SDK and the AI Gateway.

  • UI route: /dashboard/chat
  • API route: POST /api/chat
  • Server config: src/lib/ai/chat-config.ts
  • History config: src/lib/ai/chat-history-config.ts
  • Tool registry: src/lib/ai/tools/registry.ts

Request Flow

  1. User sends a message from src/app/(dashboard)/dashboard/chat/page.tsx
  2. useChat sends to POST /api/chat
  3. The route enforces:
    • Clerk auth
    • per-user/IP rate limiting
    • optional lifetime message cap
  4. streamText streams model output and optional tool calls
  5. UI renders streamed parts, including Markdown-safe assistant text

Environment Variables

Variable Purpose Default
AI_GATEWAY_API_KEY Enables Vercel AI Gateway calls Required
AI_CHAT_MODEL Model ID for chat generation openai/gpt-4.1-mini
AI_CHAT_SYSTEM_PROMPT Base assistant behavior Shipr SaaS builder prompt
AI_CHAT_TOOLS Comma-separated enabled tool names getCurrentDateTime,calculate
AI_CHAT_MAX_STEPS Max model steps/tool iterations 5
AI_CHAT_RATE_LIMIT_MAX_REQUESTS Requests allowed per window 20
AI_CHAT_RATE_LIMIT_WINDOW_MS Rate-limit window size 60000
AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT Enable one-time lifetime cap behavior true
AI_CHAT_LIFETIME_MESSAGE_LIMIT Lifetime message cap when enabled 1
AI_CHAT_HISTORY_ENABLED Enable Convex-backed chat history true
AI_CHAT_HISTORY_MAX_MESSAGE_LENGTH Max chars per persisted message 8000
AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD Max persisted messages per chat thread 120
AI_CHAT_HISTORY_MAX_THREADS Max chat threads per user 50
AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH Max chars for auto-generated titles 80
AI_CHAT_HISTORY_QUERY_LIMIT Max messages returned to chat UI 200

Error UX

The chat UI uses Sonner to surface API errors as toasts rather than exposing raw JSON payload strings in the message panel.

  • Toaster component: src/components/ui/sonner.tsx
  • Global mount: src/app/layout.tsx
  • Error handling hook: useChat({ onError }) in src/app/(dashboard)/dashboard/chat/page.tsx

Extending For Builders

Add a new tool

  1. Define the tool in src/lib/ai/tools/registry.ts
  2. Add its key to AI_CHAT_TOOLS in env
  3. Keep /api/chat unchanged so the integration remains modular

Change model behavior

  1. Update AI_CHAT_MODEL and AI_CHAT_SYSTEM_PROMPT
  2. Optionally adjust AI_CHAT_MAX_STEPS
  3. Keep the same UI and route contracts to avoid client churn

Disable boilerplate anti-abuse cap

Set:

AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT=false

No code change is required.

Security Notes

  • Chat is protected by Clerk auth (401 when unauthenticated).
  • Rate limiting is applied before model generation.
  • Tool execution is allowlist-based through the registry.
  • Lifetime message cap is server-side and enforced in the API route.

File Upload

Overview

Shipr ships with a Convex-backed file upload module for authenticated dashboard users.

  • UI page: src/app/(dashboard)/dashboard/files/page.tsx
  • Upload component: src/components/dashboard/file-upload.tsx
  • Upload hook: src/hooks/use-file-upload.ts
  • Backend mutations/queries: convex/files.ts
  • Shared config: src/lib/files/config.ts

Upload Pipeline

The module uses Convex's 3-step upload flow:

  1. Generate short-lived upload URL (files.generateUploadUrl)
  2. Upload file blob directly to Convex storage
  3. Persist verified metadata in files table (files.saveFile)

Security Controls

Authentication and ownership

  • requireCurrentUser enforces signed-in access on every write/read path.
  • requireOwnedFile enforces file ownership for getFileUrl and deleteFile.
  • getUserFiles scopes reads by userId index.

Metadata validation

saveFile does not trust client-provided size/type blindly:

  • Reads actual file metadata from ctx.db.system.get(storageId)
  • Uses storage-reported size and content type where available
  • Rejects oversized files and disallowed MIME types
  • Deletes invalid uploads from storage

Image upload rate limiting

Image uploads are rate-limited per authenticated user in a rolling time window.

  • Config lives in src/lib/files/config.ts under FILE_UPLOAD_RATE_LIMITS.image
  • Enforcement happens in convex/files.ts during saveFile
  • If limit is exceeded, the uploaded blob is deleted and the request is rejected
  • Environment overrides:
    • FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS (default 10)
    • FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS (default 60000)
    • set either to 0 to disable image upload rate limiting

Filename sanitization

Filenames are sanitized before persistence to remove path separators and unsafe characters.

Configuration

Centralized in src/lib/files/config.ts:

  • MIME allowlist and extension mapping
  • maxFileSizeBytes
  • maxFilesPerUser
  • image upload rate limits (maxUploadsPerWindow, windowMs)
  • shared format helpers (formatFileSize, formatFileDate, labels)

This config is consumed by frontend and backend, so changing limits in one place updates the full flow.

Extension Points For Builders

Add or remove supported file types

Update the file type catalog in src/lib/files/config.ts. Both UI validation and backend enforcement will align automatically.

Change storage quotas

Update FILE_STORAGE_LIMITS values (max size, max files per user).

Swap presentation layer

You can replace FileUpload or FilesPage UI without touching Convex security logic, as long as you keep mutation contracts unchanged.

Notes

  • Current list thumbnail rendering uses <img> for Convex signed URLs; lint warns about Next Image optimization tradeoffs.
  • Downloads are triggered with browser a[download] using signed storage URLs.

SEO

How Shipr handles search engine optimization out of the box.

Metadata

Global metadata is configured in src/app/layout.tsx using Next.js App Router's Metadata API. All values pull from src/lib/constants.ts so you only need to update one file.

Key constants:

  • SITE_CONFIG - site name, URL, description, socials
  • METADATA_DEFAULTS - title template (%s | Shipr), default title
  • OG_IMAGE_DEFAULTS - Open Graph image dimensions and alt text
  • PAGE_SEO - per-page title, description, and keywords

Per-page metadata

Each route exports its own metadata object. Example from src/app/(marketing)/page.tsx:

export const metadata: Metadata = {
  title: PAGE_SEO.home.title,
  description: PAGE_SEO.home.description,
  keywords: [...PAGE_SEO.home.keywords],
  alternates: { canonical: SITE_CONFIG.url },
};

To add a new page, add an entry to PAGE_SEO in constants.ts and export metadata in your page file.

Sitemap

src/app/sitemap.ts generates /sitemap.xml at build time. Routes come from SITEMAP_ROUTES in constants. Each entry has a path, priority, and change frequency.

Add new public routes to SITEMAP_ROUTES - they'll appear in the sitemap automatically.

Robots

src/app/robots.ts generates /robots.txt. Protected and internal routes are blocked via ROBOTS_DISALLOWED in constants.

Structured Data (JSON-LD)

src/lib/structured-data.tsx provides server-safe JSON-LD components:

Component Schema Type Where to use
OrganizationJsonLd Organization Root layout <head>
WebSiteJsonLd WebSite Root layout <head>
SoftwareApplicationJsonLd SoftwareApplication Home / pricing pages
ProductJsonLd Product Pricing page
FaqJsonLd FAQPage Any page with FAQs
BreadcrumbJsonLd BreadcrumbList Nested pages
ArticleJsonLd Article Blog posts

Adding a new schema

  1. Define the data shape as a TypeScript interface.
  2. Create a component that renders <JsonLd data={...} />.
  3. Drop it into the relevant page or layout.

Open Graph & Twitter Images

Static image files live in src/app/:

  • opengraph-image.png (1200×630)
  • twitter-image.png (1200×630)
  • apple-touch-icon.png
  • favicon.ico / icon.svg

Next.js automatically serves these at the correct routes.

Analytics

Shipr uses PostHog for product analytics, routed through a Next.js reverse proxy to avoid ad-blockers.

Setup

Three components handle analytics in the root layout (src/app/layout.tsx):

Component File Purpose
PostHogProvider src/components/posthog-provider.tsx Initializes the PostHog client and wraps the app
PostHogPageview src/components/posthog-pageview.tsx Captures $pageview on route changes
PostHogIdentify src/components/posthog-identify.tsx Links Clerk user identity to PostHog person

Environment Variables

NEXT_PUBLIC_POSTHOG_KEY=phc_...
NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com

Reverse Proxy

PostHog requests are proxied through Next.js rewrites in next.config.ts to bypass ad-blockers:

  • /ingest/static/* to https://us-assets.i.posthog.com/static/*
  • /ingest/* to https://us.i.posthog.com/*

Tracked Events

Event Location Description
$pageview posthog-pageview.tsx Every route change
cta_clicked Hero, CTA sections User clicked a call-to-action button
pricing_plan_clicked Pricing section User selected a pricing plan
upgrade_button_clicked Dashboard User clicked upgrade
faq_expanded FAQ section User opened a FAQ item
navigation_clicked Header Nav link clicked (includes device type)
mobile_menu_toggled Header Mobile menu opened/closed
theme_toggled Theme toggle Theme changed (includes previous/new theme)

User Identification

PostHogIdentify runs on auth state changes. When a user signs in, it calls posthog.identify() with:

  • email, name, first_name, last_name
  • username, created_at, plan

On sign-out, it calls posthog.reset() to clear the person profile.

Adding New Events

Capture events anywhere with:

import posthog from "posthog-js";

posthog.capture("event_name", { key: "value" });

Keep event names in snake_case. Include relevant context as properties.

Deployment

Environment Variables

Copy .env.example to .env and fill in the values:

cp .env.example .env
Variable Description Required
NEXT_PUBLIC_SITE_URL Your production URL (e.g. https://shipr.dev) Yes
NEXT_PUBLIC_CONVEX_URL Convex deployment URL Yes
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Clerk publishable key Yes
CLERK_SECRET_KEY Clerk secret key Yes
CLERK_JWT_ISSUER_DOMAIN Clerk JWT issuer for Convex auth Yes
AI_GATEWAY_API_KEY Vercel AI Gateway key for dashboard chat Yes
AI_CHAT_MODEL Chat model ID (defaults to openai/gpt-4.1-mini) Optional
AI_CHAT_SYSTEM_PROMPT System prompt for chat assistant behavior Optional
AI_CHAT_TOOLS Enabled chat tool names, comma-separated Optional
AI_CHAT_MAX_STEPS Max model steps/tool calls per response Optional
AI_CHAT_RATE_LIMIT_MAX_REQUESTS Max chat requests per rate-limit window Optional
AI_CHAT_RATE_LIMIT_WINDOW_MS Chat rate-limit window in milliseconds Optional
AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT Enable/disable lifetime per-user message cap Optional
AI_CHAT_LIFETIME_MESSAGE_LIMIT Lifetime message cap when enabled Optional
AI_CHAT_HISTORY_ENABLED Enable/disable Convex chat history persistence Optional
AI_CHAT_HISTORY_MAX_MESSAGE_LENGTH Max chars per persisted chat message Optional
AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD Max persisted chat messages per chat thread Optional
AI_CHAT_HISTORY_MAX_THREADS Max chat threads per user Optional
AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH Max chars for auto-generated chat titles Optional
AI_CHAT_HISTORY_QUERY_LIMIT Max persisted messages returned to chat UI Optional
FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS Max image uploads per user in each window Optional
FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS Image upload rate-limit window in milliseconds Optional
RESEND_API_KEY Resend API key for transactional emails Yes
RESEND_FROM_EMAIL Sender address (defaults to onboarding@resend.dev) Optional
NEXT_PUBLIC_POSTHOG_KEY PostHog project API key Optional
NEXT_PUBLIC_POSTHOG_HOST PostHog ingest host Optional
SENTRY_AUTH_TOKEN Sentry auth token for source maps Optional

Vercel

  1. Push your repo to GitHub
  2. Import the project in Vercel
  3. Add all environment variables in the Vercel dashboard
  4. Deploy - Vercel auto-detects Next.js

Build settings

Vercel should auto-detect these, but if not:

  • Framework: Next.js
  • Build command: pnpm build
  • Output directory: .next

Convex

  1. Install the CLI: pnpm add -g convex
  2. Run npx convex dev locally to sync your schema
  3. For production, deploy with npx convex deploy
  4. Set CLERK_JWT_ISSUER_DOMAIN in the Convex dashboard under Authentication

Clerk

  1. Create a project at clerk.com
  2. Copy your keys to .env
  3. Enable Clerk Billing if you want Pro/Free plan gating
  4. Configure the JWT issuer domain for Convex integration

Sentry

  1. Create a project at sentry.io
  2. Add SENTRY_AUTH_TOKEN to your environment
  3. Source maps are uploaded automatically during build via @sentry/nextjs
  4. Error tracking works out of the box - see src/lib/sentry.ts for helpers

Resend

  1. Create an account at resend.com
  2. Add a verified domain (or use the sandbox sender for testing)
  3. Copy your API key and add RESEND_API_KEY to .env
  4. Optionally set RESEND_FROM_EMAIL to your verified sender address (e.g. hello@yourdomain.com)
  5. The POST /api/email route and the sendEmail helper in src/lib/emails/send.ts will use these values at runtime

PostHog

  1. Create a project at posthog.com
  2. Add NEXT_PUBLIC_POSTHOG_KEY and NEXT_PUBLIC_POSTHOG_HOST to .env
  3. The app uses a reverse proxy via Next.js rewrites to bypass ad blockers (configured in next.config.ts)