senternet-site-nextjs-setup
Scaffold a production-ready Next.js (App Router) + TypeScript site targeting Firebase App Hosting.
SKILL.md.claude/skills/senternet-site-nextjs-setup/SKILL.md
GitHubNext.js + TypeScript Site Setup
Initialize a production-ready Next.js App Router site for a marketing site, configured for SSR on Firebase App Hosting.
This is the nextjs track. Read /senternet-site-framework first for how this track differs from the vite track — especially the convention map, which every later skill translates through.
When to use this instead of /senternet-site-vite-setup
Use this only when the site actually needs server rendering, route handlers, middleware, or on-demand regeneration. For a static marketing site, the Vite track is cheaper and faster. /senternet-site-framework has the full decision guidance.
Steps
1. Choose the local dev port
Ask the user which local dev port to use. Default is 3000 (also Next's own default). Explain that they may want a different one if they run multiple sites locally (e.g. 3001, 3002, 3003). Use their answer as $PORT throughout.
2. Scaffold the project
npx create-next-app@latest $SITE_NAME \
--typescript \
--tailwind \
--eslint \
--app \
--no-src-dir \
--import-alias "@/*" \
--use-npm
cd $SITE_NAME
Replace $SITE_NAME with the project directory name (e.g. myapp-site). Accept the default answer for any prompt the flags do not cover (Turbopack for dev is fine).
This gives Tailwind CSS v4 wired through @import "tailwindcss" in app/globals.css, matching the rest of the suite. Confirm that import is present; add it if the generator's version differs.
Install the build-script dependencies the other skills use:
npm install -D sharp
Puppeteer is not needed on this track — Next renders HTML itself.
3. Write .site-framework.json
In the site root, so every later skill and every upfit run knows which track this is:
{
"framework": "nextjs",
"renderMode": "ssr-app-hosting",
"host": "firebase-app-hosting"
}
4. Create the route manifest at config/routes.mjs
This is the single source of truth for sitemap, robots, and IndexNow. It is plain .mjs so both the TypeScript app and the Node build scripts can import it without a transpile step.
/** @type {import('./routes.d.mts').SiteRoute[]} */
export const ROUTES = [
{ path: '/', changefreq: 'weekly', priority: 1.0, indexable: true },
{ path: '/privacy', changefreq: 'yearly', priority: 0.3, indexable: false },
{ path: '/terms', changefreq: 'yearly', priority: 0.3, indexable: false },
// Add every new page here. Legal pages stay indexable: false.
];
export const indexableRoutes = () => ROUTES.filter(r => r.indexable);
And config/routes.d.mts so the app gets types:
export interface SiteRoute {
path: string;
changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
priority: number;
indexable: boolean;
}
export declare const ROUTES: SiteRoute[];
export declare function indexableRoutes(): SiteRoute[];
5. Configure next.config.ts
import type { NextConfig } from 'next';
const securityHeaders = [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
];
const nextConfig: NextConfig = {
reactStrictMode: true,
poweredByHeader: false,
trailingSlash: false,
images: {
formats: ['image/avif', 'image/webp'],
},
async headers() {
return [
{ source: '/:path*', headers: securityHeaders },
{
// Everything under /public that is content-hashed by the build already
// gets an immutable cache from Next. These are the hand-placed assets.
source: '/:all*(svg|png|jpg|jpeg|webp|avif|ico|woff2)',
headers: [{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }],
},
];
},
};
export default nextConfig;
Do not set output: 'export'. Static export disables route handlers, middleware, image optimization, and ISR — which are the only reasons to be on this track. Do not override distDir either; App Hosting expects the default .next/.
6. Create apphosting.yaml
At the repo root. This is how App Hosting sizes the backend and injects configuration at build and runtime.
runConfig:
minInstances: 0
maxInstances: 2
concurrency: 80
cpu: 1
memoryMiB: 512
env:
- variable: NEXT_PUBLIC_BASE_URL
value: https://www.DOMAIN.com
availability:
- BUILD
- RUNTIME
Notes on the values:
minInstances: 0means cold starts on a quiet marketing site. Raise it to1only if the user accepts paying for an always-warm instance to remove that latency.- Every variable the browser reads must be prefixed
NEXT_PUBLIC_and listed withBUILDavailability, or it will beundefinedin the client bundle. - Secrets are never
value:entries. They are Secret Manager references:
Create and grant those withenv: - variable: RESEND_API_KEY secret: resend-api-key availability: - RUNTIMEfirebase apphosting:secrets:setandfirebase apphosting:secrets:grantaccesswhen the skill that needs them runs.
For a separate dev backend, add apphosting.dev.yaml with the dev values — App Hosting merges the environment-specific file over apphosting.yaml.
In a monorepo, apphosting.yaml goes in the app's directory (the backend's root dir, e.g. apps/site/apphosting.yaml), not at the repo root. See the next step.
6b. Monorepos and workspaces
If the repo uses npm/pnpm/yarn workspaces, App Hosting needs a supported monorepo tool on top of them — use Turborepo. Workspaces alone are not enough: the builder resolves the app's dependencies through the monorepo tool, and a repo that declares workspaces without one fails to build. The supported tools are Nx and Turborepo (Turborepo support shipped in January 2026). Turborepo is the right default for a Next.js site.
Skip this step entirely for a single-package repo. Do not add Turborepo to a repo that has no workspaces — it buys nothing and adds a build tool to maintain.
Required layout:
├── packages/ # shared code, if any
├── apps/
│ └── site/ # the Next.js app — this is the backend's root dir
│ ├── apphosting.yaml
│ ├── next.config.ts
│ └── package.json
├── turbo.json # at the repo root
├── package.json # root, declaring "workspaces"
└── package-lock.json # one lockfile, at the repo root
- Root
package.jsondeclares the workspaces and takesturboas a devDependency:{ "private": true, "workspaces": ["apps/*", "packages/*"], "devDependencies": { "turbo": "^2" }, "scripts": { "build": "turbo run build", "dev": "turbo run dev" } } turbo.jsonat the repo root declares the build task and its dependency order, so shared packages build before the app that imports them:{ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": [".next/**", "!.next/cache/**"] }, "dev": { "cache": false, "persistent": true } } }- Keep a single lockfile at the repo root. A second lockfile inside the app directory makes the builder resolve dependencies from the wrong tree.
- Tell the backend which app to build. During the GitHub connection flow, give the app's path (e.g.
apps/site) as the root directory. For CLI deploys, put it infirebase.json:{ "apphosting": { "backendId": "my-site", "rootDir": "/apps/site", "ignore": ["node_modules", ".git", "firebase-debug.log"] } }
Known limitation: App Hosting does not support Turborepo remote caching. Local task caching within a build still applies.
7. Set up app/layout.tsx
The layout carries the base metadata that index.html carries on the Vite track:
import type { Metadata } from 'next';
import './globals.css';
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000';
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
default: 'APPNAME - Tagline',
template: '%s | APPNAME',
},
description: 'One sentence description for search results.',
applicationName: 'APPNAME',
robots: { index: true, follow: true },
openGraph: {
type: 'website',
siteName: 'APPNAME',
locale: 'en_US',
url: '/',
images: [{ url: '/share/home.png', width: 1200, height: 630, alt: 'APPNAME' }],
},
twitter: {
card: 'summary_large_image',
creator: '@HANDLE',
site: '@HANDLE',
},
alternates: { canonical: '/' },
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
metadataBase is what makes every relative url and images path in page metadata resolve to an absolute URL. Without it, OG images silently ship as relative paths that crawlers cannot fetch.
8. Create app/sitemap.ts and app/robots.ts
Both read the route manifest, so adding a page in one place keeps them in sync.
// app/sitemap.ts
import type { MetadataRoute } from 'next';
import { indexableRoutes } from '@/config/routes.mjs';
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000';
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return indexableRoutes().map(route => ({
url: `${baseUrl}${route.path === '/' ? '' : route.path}`,
lastModified,
changeFrequency: route.changefreq,
priority: route.priority,
}));
}
// app/robots.ts
import type { MetadataRoute } from 'next';
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL ?? 'http://localhost:3000';
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: `${baseUrl}/sitemap.xml`,
};
}
Only indexable: true routes reach the sitemap. noindex pages still render and are still crawlable by direct link — they just must never be advertised.
9. Create the home page
app/page.tsx. Keep it a server component (no 'use client') so it renders to HTML with no JS cost:
export default function HomePage() {
return (
<main>
<h1>APPNAME</h1>
</main>
);
}
Every page below the root exports its own metadata:
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About',
description: 'Learn more about what we do.',
alternates: { canonical: '/about' },
openGraph: { url: '/about', images: [{ url: '/share/about.png', width: 1200, height: 630 }] },
};
export default function AboutPage() {
return <main>{/* ... */}</main>;
}
10. Server vs client components
Default to server components. Add 'use client' only to the leaf component that actually needs state, effects, or event handlers — a mobile nav toggle, a form, an analytics hook. Marking a whole page 'use client' ships the entire subtree to the browser and gives back the main reason to be on this framework.
There is no hydrateRoot, no app-ready event, and no window.__PRERENDERING__ on this track. Any skill that references them is describing the Vite track; skip that step.
11. Environment files
# .env.development
NEXT_PUBLIC_BASE_URL=http://localhost:$PORT
NEXT_PUBLIC_GA_ID=
# .env.production
NEXT_PUBLIC_BASE_URL=https://www.DOMAIN.com
NEXT_PUBLIC_GA_ID=G-XXXXXXXXXX
.env.local stays untracked for machine-specific overrides. In deployed environments the values in apphosting.yaml are what actually apply, so any variable added to .env.production must be mirrored there or it will be missing in the build. On upfit runs, preserve an existing non-empty NEXT_PUBLIC_GA_ID rather than overwriting it with the placeholder.
Confirm .gitignore covers .env*.local and .next/.
12. package.json scripts
{
"scripts": {
"dev": "next dev --port $PORT",
"build": "next build",
"start": "next start --port $PORT",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"build:prod": "next build",
"deploy:prod": "firebase apphosting:rollouts:create $BACKEND_ID --project $PREFIX-prod --git-branch main && node scripts/indexnow.mjs"
}
}
There is no separate prerender step to chain onto build:prod — next build produces the static HTML itself. build:prod is kept as an alias so the shared workflow steps and CI in the rest of the suite keep working.
13. Set up the App Hosting backend
Run /senternet-site-firebase and follow its Next.js track / App Hosting section. It creates the Firebase projects, connects the GitHub repo, creates the backend, and handles the custom domain. App Hosting deploys from a connected git branch, so the repo needs a GitHub remote first — run /senternet-site-github-setup before that step if there isn't one.
14. Directory layout
Create the directories the later skills expect:
app/ # routes, layout, sitemap.ts, robots.ts
components/ # shared React components
config/ # routes.mjs, routes.d.mts
lib/ # analytics helpers, utilities
public/ # favicon, share images, static assets
scripts/ # indexnow.mjs, generate-share-images.mjs
Verification
The scaffold is done when all of these pass:
npm run devstarts cleanly on$PORTnpm run buildcompletes with no TypeScript errors, and the build output marks/as static (○) rather than dynamic (ƒ) — a marketing page rendering dynamically means something in the tree opted into request-time rendering by accidentnpm run typecheckis cleannpm run build && npm start, thencurl -s localhost:$PORT | grep "<h1"returns the heading — this is the crawler's view, and it must contain real contentcurl -s localhost:$PORT/sitemap.xmllists only indexable routescurl -s localhost:$PORT/robots.txtpoints at the sitemap on the canonical host.site-framework.json,apphosting.yaml, andconfig/routes.mjsall exist- If the root
package.jsondeclaresworkspaces:turbo.jsonexists at the repo root, there is exactly one lockfile (also at the root), and the backend's root directory points at the app rather than/. Verify withnpm run buildfrom the repo root — building only from inside the app directory hides the dependency-resolution failure that App Hosting will hit.
Notes
- App Hosting rollouts are triggered by pushes to the connected branch.
firebase apphosting:rollouts:createforces one without a new commit. Verify the backend exists withfirebase apphosting:backends:list --project $PREFIX-prodbefore wiring the deploy script, and checkfirebase apphosting --helpif a subcommand name has moved — this CLI surface changes faster than the Hosting one. firebase.jsonis not used for headers on this track — anything the Vite track puts infirebase.jsonheadersbelongs innext.config.tsheaders()instead. It is still used for App Hosting itself: theapphostingblock (backendId,rootDir,ignore) is what CLI deploys read to find the app.- In a monorepo, the paths in every other skill are relative to the app directory, not the repo root.
app/,config/routes.mjs,public/, andapphosting.yamlall live under the backend's root dir;turbo.json, the rootpackage.json, the lockfile, andfirebase.jsonlive at the repo root. - Cold starts are the tradeoff for SSR. Prefer static rendering for every page that can be static, and reach for
revalidateover request-time rendering when content needs to change without a deploy. - Do not add
react-router-dom. Routing is the framework's job here, and mixing the two breaks server rendering.