Server response:
+{message}
+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 Server response: {message}