Skip to content

Commit e3aee40

Browse files
authored
Ensure Next.js outputs static exports at build time (#69)
* Ensure Next.js outputs static exports at build time * Revise error message to describe alternative heroku/nodejs configuration. * Update static-web-server config: runtime config globs all HTML files, use Next's 404 page, enable clean URLs, and augment the integration test.
1 parent 6f4feda commit e3aee40

File tree

5 files changed

+154
-4
lines changed

5 files changed

+154
-4
lines changed

buildpacks/website-nextjs/README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* At build:
44
* Detects `next` in the app's `package.json` dependencies.
55
* Requires `heroku/nodejs` installation and build.
6-
* Configures `heroku/static-web-server` for `next`'s output.
6+
* Configures `heroku/static-web-server` for `next`'s default output to `out/`.
77

88
## Usage
99

@@ -13,6 +13,14 @@ Create an app with [Next.js](https://nextjs.org/):
1313
npx create-next-app@latest
1414
```
1515

16+
Configure the Next.js app to for [static exports](https://nextjs.org/docs/app/guides/static-exports). Set the output mode in `next.config.js`:
17+
18+
```javascript
19+
const nextConfig = {
20+
output: 'export'
21+
}
22+
```
23+
1624
Now, the app should be ready to build, with Next.js auto-detected by `heroku/builder`:
1725

1826
```bash

buildpacks/website-nextjs/src/errors.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ locally with a minimal example and open an issue in the buildpack's GitHub repos
1414
#[derive(Debug)]
1515
pub(crate) enum WebsiteNextjsBuildpackError {
1616
Detect(io::Error),
17+
NextInfoCommandError(io::Error),
18+
NextInfoFailure(String),
1719
ReadPackageJson(io::Error),
20+
RequiresStaticExport,
1821
ParsePackageJson(serde_json::Error),
1922
SettingBuildPlanMetadata(toml::ser::Error),
2023
}
@@ -58,13 +61,48 @@ fn buildpack_error_message(error: WebsiteNextjsBuildpackError) -> ErrorMessage {
5861
error_string: e.to_string(),
5962
error_id: "detect_error".to_string(),
6063
},
64+
WebsiteNextjsBuildpackError::NextInfoCommandError(e) => ErrorMessage {
65+
message: formatdoc! {"
66+
Error executing `next info` in {buildpack_name}.
67+
", buildpack_name = style::value(BUILDPACK_NAME) },
68+
error_string: e.to_string(),
69+
error_id: "next_info_command_error".to_string(),
70+
},
71+
WebsiteNextjsBuildpackError::NextInfoFailure(m) => ErrorMessage {
72+
message: formatdoc! {"
73+
`next info` command failed in {buildpack_name}.
74+
", buildpack_name = style::value(BUILDPACK_NAME) },
75+
error_string: m,
76+
error_id: "next_info_failure".to_string(),
77+
},
6178
WebsiteNextjsBuildpackError::ReadPackageJson(e) => ErrorMessage {
6279
message: formatdoc! {"
6380
Error reading package.json from {buildpack_name}.
6481
", buildpack_name = style::value(BUILDPACK_NAME) },
6582
error_string: e.to_string(),
6683
error_id: "read_package_json_error".to_string(),
6784
},
85+
WebsiteNextjsBuildpackError::RequiresStaticExport => ErrorMessage {
86+
message: formatdoc! {r#"
87+
{buildpack_name} requires `output: 'export'` set in `next.config.js`.
88+
89+
This buildpack deploys Next.js as a static website, for enhanced
90+
performance and security. Some features of Next.js are not supported
91+
in static mode. More info at:
92+
93+
https://nextjs.org/docs/app/guides/static-exports
94+
95+
You may choose to build a Node.js app, instead of `output: 'export'`.
96+
Set or replace the buildpacks in the `project.toml` file, and then
97+
retry the build:
98+
99+
[[io.buildpacks.group]]
100+
id = "heroku/nodejs"
101+
102+
"#, buildpack_name = style::value(BUILDPACK_NAME) },
103+
error_string: "Next.js build output must target static exports".to_string(),
104+
error_id: "requires_static_export_error".to_string(),
105+
},
68106
WebsiteNextjsBuildpackError::ParsePackageJson(e) => ErrorMessage {
69107
message: formatdoc! {"
70108
Error parsing package.json from {buildpack_name}.

buildpacks/website-nextjs/src/main.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
mod errors;
22
mod o11y;
33

4+
use std::process::Command;
5+
46
use crate::errors::{on_error, WebsiteNextjsBuildpackError};
57
use crate::o11y::*;
68
use libcnb::build::{BuildContext, BuildResult, BuildResultBuilder};
@@ -56,14 +58,21 @@ impl Buildpack for WebsiteNextjsBuildpack {
5658

5759
let mut static_web_server_req = Require::new("static-web-server");
5860

61+
// Following Next.js static deployment guidance
62+
// https://nextjs.org/docs/app/guides/static-exports
5963
static_web_server_req
6064
.metadata(toml! {
6165
root = "/workspace/out"
6266
index = "index.html"
6367

68+
[runtime_config]
69+
html_files = ["**/*.html"]
70+
6471
[errors.404]
65-
file_path = "_not-found.html"
66-
status = 404
72+
file_path = "404.html"
73+
74+
[caddy_server_opts]
75+
clean_urls = true
6776
})
6877
.map_err(WebsiteNextjsBuildpackError::SettingBuildPlanMetadata)?;
6978

@@ -90,6 +99,27 @@ impl Buildpack for WebsiteNextjsBuildpack {
9099

91100
fn build(&self, _context: BuildContext<Self>) -> libcnb::Result<BuildResult, Self::Error> {
92101
log_header(BUILDPACK_NAME);
102+
103+
// Ensure comptibility with the static web server,
104+
// that Next's build output is set to static exports.
105+
let mut cmd = Command::new("sh");
106+
cmd.args(["-c", "npm exec next info"]);
107+
let next_info = cmd
108+
.output()
109+
.map_err(WebsiteNextjsBuildpackError::NextInfoCommandError)?;
110+
let next_info_stdout = String::from_utf8_lossy(&next_info.stdout);
111+
let next_info_stderr = String::from_utf8_lossy(&next_info.stderr);
112+
if !next_info.status.success() {
113+
return Err(libcnb::Error::BuildpackError(
114+
WebsiteNextjsBuildpackError::NextInfoFailure(next_info_stderr.to_string()),
115+
));
116+
}
117+
if !next_info_stdout.contains(&"output: export".to_string()) {
118+
return Err(libcnb::Error::BuildpackError(
119+
WebsiteNextjsBuildpackError::RequiresStaticExport,
120+
));
121+
}
122+
93123
BuildResultBuilder::new().build()
94124
}
95125

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import Image from "next/image";
2+
3+
export default function Home() {
4+
return (
5+
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6+
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7+
<Image
8+
className="dark:invert"
9+
src="/next.svg"
10+
alt="Next.js logo"
11+
width={100}
12+
height={20}
13+
priority
14+
/>
15+
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16+
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17+
A Nested Page
18+
</h1>
19+
</div>
20+
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
21+
<a
22+
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
23+
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
24+
target="_blank"
25+
rel="noopener noreferrer"
26+
>
27+
<Image
28+
className="dark:invert"
29+
src="/vercel.svg"
30+
alt="Vercel logomark"
31+
width={16}
32+
height={16}
33+
/>
34+
Deploy Now
35+
</a>
36+
<a
37+
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
38+
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
39+
target="_blank"
40+
rel="noopener noreferrer"
41+
>
42+
Documentation
43+
</a>
44+
</div>
45+
</main>
46+
</div>
47+
);
48+
}

buildpacks/website-nextjs/tests/integration_test.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,34 @@ fn nextjs_app() {
2424
})
2525
.unwrap();
2626
let response_body = response.into_string().unwrap();
27+
assert_contains!(response_body, "Generated by create next app");
28+
assert_contains!(response_body, "To get started");
2729

28-
assert_contains!(response_body, "Create Next App");
30+
let second_response = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || {
31+
ureq::get(&format!("http://{socket_addr}/nested")).call()
32+
})
33+
.unwrap();
34+
let second_response_body = second_response.into_string().unwrap();
35+
assert_contains!(response_body, "Generated by create next app");
36+
assert_contains!(second_response_body, "A Nested Page");
37+
38+
let not_found_result = retry(DEFAULT_RETRIES, DEFAULT_RETRY_DELAY, || {
39+
ureq::get(&format!("http://{socket_addr}/non-existent-path")).call()
40+
});
41+
match not_found_result {
42+
Err(ureq::Error::Status(code, response)) => {
43+
assert_eq!(code, 404);
44+
let response_body = response.into_string().unwrap();
45+
assert_contains!(response_body, "Generated by create next app");
46+
assert_contains!(response_body, "This page could not be found.");
47+
}
48+
Ok(_) => {
49+
panic!("should respond 404 Not Found, but got 200 ok");
50+
}
51+
Err(error) => {
52+
panic!("should respond 404 Not Found, but got other error: {error:?}");
53+
}
54+
}
2955
},
3056
);
3157
});

0 commit comments

Comments
 (0)