diff --git a/packages/create-next-app/create-app.ts b/packages/create-next-app/create-app.ts index 33f0b24105e61..295d93d75dad8 100644 --- a/packages/create-next-app/create-app.ts +++ b/packages/create-next-app/create-app.ts @@ -36,6 +36,7 @@ export async function createApp({ importAlias, skipInstall, empty, + api, turbopack, disableGit, }: { @@ -51,6 +52,7 @@ export async function createApp({ importAlias: string skipInstall: boolean empty: boolean + api?: boolean turbopack: boolean disableGit?: boolean }): Promise { @@ -224,7 +226,7 @@ export async function createApp({ await installTemplate({ appName, root, - template, + template: api ? 'app-api' : template, mode, packageManager, isOnline, diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index c6456e7b8cb72..42c6b7d63c467 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -57,6 +57,7 @@ const program = new Command(packageJson.name) '--import-alias ', 'Specify import alias to use (default "@/*").' ) + .option('--api', 'Initialize a headless API using the App Router.') .option('--empty', 'Initialize an empty project.') .option( '--use-npm', @@ -275,7 +276,7 @@ async function run(): Promise { } } - if (!opts.eslint && !args.includes('--no-eslint')) { + if (!opts.eslint && !args.includes('--no-eslint') && !opts.api) { if (skipPrompt) { opts.eslint = getPrefOrDefault('eslint') } else { @@ -294,7 +295,7 @@ async function run(): Promise { } } - if (!opts.tailwind && !args.includes('--no-tailwind')) { + if (!opts.tailwind && !args.includes('--no-tailwind') && !opts.api) { if (skipPrompt) { opts.tailwind = getPrefOrDefault('tailwind') } else { @@ -332,7 +333,7 @@ async function run(): Promise { } } - if (!opts.app && !args.includes('--no-app')) { + if (!opts.app && !args.includes('--no-app') && !opts.api) { if (skipPrompt) { opts.app = getPrefOrDefault('app') } else { @@ -429,6 +430,7 @@ async function run(): Promise { importAlias: opts.importAlias, skipInstall: opts.skipInstall, empty: opts.empty, + api: opts.api, turbopack: opts.turbopack, disableGit: opts.disableGit, }) diff --git a/packages/create-next-app/templates/app-api/js/README-template.md b/packages/create-next-app/templates/app-api/js/README-template.md new file mode 100644 index 0000000000000..4f3cc28d3a821 --- /dev/null +++ b/packages/create-next-app/templates/app-api/js/README-template.md @@ -0,0 +1,40 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/route.ts`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## API Routes + +This directory contains example API routes for the headless API app. + +For more details, see [route.js file convention](https://nextjs.org/docs/app/api-reference/file-conventions/route). diff --git a/packages/create-next-app/templates/app-api/js/app/[slug]/route.js b/packages/create-next-app/templates/app-api/js/app/[slug]/route.js new file mode 100644 index 0000000000000..58d5c02d61280 --- /dev/null +++ b/packages/create-next-app/templates/app-api/js/app/[slug]/route.js @@ -0,0 +1,6 @@ +import { NextResponse } from "next/server"; + +export async function GET(request, { params }) { + const { slug } = await params; + return NextResponse.json({ message: `Hello ${slug}!` }); +} diff --git a/packages/create-next-app/templates/app-api/js/app/route.js b/packages/create-next-app/templates/app-api/js/app/route.js new file mode 100644 index 0000000000000..5c2378c0afc74 --- /dev/null +++ b/packages/create-next-app/templates/app-api/js/app/route.js @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ message: "Hello world!" }); +} diff --git a/packages/create-next-app/templates/app-api/js/gitignore b/packages/create-next-app/templates/app-api/js/gitignore new file mode 100644 index 0000000000000..8777267507c0e --- /dev/null +++ b/packages/create-next-app/templates/app-api/js/gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/create-next-app/templates/app-api/js/jsconfig.json b/packages/create-next-app/templates/app-api/js/jsconfig.json new file mode 100644 index 0000000000000..2a2e4b3bf8ba1 --- /dev/null +++ b/packages/create-next-app/templates/app-api/js/jsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./*"] + } + } +} diff --git a/packages/create-next-app/templates/app-api/js/next.config.mjs b/packages/create-next-app/templates/app-api/js/next.config.mjs new file mode 100644 index 0000000000000..4678774e6d606 --- /dev/null +++ b/packages/create-next-app/templates/app-api/js/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/packages/create-next-app/templates/app-api/ts/README-template.md b/packages/create-next-app/templates/app-api/ts/README-template.md new file mode 100644 index 0000000000000..4f3cc28d3a821 --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/README-template.md @@ -0,0 +1,40 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/route.ts`. The page auto-updates as you edit the file. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. + +## API Routes + +This directory contains example API routes for the headless API app. + +For more details, see [route.js file convention](https://nextjs.org/docs/app/api-reference/file-conventions/route). diff --git a/packages/create-next-app/templates/app-api/ts/app/[slug]/route.ts b/packages/create-next-app/templates/app-api/ts/app/[slug]/route.ts new file mode 100644 index 0000000000000..48fc8572c4864 --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/app/[slug]/route.ts @@ -0,0 +1,9 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> }, +) { + const { slug } = await params; + return NextResponse.json({ message: `Hello ${slug}!` }); +} diff --git a/packages/create-next-app/templates/app-api/ts/app/route.ts b/packages/create-next-app/templates/app-api/ts/app/route.ts new file mode 100644 index 0000000000000..5c2378c0afc74 --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/app/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export async function GET() { + return NextResponse.json({ message: "Hello world!" }); +} diff --git a/packages/create-next-app/templates/app-api/ts/gitignore b/packages/create-next-app/templates/app-api/ts/gitignore new file mode 100644 index 0000000000000..26b002aac1dd1 --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/gitignore @@ -0,0 +1,40 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env files (can opt-in for commiting if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/create-next-app/templates/app-api/ts/next-env.d.ts b/packages/create-next-app/templates/app-api/ts/next-env.d.ts new file mode 100644 index 0000000000000..4f11a03dc6cc3 --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/create-next-app/templates/app-api/ts/next.config.ts b/packages/create-next-app/templates/app-api/ts/next.config.ts new file mode 100644 index 0000000000000..e9ffa3083ad27 --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ +}; + +export default nextConfig; diff --git a/packages/create-next-app/templates/app-api/ts/tsconfig.json b/packages/create-next-app/templates/app-api/ts/tsconfig.json new file mode 100644 index 0000000000000..d8b93235f205e --- /dev/null +++ b/packages/create-next-app/templates/app-api/ts/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/create-next-app/templates/index.ts b/packages/create-next-app/templates/index.ts index 1c86897706411..34880b4b115d0 100644 --- a/packages/create-next-app/templates/index.ts +++ b/packages/create-next-app/templates/index.ts @@ -51,6 +51,7 @@ export const installTemplate = async ({ * Copy the template files to the target directory. */ console.log("\nInitializing project with template:", template, "\n"); + const isApi = template === "app-api"; const templatePath = path.join(__dirname, template, mode); const copySource = ["**"]; if (!eslint) copySource.push("!eslint.config.mjs"); @@ -146,35 +147,37 @@ export const installTemplate = async ({ }), ); - const isAppTemplate = template.startsWith("app"); + if (!isApi) { + const isAppTemplate = template.startsWith("app"); - // Change the `Get started by editing pages/index` / `app/page` to include `src` - const indexPageFile = path.join( - "src", - isAppTemplate ? "app" : "pages", - `${isAppTemplate ? "page" : "index"}.${mode === "ts" ? "tsx" : "js"}`, - ); - - await fs.writeFile( - indexPageFile, - (await fs.readFile(indexPageFile, "utf8")).replace( - isAppTemplate ? "app/page" : "pages/index", - isAppTemplate ? "src/app/page" : "src/pages/index", - ), - ); - - if (tailwind) { - const tailwindConfigFile = path.join( - root, - mode === "ts" ? "tailwind.config.ts" : "tailwind.config.mjs", + // Change the `Get started by editing pages/index` / `app/page` to include `src` + const indexPageFile = path.join( + "src", + isAppTemplate ? "app" : "pages", + `${isAppTemplate ? "page" : "index"}.${mode === "ts" ? "tsx" : "js"}`, ); + await fs.writeFile( - tailwindConfigFile, - (await fs.readFile(tailwindConfigFile, "utf8")).replace( - /\.\/(\w+)\/\*\*\/\*\.\{js,ts,jsx,tsx,mdx\}/g, - "./src/$1/**/*.{js,ts,jsx,tsx,mdx}", + indexPageFile, + (await fs.readFile(indexPageFile, "utf8")).replace( + isAppTemplate ? "app/page" : "pages/index", + isAppTemplate ? "src/app/page" : "src/pages/index", ), ); + + if (tailwind) { + const tailwindConfigFile = path.join( + root, + mode === "ts" ? "tailwind.config.ts" : "tailwind.config.mjs", + ); + await fs.writeFile( + tailwindConfigFile, + (await fs.readFile(tailwindConfigFile, "utf8")).replace( + /\.\/(\w+)\/\*\*\/\*\.\{js,ts,jsx,tsx,mdx\}/g, + "./src/$1/**/*.{js,ts,jsx,tsx,mdx}", + ), + ); + } } } @@ -236,6 +239,20 @@ export const installTemplate = async ({ }; } + if (isApi) { + delete packageJson.dependencies.react; + delete packageJson.dependencies["react-dom"]; + // We cannot delete `@types/react` now since it is used in + // route type definitions e.g. `.next/types/app/page.ts`. + // TODO(jiwon): Implement this when we added logic to + // auto-install `react` and `react-dom` if page.tsx was used. + // We can achieve this during verify-typescript stage and see + // if a type error was thrown at `distDir/types/app/page.ts`. + delete packageJson.devDependencies["@types/react-dom"]; + + delete packageJson.scripts.lint; + } + const devDeps = Object.keys(packageJson.devDependencies).length; if (!devDeps) delete packageJson.devDependencies; diff --git a/packages/create-next-app/templates/types.ts b/packages/create-next-app/templates/types.ts index 2e6a0b0ff8472..79d1bc9370172 100644 --- a/packages/create-next-app/templates/types.ts +++ b/packages/create-next-app/templates/types.ts @@ -2,6 +2,7 @@ import { PackageManager } from "../helpers/get-pkg-manager"; export type TemplateType = | "app" + | "app-api" | "app-empty" | "app-tw" | "app-tw-empty" diff --git a/test/integration/create-next-app/lib/specification.ts b/test/integration/create-next-app/lib/specification.ts index 66421b670516b..7618c7796c522 100644 --- a/test/integration/create-next-app/lib/specification.ts +++ b/test/integration/create-next-app/lib/specification.ts @@ -172,6 +172,23 @@ export const projectSpecification: ProjectSpecification = { ], }, }, + 'app-api': { + js: { + deps: ['next'], + devDeps: [], + files: ['app/route.js', 'app/[slug]/route.js', 'jsconfig.json'], + }, + ts: { + deps: ['next'], + devDeps: ['@types/node', '@types/react', 'typescript'], + files: [ + 'app/route.ts', + 'app/[slug]/route.ts', + 'tsconfig.json', + 'next-env.d.ts', + ], + }, + }, 'app-empty': { js: { deps: [], diff --git a/test/integration/create-next-app/templates/app-api.test.ts b/test/integration/create-next-app/templates/app-api.test.ts new file mode 100644 index 0000000000000..4e3a25c033a1a --- /dev/null +++ b/test/integration/create-next-app/templates/app-api.test.ts @@ -0,0 +1,186 @@ +import { join } from 'node:path' +import { + projectShouldHaveNoGitChanges, + tryNextDev, + run, + useTempDir, + projectFilesShouldExist, +} from '../utils' +import { mapSrcFiles, projectSpecification } from '../lib/specification' +import { projectDepsShouldBe } from '../lib/utils' + +function shouldBeApiTemplateProject({ + cwd, + projectName, + mode, + srcDir, +}: { + cwd: string + projectName: string + mode: 'js' | 'ts' + srcDir?: boolean +}) { + const template = 'app-api' + + projectFilesShouldExist({ + cwd, + projectName, + files: mapSrcFiles(projectSpecification[template][mode].files, srcDir), + }) + + projectDepsShouldBe({ + type: 'dependencies', + cwd, + projectName, + deps: mapSrcFiles(projectSpecification[template][mode].deps, srcDir), + }) + + projectDepsShouldBe({ + type: 'devDependencies', + cwd, + projectName, + deps: mapSrcFiles(projectSpecification[template][mode].devDeps, srcDir), + }) +} + +describe('create-next-app --api (Headless App)', () => { + let nextTgzFilename: string + + beforeAll(() => { + if (!process.env.NEXT_TEST_PKG_PATHS) { + throw new Error('This test needs to be run with `node run-tests.js`.') + } + + const pkgPaths = new Map( + JSON.parse(process.env.NEXT_TEST_PKG_PATHS) + ) + + nextTgzFilename = pkgPaths.get('next')! + }) + + it('should create JavaScript project with --js flag', async () => { + await useTempDir(async (cwd) => { + const projectName = 'app-js' + const { exitCode } = await run( + [ + projectName, + '--js', + '--api', + '--no-turbopack', + '--no-src-dir', + '--no-import-alias', + ], + nextTgzFilename, + { + cwd, + } + ) + + expect(exitCode).toBe(0) + shouldBeApiTemplateProject({ + cwd, + projectName, + mode: 'js', + }) + await tryNextDev({ + cwd, + isApi: true, + projectName, + }) + }) + }) + + it('should create TypeScript project with --ts flag', async () => { + await useTempDir(async (cwd) => { + const projectName = 'app-ts' + const { exitCode } = await run( + [ + projectName, + '--ts', + '--api', + '--no-turbopack', + '--no-src-dir', + '--no-import-alias', + ], + nextTgzFilename, + { + cwd, + } + ) + + expect(exitCode).toBe(0) + shouldBeApiTemplateProject({ + cwd, + projectName, + mode: 'ts', + }) + await tryNextDev({ cwd, isApi: true, projectName }) + projectShouldHaveNoGitChanges({ cwd, projectName }) + }) + }) + + it('should create project inside "src" directory with --src-dir flag', async () => { + await useTempDir(async (cwd) => { + const projectName = 'app-src-dir' + const { exitCode } = await run( + [ + projectName, + '--ts', + '--api', + '--no-turbopack', + '--src-dir', + '--no-import-alias', + ], + nextTgzFilename, + { + cwd, + stdio: 'inherit', + } + ) + + expect(exitCode).toBe(0) + shouldBeApiTemplateProject({ + cwd, + projectName, + mode: 'ts', + srcDir: true, + }) + await tryNextDev({ + cwd, + isApi: true, + projectName, + }) + }) + }) + + it('should enable turbopack dev with --turbopack flag', async () => { + await useTempDir(async (cwd) => { + const projectName = 'app-turbo' + const { exitCode } = await run( + [ + projectName, + '--ts', + '--api', + '--turbopack', + '--no-src-dir', + '--no-import-alias', + ], + nextTgzFilename, + { + cwd, + } + ) + + expect(exitCode).toBe(0) + const projectRoot = join(cwd, projectName) + const pkgJson = require(join(projectRoot, 'package.json')) + expect(pkgJson.scripts.dev).toBe('next dev --turbopack') + + await tryNextDev({ + cwd, + isApi: true, + projectName, + }) + }) + }) +}) diff --git a/test/integration/create-next-app/utils.ts b/test/integration/create-next-app/utils.ts index c0097e5d6af06..1b59e00ad0094 100644 --- a/test/integration/create-next-app/utils.ts +++ b/test/integration/create-next-app/utils.ts @@ -46,11 +46,13 @@ export async function tryNextDev({ cwd, projectName, isApp = true, + isApi = false, isEmpty = false, }: { cwd: string projectName: string isApp?: boolean + isApi?: boolean isEmpty?: boolean }) { const dir = join(cwd, projectName) @@ -61,7 +63,7 @@ export async function tryNextDev({ try { const res = await fetchViaHTTP(port, '/') - if (isEmpty) { + if (isEmpty || isApi) { expect(await res.text()).toContain('Hello world!') } else { expect(await res.text()).toContain('Get started by editing')