diff --git a/README.md b/README.md index 652b7fee9..f6ed28a4b 100644 --- a/README.md +++ b/README.md @@ -137,8 +137,9 @@ You can now use the `--advanced` flag when running the `create` command to get a - [Tailwind](https://tailwindcss.com/) css framework - Docker configuration for go project - [React](https://react.dev/) frontend written in TypeScript, including an example fetch request to the backend +- [Next.js](https://nextjs.org/) frontend (App Router + TypeScript) with [Tailwind CSS](https://tailwindcss.com/) and [shadcn/ui](https://ui.shadcn.com/) pre-wired, including an example fetch to the Go backend -Note: Selecting Tailwind option will automatically select HTMX unless React is explicitly selected +Note: Selecting Tailwind option will automatically select HTMX unless React or Next.js is explicitly selected. React, Next.js, and HTMX are mutually exclusive — only one frontend option can be chosen at a time. @@ -214,10 +215,18 @@ React: go-blueprint create --advanced --feature react ``` -Or all features at once: +Next.js (App Router, TypeScript, Tailwind, shadcn/ui — all bundled): ```bash -go-blueprint create --name my-project --framework chi --driver mysql --advanced --feature htmx --feature githubaction --feature websocket --feature tailwind --feature docker --git commit --feature react +go-blueprint create --advanced --feature nextjs +``` + +The Next.js scaffold runs `create-next-app@latest` with `--ts --app --eslint --src-dir --tailwind --use-npm --import-alias "@/*"`, then initializes shadcn/ui (`init -d`) and adds the `button` and `card` components. A `frontend/.env.local` is created with `NEXT_PUBLIC_API_URL` pointing at the Go backend, and `next.config.mjs` is configured with `output: "standalone"` plus an `/api/*` → Go rewrite so server-side fetches can hit the backend without CORS. When combined with `--feature docker`, the frontend runs as its own compose service on port `3000`. + +Or all features at once (picking Next.js as the frontend): + +```bash +go-blueprint create --name my-project --framework chi --driver mysql --advanced --feature nextjs --feature githubaction --feature websocket --feature docker --git commit ```

diff --git a/cmd/create.go b/cmd/create.go index fd2fe065f..14a13da5f 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -285,6 +285,15 @@ var createCmd = &cobra.Command{ fmt.Println(endingMsgStyle.Render("\nNext steps:")) fmt.Println(endingMsgStyle.Render(fmt.Sprintf("• cd into the newly created project with: `cd %s`\n", utils.GetRootDir(project.ProjectName)))) + if options.Advanced.Choices["Nextjs"] { + options.Advanced.Choices["React"] = false + options.Advanced.Choices["Htmx"] = false + options.Advanced.Choices["Tailwind"] = false + fmt.Println(endingMsgStyle.Render("• cd into frontend\n")) + fmt.Println(endingMsgStyle.Render("• npm install\n")) + fmt.Println(endingMsgStyle.Render("• npm run dev\n")) + } + if options.Advanced.Choices["React"] { options.Advanced.Choices["Htmx"] = false options.Advanced.Choices["Tailwind"] = false diff --git a/cmd/flags/advancedFeatures.go b/cmd/flags/advancedFeatures.go index f97d629e3..d70c895af 100644 --- a/cmd/flags/advancedFeatures.go +++ b/cmd/flags/advancedFeatures.go @@ -13,10 +13,11 @@ const ( Websocket string = "websocket" Tailwind string = "tailwind" React string = "react" + Nextjs string = "nextjs" Docker string = "docker" ) -var AllowedAdvancedFeatures = []string{string(React), string(Htmx), string(GoProjectWorkflow), string(Websocket), string(Tailwind), string(Docker)} +var AllowedAdvancedFeatures = []string{string(React), string(Nextjs), string(Htmx), string(GoProjectWorkflow), string(Websocket), string(Tailwind), string(Docker)} func (f AdvancedFeatures) String() string { return strings.Join(f, ",") diff --git a/cmd/program/program.go b/cmd/program/program.go index a615b5aa9..83282e6e1 100644 --- a/cmd/program/program.go +++ b/cmd/program/program.go @@ -407,6 +407,17 @@ func (p *Project) CreateMainFile() error { return err } + if p.AdvancedOptions[string(flags.Nextjs)] { + // deselect htmx/react since nextjs is selected + p.AdvancedOptions[string(flags.Htmx)] = false + p.AdvancedOptions[string(flags.React)] = false + if err := p.CreateNextJSProject(projectPath); err != nil { + return fmt.Errorf("failed to set up Next.js project: %w", err) + } + // tailwind is baked into the Next scaffold; skip downstream tailwind-for-htmx block + p.AdvancedOptions[string(flags.Tailwind)] = false + } + if p.AdvancedOptions[string(flags.React)] { // deselect htmx option automatically since react is selected p.AdvancedOptions[string(flags.Htmx)] = false @@ -932,6 +943,100 @@ func (p *Project) CreateViteReactProject(projectPath string) error { return nil } +func (p *Project) CreateNextJSProject(projectPath string) error { + if err := checkNpmInstalled(); err != nil { + return err + } + + originalDir, err := os.Getwd() + if err != nil { + return fmt.Errorf("failed to get current directory: %w", err) + } + defer func() { + if err := os.Chdir(originalDir); err != nil { + fmt.Fprintf(os.Stderr, "failed to change back to original directory: %v\n", err) + } + }() + + if err := os.Chdir(projectPath); err != nil { + return fmt.Errorf("failed to change into project directory: %w", err) + } + + fmt.Println("Scaffolding Next.js app with create-next-app...") + cmd := exec.Command("npx", "--yes", "create-next-app@latest", "frontend", + "--ts", + "--app", + "--eslint", + "--src-dir", + "--tailwind", + "--use-npm", + "--import-alias", "@/*", + "--no-turbopack", + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to run create-next-app: %w", err) + } + + frontendPath := filepath.Join(projectPath, "frontend") + if err := os.Chdir(frontendPath); err != nil { + return fmt.Errorf("failed to change to frontend directory: %w", err) + } + + // Create global .env so we can read the backend PORT. + if err := p.CreateFileWithInjection("", projectPath, ".env", "env"); err != nil { + return fmt.Errorf("failed to create global .env file: %w", err) + } + + backendPort := "8080" + if data, readErr := os.ReadFile(filepath.Join(projectPath, ".env")); readErr == nil { + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "PORT=") { + backendPort = strings.SplitN(line, "=", 2)[1] + break + } + } + } + + frontendEnvContent := fmt.Sprintf("NEXT_PUBLIC_API_URL=http://localhost:%s\n", backendPort) + if err := os.WriteFile(filepath.Join(frontendPath, ".env.local"), []byte(frontendEnvContent), 0644); err != nil { + return fmt.Errorf("failed to create frontend .env.local: %w", err) + } + + // Overwrite next.config with one that proxies /api/* to the Go backend and enables standalone output. + nextConfigPath := filepath.Join(frontendPath, "next.config.mjs") + if err := os.WriteFile(nextConfigPath, advanced.NextJSConfigFile(), 0644); err != nil { + return fmt.Errorf("failed to write next.config.mjs: %w", err) + } + // create-next-app may emit next.config.ts as well — remove it so .mjs wins. + _ = os.Remove(filepath.Join(frontendPath, "next.config.ts")) + + // Initialize shadcn/ui with defaults and add baseline components. + fmt.Println("Initializing shadcn/ui...") + initCmd := exec.Command("npx", "--yes", "shadcn@latest", "init", "-d", "-y") + initCmd.Stdout = os.Stdout + initCmd.Stderr = os.Stderr + if err := initCmd.Run(); err != nil { + return fmt.Errorf("failed to init shadcn/ui: %w", err) + } + + addCmd := exec.Command("npx", "--yes", "shadcn@latest", "add", "button", "card", "-y") + addCmd.Stdout = os.Stdout + addCmd.Stderr = os.Stderr + if err := addCmd.Run(); err != nil { + return fmt.Errorf("failed to add shadcn components: %w", err) + } + + // Overwrite the starter page with one that uses our shadcn Button + Card and fetches from Go. + pagePath := filepath.Join(frontendPath, "src", "app", "page.tsx") + if err := os.WriteFile(pagePath, advanced.NextJSPageFile(), 0644); err != nil { + return fmt.Errorf("failed to write page.tsx template: %w", err) + } + + return nil +} + func (p *Project) CreateHtmxTemplates() { routesPlaceHolder := "" importsPlaceHolder := "" diff --git a/cmd/steps/steps.go b/cmd/steps/steps.go index eacb874bc..9bcd2e93c 100644 --- a/cmd/steps/steps.go +++ b/cmd/steps/steps.go @@ -99,7 +99,12 @@ func InitSteps(projectType flags.Framework, databaseType flags.Database) *Steps { Flag: "React", Title: "React", - Desc: "Use Vite to spin up a React project in TypeScript. This disables selecting HTMX/Templ", + Desc: "Use Vite to spin up a React project in TypeScript. This disables selecting HTMX/Templ and Next.js", + }, + { + Flag: "Nextjs", + Title: "Next.js", + Desc: "Scaffold a Next.js app (App Router, TypeScript, Tailwind, shadcn/ui). This disables selecting HTMX/Templ and React", }, { Flag: "Htmx", diff --git a/cmd/template/advanced/files/docker/docker_compose.yml.tmpl b/cmd/template/advanced/files/docker/docker_compose.yml.tmpl index 48edb7b49..04220b5da 100644 --- a/cmd/template/advanced/files/docker/docker_compose.yml.tmpl +++ b/cmd/template/advanced/files/docker/docker_compose.yml.tmpl @@ -27,6 +27,20 @@ services: depends_on: - app {{- end }} +{{- if .AdvancedOptions.nextjs }} + frontend: + build: + context: . + dockerfile: Dockerfile + target: frontend + restart: unless-stopped + ports: + - 3000:3000 + environment: + NEXT_PUBLIC_API_URL: http://app:${PORT} + depends_on: + - app +{{- end }} {{- if and (.AdvancedOptions.docker) (eq .DBDriver "sqlite") }} volumes: diff --git a/cmd/template/advanced/files/docker/dockerfile.tmpl b/cmd/template/advanced/files/docker/dockerfile.tmpl index 5690f6171..b002bcadf 100644 --- a/cmd/template/advanced/files/docker/dockerfile.tmpl +++ b/cmd/template/advanced/files/docker/dockerfile.tmpl @@ -44,3 +44,23 @@ COPY --from=frontend_builder /frontend/dist /app/dist EXPOSE 5173 CMD ["serve", "-s", "/app/dist", "-l", "5173"] {{- end}} + +{{ if .AdvancedOptions.nextjs}} +FROM node:20 AS frontend_builder +WORKDIR /frontend + +COPY frontend/package*.json ./ +RUN npm ci +COPY frontend/. . +RUN npm run build + +FROM node:20-slim AS frontend +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +COPY --from=frontend_builder /frontend/.next/standalone ./ +COPY --from=frontend_builder /frontend/.next/static ./.next/static +COPY --from=frontend_builder /frontend/public ./public +EXPOSE 3000 +CMD ["node", "server.js"] +{{- end}} diff --git a/cmd/template/advanced/files/nextjs/next.config.mjs.tmpl b/cmd/template/advanced/files/nextjs/next.config.mjs.tmpl new file mode 100644 index 000000000..01a02149b --- /dev/null +++ b/cmd/template/advanced/files/nextjs/next.config.mjs.tmpl @@ -0,0 +1,15 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + output: "standalone", + async rewrites() { + const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080" + return [ + { + source: "/api/:path*", + destination: `${apiUrl}/:path*`, + }, + ] + }, +} + +export default nextConfig diff --git a/cmd/template/advanced/files/nextjs/page.tsx.tmpl b/cmd/template/advanced/files/nextjs/page.tsx.tmpl new file mode 100644 index 000000000..428753904 --- /dev/null +++ b/cmd/template/advanced/files/nextjs/page.tsx.tmpl @@ -0,0 +1,56 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export default function Home() { + const [message, setMessage] = useState("") + const [loading, setLoading] = useState(false) + + const apiUrl = + process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080" + + const fetchData = async () => { + setLoading(true) + try { + const res = await fetch(`${apiUrl}/`) + setMessage(await res.text()) + } catch (err) { + console.error("Error fetching data:", err) + setMessage("Failed to reach Go backend") + } finally { + setLoading(false) + } + } + + return ( +

+ + + Next.js + Go Blueprint + + Talking to the Go backend at {apiUrl} + + + + + {message && ( +
+

Server response:

+

{message}

+
+ )} +
+
+
+ ) +} diff --git a/cmd/template/advanced/routes.go b/cmd/template/advanced/routes.go index a671b5e7f..259a52bc0 100644 --- a/cmd/template/advanced/routes.go +++ b/cmd/template/advanced/routes.go @@ -22,6 +22,12 @@ var reactTailwindAppFile []byte //go:embed files/react/app.tsx.tmpl var reactAppFile []byte +//go:embed files/nextjs/page.tsx.tmpl +var nextjsPageFile []byte + +//go:embed files/nextjs/next.config.mjs.tmpl +var nextjsConfigFile []byte + //go:embed files/tailwind/input.css.tmpl var inputCssTemplate []byte @@ -127,6 +133,14 @@ func ReactAppfile() []byte { return reactAppFile } +func NextJSPageFile() []byte { + return nextjsPageFile +} + +func NextJSConfigFile() []byte { + return nextjsConfigFile +} + func InputCssTemplateReact() []byte { return inputCssTemplateReact } diff --git a/cmd/template/framework/files/makefile.tmpl b/cmd/template/framework/files/makefile.tmpl index 7e10b2548..2387abc85 100644 --- a/cmd/template/framework/files/makefile.tmpl +++ b/cmd/template/framework/files/makefile.tmpl @@ -47,15 +47,15 @@ tailwind-install: @if not exist tailwindcss.exe powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri 'https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-windows-x64.exe' -OutFile 'tailwindcss.exe'"{{- end }} {{- end }} -build:{{- if and .AdvancedOptions.tailwind (not .AdvancedOptions.react) }} tailwind-install{{- end }}{{- if and (or .AdvancedOptions.htmx .AdvancedOptions.tailwind) (not .AdvancedOptions.react) }} templ-install{{- end }} +build:{{- if and .AdvancedOptions.tailwind (not .AdvancedOptions.react) (not .AdvancedOptions.nextjs) }} tailwind-install{{- end }}{{- if and (or .AdvancedOptions.htmx .AdvancedOptions.tailwind) (not .AdvancedOptions.react) (not .AdvancedOptions.nextjs) }} templ-install{{- end }} @echo "Building..." - {{ if and (or .AdvancedOptions.htmx .AdvancedOptions.tailwind) (not .AdvancedOptions.react) }}@templ generate{{- end }} - {{ if and .AdvancedOptions.tailwind (not .AdvancedOptions.react) }}@{{ if .OSCheck.UnixBased }}./tailwindcss{{ else }}.\tailwindcss.exe{{ end }} -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css{{ end }} + {{ if and (or .AdvancedOptions.htmx .AdvancedOptions.tailwind) (not .AdvancedOptions.react) (not .AdvancedOptions.nextjs) }}@templ generate{{- end }} + {{ if and .AdvancedOptions.tailwind (not .AdvancedOptions.react) (not .AdvancedOptions.nextjs) }}@{{ if .OSCheck.UnixBased }}./tailwindcss{{ else }}.\tailwindcss.exe{{ end }} -i cmd/web/styles/input.css -o cmd/web/assets/css/output.css{{ end }} {{ if .OSCheck.UnixBased }}@{{- if and (.AdvancedOptions.docker) (eq .DBDriver "sqlite") }}CGO_ENABLED=1 GOOS=linux {{ end }}go build -o main cmd/api/main.go{{- else }}@go build -o main.exe cmd/api/main.go{{- end }} # Run the application run: - @go run cmd/api/main.go{{- if .AdvancedOptions.react }} & + @go run cmd/api/main.go{{- if or .AdvancedOptions.react .AdvancedOptions.nextjs }} & @npm install --prefer-offline --no-fund --prefix ./frontend @npm run dev --prefix ./frontend {{- end }} diff --git a/docs/docs/advanced-flag/nextjs.md b/docs/docs/advanced-flag/nextjs.md new file mode 100644 index 000000000..b0ff60522 --- /dev/null +++ b/docs/docs/advanced-flag/nextjs.md @@ -0,0 +1,96 @@ +The Next.js advanced flag scaffolds a modern Next.js frontend alongside your Go backend. It runs the official `create-next-app` CLI with sensible defaults and initializes [shadcn/ui](https://ui.shadcn.com/) so you have production-grade components available from the first commit. + +The Next.js flag is mutually exclusive with the React and HTMX/Templ flags — only one frontend option may be selected at a time. + +## What it generates + +`create-next-app@latest` is invoked with: + +``` +--ts --app --eslint --src-dir --tailwind --use-npm --import-alias "@/*" +``` + +Which gives you: + +- **App Router** (Next.js 13+ default) +- **TypeScript** +- **Tailwind CSS** (baked in — no separate `--feature tailwind` needed) +- **ESLint** +- **`src/` directory** layout +- **`@/*` import alias** + +On top of that, the blueprint: + +- Runs `npx shadcn@latest init -d` and adds the `button` and `card` components as a baseline. +- Overwrites `src/app/page.tsx` with a starter page that fetches from the Go backend using `process.env.NEXT_PUBLIC_API_URL`. +- Writes `frontend/.env.local` with `NEXT_PUBLIC_API_URL=http://localhost:` (read from the project's root `.env`). +- Writes `frontend/next.config.mjs` with `output: "standalone"` and a `rewrites()` rule proxying `/api/:path*` to the Go backend — useful for server-side fetches where `NEXT_PUBLIC_*` is not appropriate. + +## Project structure + +```bash +/ (Root) +├── frontend/ # Next.js advanced flag. Excludes HTMX and React. +│ ├── .env.local # NEXT_PUBLIC_API_URL pointing at the Go backend. +│ ├── next.config.mjs # standalone output + /api/* proxy to Go. +│ ├── components.json # shadcn/ui config. +│ ├── src/ +│ │ ├── app/ +│ │ │ ├── layout.tsx +│ │ │ └── page.tsx # Overwritten with a Go-backend demo page. +│ │ ├── components/ui/ # shadcn components (button, card, ...). +│ │ └── lib/utils.ts +│ ├── public/ +│ ├── tsconfig.json +│ └── package.json +``` + +## Usage + +```bash +cd frontend +npm install +npm run dev +``` + +The Next.js dev server runs on `http://localhost:3000` and talks to the Go backend on `http://localhost:8080` (or whatever `PORT` is set to). + +## Makefile + +`make run` starts the Go server and the Next.js dev server together: + +```makefile +run: + @go run cmd/api/main.go & + @npm install --prefer-offline --no-fund --prefix ./frontend + @npm run dev --prefix ./frontend +``` + +## Docker + +Combine with the `docker` feature to get a multi-stage Dockerfile and docker-compose service. The frontend service is exposed on `3000` and points at the `app` service via `NEXT_PUBLIC_API_URL=http://app:${PORT}`. Spin everything up with: + +```bash +make docker-run +``` + +## Environment variables + +| Variable | Where | Purpose | +|---|---|---| +| `PORT` | project `.env` | Go backend port | +| `NEXT_PUBLIC_API_URL` | `frontend/.env.local` | Base URL the client uses to call the Go API | + +If you change `PORT`, update `NEXT_PUBLIC_API_URL` to match. + +## Adding more shadcn components + +```bash +cd frontend +npx shadcn@latest add dialog input form +``` + +## Notes + +- The first run downloads `create-next-app` and shadcn dependencies; subsequent runs are much faster thanks to npm's cache. +- `--no-turbopack` is passed for stability; swap it out once Turbopack graduates. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b9ece5be4..dd87c34ae 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -47,6 +47,7 @@ nav: - Websocket: advanced-flag/websocket.md - Docker: advanced-flag/docker.md - React & Vite (TypeScript): advanced-flag/react-vite.md + - Next.js (App Router + shadcn/ui): advanced-flag/nextjs.md - Testing endpoints: - Server: endpoints-test/server.md - DB Health Endpoints: