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/chatvia 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
- Create an account at resend.com and grab your API key.
- Add the key to
.env:RESEND_API_KEY=re_... RESEND_FROM_EMAIL=hello@yourdomain.com - If you are testing locally without a verified domain, leave
RESEND_FROM_EMAILunset 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-upsplanChangedEmail({ 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
- Tracking: The
onboardingCompletedandonboardingStepfields in the Convexuserstable track progress. - Hook: The
useOnboardinghook checks status and redirects appropriately. - Auto-redirect: The
DashboardShellcomponent callsuseOnboarding()to enforce the flow. - 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:
- Welcome - Introduction and overview
- Profile - Show user info from Clerk
- Preferences - Final step before completion
Add new steps by:
- Adding the step to the
STEPSarray - Adding a case to the
renderStep()function - Updating the
OnboardingSteptype inconvex/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 |
| 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 atemplatefield ("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 insrc/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
- Clerk handles sign-in, sign-up, session management, and billing plans
- Convex stores user data synced from Clerk via the
useSyncUserhook - 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
- User sends a message from
src/app/(dashboard)/dashboard/chat/page.tsx useChatsends toPOST /api/chat- The route enforces:
- Clerk auth
- per-user/IP rate limiting
- optional lifetime message cap
streamTextstreams model output and optional tool calls- 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 })insrc/app/(dashboard)/dashboard/chat/page.tsx
Extending For Builders
Add a new tool
- Define the tool in
src/lib/ai/tools/registry.ts - Add its key to
AI_CHAT_TOOLSin env - Keep
/api/chatunchanged so the integration remains modular
Change model behavior
- Update
AI_CHAT_MODELandAI_CHAT_SYSTEM_PROMPT - Optionally adjust
AI_CHAT_MAX_STEPS - 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 (
401when 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:
- Generate short-lived upload URL (
files.generateUploadUrl) - Upload file blob directly to Convex storage
- Persist verified metadata in
filestable (files.saveFile)
Security Controls
Authentication and ownership
requireCurrentUserenforces signed-in access on every write/read path.requireOwnedFileenforces file ownership forgetFileUrlanddeleteFile.getUserFilesscopes reads byuserIdindex.
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.tsunderFILE_UPLOAD_RATE_LIMITS.image - Enforcement happens in
convex/files.tsduringsaveFile - If limit is exceeded, the uploaded blob is deleted and the request is rejected
- Environment overrides:
FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS(default10)FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS(default60000)- set either to
0to 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
maxFileSizeBytesmaxFilesPerUser- 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, socialsMETADATA_DEFAULTS- title template (%s | Shipr), default titleOG_IMAGE_DEFAULTS- Open Graph image dimensions and alt textPAGE_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
- Define the data shape as a TypeScript interface.
- Create a component that renders
<JsonLd data={...} />. - 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.pngfavicon.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/*tohttps://us-assets.i.posthog.com/static/*/ingest/*tohttps://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_nameusername,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
- Push your repo to GitHub
- Import the project in Vercel
- Add all environment variables in the Vercel dashboard
- 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
- Install the CLI:
pnpm add -g convex - Run
npx convex devlocally to sync your schema - For production, deploy with
npx convex deploy - Set
CLERK_JWT_ISSUER_DOMAINin the Convex dashboard under Authentication
Clerk
- Create a project at clerk.com
- Copy your keys to
.env - Enable Clerk Billing if you want Pro/Free plan gating
- Configure the JWT issuer domain for Convex integration
Sentry
- Create a project at sentry.io
- Add
SENTRY_AUTH_TOKENto your environment - Source maps are uploaded automatically during build via
@sentry/nextjs - Error tracking works out of the box - see
src/lib/sentry.tsfor helpers
Resend
- Create an account at resend.com
- Add a verified domain (or use the sandbox sender for testing)
- Copy your API key and add
RESEND_API_KEYto.env - Optionally set
RESEND_FROM_EMAILto your verified sender address (e.g.hello@yourdomain.com) - The
POST /api/emailroute and thesendEmailhelper insrc/lib/emails/send.tswill use these values at runtime
PostHog
- Create a project at posthog.com
- Add
NEXT_PUBLIC_POSTHOG_KEYandNEXT_PUBLIC_POSTHOG_HOSTto.env - The app uses a reverse proxy via Next.js rewrites to bypass ad blockers (configured in
next.config.ts)